Öffnungszeiten: Team-Aktionen und Ersatz-Ticket beim Abschalten

Team-Route für zeiten: Aktivieren läuft über den Gratis-Pfad (einschaltenZeiten, setup,
unlockedBy team, Neubau, Einbau-Ticket, keine K24), Abschalten setzt disabled mit Grund team
ohne Löschfrist und legt über nachAbschaltung bei geändertem Wochenplan das Ticket
"Ersatz-Tabelle aktualisieren" an. PATCH /:customerId/zeiten/team speichert die
Team-Einstellungen mit teamVersion. Die Team-Ansicht liefert für zeiten zusätzlich plaene,
team, teamVersion und hindernis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 02:39:43 +00:00
1 parent 11dc4b2c9f
commit 97e6971276
3 files changed
+780 -13

No files matched your search

+125 -13
View File
@@ -12,8 +12,12 @@ import {
listCustomerFeatureDocs,
listCustomerFeatures,
} from '../../services/siteFeatures.js'
import { featureHaken } from '../../services/featureRegistry.js'
import { nachAbschaltung } from '../../services/zeiten/abschalten.js'
import { einschaltenZeiten } from '../../services/zeiten/einschalten.js'
import { ersatzHtml } from '../../services/zeiten/ersatz.js'
import { ladeZeiten } from '../../services/zeiten/speicher.js'
import { normalisiereTeam } from '../../services/zeiten/pruefen.js'
import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js'
import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js'
/**
@@ -49,6 +53,29 @@ async function ticketExists(ticketId) {
}
}
/**
* Zusatzfelder der Zeiten fuer das WOMS-Panel (Spec 8): plaene [{ kurz, name, haupt }] (Hauptplan zuerst, sonst in gespeicherter
* Reihenfolge; Kurznamen fuer data-plan und "Ersatz-HTML kopieren"), team und teamVersion (Team-Einstellungen mit ihrer Version fuer
* PATCH .../zeiten/team) und hindernis: 'zeiten_fehlen', solange die Einrichtung (setup) auf Zeiten des Kunden wartet, sonst null.
* Gelesen wird nur, wo es Daten geben kann (setup, active, disabled); sonst Vorgaben. hindernis fragt den Register-Haken freischaltbar
* (dieselbe Regel wie der 5-Minuten-Job, fail-closed). Wirft bei Lesefehlern, wie der Rest der Ansicht.
*/
async function zeitenZusatz(doc) {
const mitDaten = doc && (doc.state === 'setup' || doc.state === 'active' || doc.state === 'disabled')
const gespeichert = mitDaten ? await ladeZeiten(doc.$id) : null
const plaene = (Array.isArray(gespeichert?.daten?.plaene) ? gespeichert.daten.plaene : [])
.filter((p) => p && typeof p.kurz === 'string' && p.kurz !== '')
.map((p) => ({ kurz: p.kurz, name: typeof p.name === 'string' ? p.name : '', haupt: p.haupt === true }))
const { freischaltbar } = featureHaken('zeiten')
const wartet = doc?.state === 'setup' && typeof freischaltbar === 'function' && (await freischaltbar(doc)) !== true
return {
plaene: [...plaene.filter((p) => p.haupt), ...plaene.filter((p) => !p.haupt)],
team: gespeichert?.team ?? normalisiereTeam({}).team,
teamVersion: gespeichert?.teamVersion ?? 0,
hindernis: wartet ? 'zeiten_fehlen' : null,
}
}
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId)
@@ -59,6 +86,7 @@ async function adminView(customerId) {
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
view.adminNote = doc?.adminNote || ''
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId)))
if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc))
}
}
return projects
@@ -101,7 +129,40 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => {
}
})
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
/**
* Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team'
* und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument
* entsteht wie bei den anderen Funktionen (Zeit, Name, Aktion, Text).
* Sperren: einschaltenZeiten nimmt fuer die Zustandsentscheidung selbst `feature-order:<kunde>:<projekt>` (dieselbe wie im Checkout)
* und ist fuer das Team idempotent; Neubau und Ticket laufen danach bewusst ohne Sperre. Hier wird `feature-admin:<projekt>` nicht
* gehalten: sie wuerde dem Team (und dem Abo-Abgleich) fuer die Dauer von Neubau und Ticket jede Aktion an diesem Projekt sperren,
* ohne etwas zu schuetzen, denn holeZeitenNach liest den Zustand frisch und laesst ein inzwischen abgeschaltetes Dokument unberuehrt.
*/
async function zeitenAktivieren({ customerId, projectId, by, note }) {
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
const adminNote = composeAdminNote(bisher?.adminNote, { by, action: 'activate', note })
await einschaltenZeiten({ customerId, projectId, by: 'team', staff: { name: by, adminNote } })
}
/**
* Oeffnungszeiten live abschalten: disabled mit Grund team, ohne Loeschfrist (loeschungAm bleibt null, die Daten bleiben).
* Zustand und Notiz unter `feature-admin:<projekt>`; das Ersatz-Ticket (nachAbschaltung, Netzwerk, wirft nie) danach ausserhalb der
* Sperre. Es laeuft bei jedem Aufruf, auch bei einem Doppelklick: ein schon angelegtes Ticket merkt sich der Ersatz-Hash.
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>}
*/
async function zeitenAbschalten({ customerId, projectId, by, note }) {
const doc = await withLocks([`feature-admin:${projectId}`], async () => {
const bisher = await getFeatureDoc(customerId, projectId, 'zeiten')
if (!bisher) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}`), { status: 409 })
}
const adminNote = composeAdminNote(bisher.adminNote, { by, action: 'disable', note })
return disable(bisher.$id, { by, adminNote, reason: 'team' })
})
return nachAbschaltung(doc)
}
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404
funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
try {
const { customerId, key } = req.params
@@ -116,20 +177,71 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
}
await assertOwnProject(customerId, projectId)
const by = adminName(req)
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, key, { by, adminNote })
} else {
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
let ersatzTicket
if (key === 'zeiten') {
if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note })
else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note })
} else {
await withLocks([`feature-admin:${projectId}`], async () => {
const doc = await getFeatureDoc(customerId, projectId, key)
const adminNote = composeAdminNote(doc?.adminNote, { by, action, note })
if (action === 'activate') {
await adminActivate(customerId, projectId, key, { by, adminNote })
} else {
if (!doc) {
throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 })
}
await disable(doc.$id, { by, adminNote })
}
await disable(doc.$id, { by, adminNote })
}
})
}
res.json({
projects: (await adminView(customerId)).filter((p) => p.projectId === projectId),
// Ergebnis des Ersatz-Tickets beim Abschalten der Zeiten (angelegt | aktuell | fehler), damit ein Fehler im Panel sichtbar wird
...(ersatzTicket ? { ersatzTicket } : {}),
})
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) })
} catch (err) {
sendError(res, err)
}
})
/** Antwort der Team-Einstellungen: Pruefergebnis (400 mit fehler je Feld) und Versionskonflikt (409 mit code) wie die Kundenroute. */
function sendEinstellungenError(res, err) {
if (err?.status === 400 && err.fehler && typeof err.fehler === 'object') {
return res.status(400).json({ error: err.message, fehler: err.fehler })
}
if (err?.status === 409 && err.code === 'ZEITEN_VERALTET') {
return res.status(409).json({ error: err.message, code: err.code })
}
return sendError(res, err)
}
/**
* Team-Einstellungen von Oeffnungszeiten live (Spec 5 und 8): { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }
* -> { team, teamVersion }. PATCH, weil das Ticketsystem nur GET/POST/PATCH/DELETE durchlaesst. Die Version ist die des Teams
* (teamVersion aus der Ansicht); eine andere oder fehlende ergibt 409 ZEITEN_VERALTET, ungueltige Werte 400 mit fehler.
* Die Sperre `zeiten:<projekt>` nimmt speichereTeam selbst; der Zustand wird nicht veraendert, darum keine feature-admin-Sperre.
* Nicht fuer eine ausgeschaltete Funktion (off, z. B. nach der Loeschung der Daten): 409, damit kein verwaistes Dokument entsteht.
*/
funktionenAdminRouter.patch('/:customerId/zeiten/team', async (req, res) => {
try {
const { customerId } = req.params
const { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus } = req.body || {}
if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' })
await assertOwnProject(customerId, projectId)
const doc = await getFeatureDoc(customerId, projectId, 'zeiten')
if (!doc) return res.status(404).json({ error: `Für dieses Projekt gibt es noch ${FEATURE_LABELS.zeiten.keine}` })
if (doc.state === 'off') {
return res.status(409).json({ error: `${FEATURE_LABELS.zeiten.name} ist für dieses Projekt nicht eingeschaltet.` })
}
const gespeichert = await speichereTeam({
feature: doc,
eingabe: { navSelektor, kontaktleisteAus, hinweisleisteAus },
teamVersion,
by: `team:${req.staff?.appwriteUserId ?? 'unbekannt'}`,
})
res.json(gespeichert)
} catch (err) {
sendEinstellungenError(res, err)
}
})
+95
View File
@@ -0,0 +1,95 @@
import { config } from '../../config.js'
import { berlinDateTime } from '../berlinTime.js'
import { getDocument } from '../appwriteAdmin.js'
import { createFeatureTicket } from '../ticketAdmin.js'
import { ersatzHtml, wochenplanHash } from './ersatz.js'
import { ladeZeiten, setzeErsatzHash } from './speicher.js'
import { hauptplan } from './zeitlogik.js'
/**
* Nach dem Abschalten von Oeffnungszeiten live (Spec 4.5): steht in den Platzhaltern der Kundenseite noch eine Ersatz-Tabelle, die
* nicht mehr zum aktuellen Wochenplan passt, bekommt das Team ein WOMS-Ticket "Ersatz-Tabelle aktualisieren - <Projekt>" mit dem
* aktuellen Ersatz-HTML (gehoert zum enthaltenen Einbau). Ohne Skript zeigt die Seite ab jetzt nur noch diese Tabelle.
*
* Aufrufer: die Team-Route (disable) und spaeter der Abo-Abgleich (Abo-Ende). Aufruf NACH dem Speichern des Zustands und ausserhalb
* jeder Sperre `feature-admin:<projekt>`: das Ticket legt eine Netzwerkanfrage an, und Sperren sind nicht wiederbetretbar.
*/
const THEMA = 'Ersatz-Tabelle aktualisieren'
/** Text des Tickets: Kopfzeilen wie bei den Einbau-Tickets, Anlass, Aufgabe und das aktuelle Ersatz-HTML des Hauptplans. */
function ticketDetails({ doc, project, daten, haupt, now }) {
const projektName = project?.projectName || project?.subdomain || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
'Funktion: zeiten',
`Projekt: ${projektName}`,
`Domain: ${domain}`,
`siteKey: ${doc.siteKey ?? ''}`,
'',
`Öffnungszeiten live wurde am ${berlinDateTime(now).date} abgeschaltet. Der Wochenplan weicht von der Tabelle ab, die beim Einbau auf die Seite gesetzt wurde (oder der damalige Stand ist nicht bekannt).`,
'Aufgabe: Auf der Kundenseite in jedem Platzhalter <div data-webklar-zeiten ...> die Tabelle durch die aktuelle ersetzen. Ohne Skript zeigt die Seite nur noch diese Tabelle.',
'Weitere Pläne: WOMS → Funktionen → Ersatz-HTML kopieren.',
'',
haupt.kurz ? `Ersatz-HTML (Hauptplan ${haupt.kurz}):` : 'Ersatz-HTML (Hauptplan):',
ersatzHtml(daten, haupt),
].join('\n')
}
/** Das Projekt des Dokuments; ein geloeschtes Projekt (404) ist kein Fehler, der Name fehlt dann im Ticket. */
async function ladeProjekt(projectId) {
try {
return await getDocument(config.collections.websiteProjects, projectId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/**
* Pruefen, ob die Ersatz-Tabelle der Seite noch zum Wochenplan passt, und sonst das Ticket anlegen.
* Ruling R23: ein leerer oder fehlender gemerkter Hash (ersatzHash) gilt als "unbekannt", also wie geaendert, sobald Zeiten da sind
* (schlimmstenfalls ein unnoetiges Ticket). Nach dem Ticket wird der Hash auf den aktuellen Stand gesetzt: er gehoert dann zur Tabelle,
* die das Team einsetzt, und ein zweiter Aufruf (Doppelklick, spaeterer Lauf) legt kein zweites Ticket an. Scheitert das Ticket,
* bleibt der Hash, und der naechste Aufruf versucht es erneut.
* @param {{ $id: string, customerId?: string, projectId?: string, siteKey?: string }} doc portalFeatures-Dokument der Zeiten
* (aus disable(): der Stand nach dem Abschalten)
* @param {{ now?: Date }} [options]
* @returns {Promise<'angelegt'|'aktuell'|'fehler'>} angelegt: Ticket entstanden; aktuell: nichts zu tun (keine Zeiten oder Hash gleich);
* fehler: nicht pruefbar oder Ticket nicht angelegt (im Log ohne Kundendaten). Wirft nie.
*/
export async function nachAbschaltung(doc, { now = new Date() } = {}) {
try {
if (typeof doc?.$id !== 'string' || doc.$id === '') throw new TypeError('Dokument ohne $id')
const gespeichert = await ladeZeiten(doc.$id)
const daten = gespeichert?.daten ?? null
const haupt = hauptplan(daten)
// ohne Zeiten wurde nie eine Tabelle zum Ersetzen vorbereitet
if (!haupt) return 'aktuell'
const hash = wochenplanHash(daten)
if (gespeichert.ersatzHash !== '' && gespeichert.ersatzHash === hash) return 'aktuell'
const project = await ladeProjekt(doc.projectId)
const projektName = project?.projectName || project?.subdomain || ''
await createFeatureTicket({
customerId: doc.customerId,
project,
featureKey: 'zeiten',
siteKey: doc.siteKey,
topic: projektName ? `${THEMA} – ${projektName}` : THEMA,
details: ticketDetails({ doc, project, daten, haupt, now }),
})
// Das Ticket steht; scheitert nur das Merken, entsteht beim naechsten Abschalten ein zweites (Log, kein Fehler)
try {
await setzeErsatzHash(doc, hash)
} catch (err) {
console.error(`[zeiten] Ersatz-Hash für Projekt ${doc.projectId} nach dem Ticket nicht gemerkt: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
}
return 'angelegt'
} catch (err) {
// nie Kundendaten ins Log: Art und Statuscode, keine Meldung (Appwrite-Meldungen koennen Feldinhalte nennen)
console.error(`[zeiten] Ersatz-Ticket nach dem Abschalten (Projekt ${doc?.projectId ?? '?'}): ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.replace(/\s+/g, ' ').trim())
return 'fehler'
}
}
+560
View File
@@ -0,0 +1,560 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Team-Aktionen fuer Oeffnungszeiten live (Aufgabe 19, Spec 4.5, 5 und 8): POST /api/team/funktionen/:customerId/zeiten
// (activate ueber den Gratis-Pfad einschaltenZeiten, disable + nachAbschaltung), PATCH /:customerId/zeiten/team und die
// erweiterte Ansicht (plaene, team, teamVersion, hindernis) hinter requireStaffJwt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
},
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp, send, post } = await import('./support/testApp.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { speichereZeiten, speichereTeam, setzeErsatzHash, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
const { ersatzHtml, wochenplanHash } = await import('../server/services/zeiten/ersatz.js')
const { hauptplan } = await import('../server/services/zeiten/zeitlogik.js')
const { nachAbschaltung } = await import('../server/services/zeiten/abschalten.js')
const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const app = await startTestApp({ '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close()]))
after(() => setMailTransportForTests(null))
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const SITE_KEY = 'abcdefghijklmnop'
const P1 = 'p_aaaaaaa1'
const P2 = 'p_aaaaaaa2'
const projekt = (id, over = {}) => ({ $id: id, customerId: 'c1', projectName: id === 'p1' ? 'Salon Haarlinie' : `Projekt ${id}`, subdomain: `sub-${id}`, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over })
const featureDoc = (projectId, over = {}) => ({
$id: `f_${projectId}_zeiten`,
customerId: 'c1',
projectId,
featureKey: 'zeiten',
state: 'setup',
enabled: false,
unlockedBy: 'team',
unlockedByPurchase: false,
siteKey: SITE_KEY,
...over,
})
const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`)
const tickets = () => [...aw.docs('workorders').values()]
function tage(woche = {}) {
const t = {}
for (let d = 1; d <= 7; d += 1) {
const w = woche[d]
t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] }
}
return t
}
const planDaten = (id, name, extra = {}) => ({ id, name, haupt: false, tage: tage({ 1: ['09:00', '18:00'] }), ...extra })
const eingabe = (plaene) => ({ land: 'RP', plaene, feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null } })
const EIN_PLAN = () => eingabe([planDaten(P1, 'Standard', { haupt: true })])
const ZWEI_PLAENE = () => eingabe([planDaten(P1, 'Standard', { haupt: true }), planDaten(P2, 'Weihnachten', { tage: tage({ 2: ['10:00', '14:00'] }) })])
/** Wie EIN_PLAN, aber mit anderen Oeffnungszeiten des Hauptplans (neue Version 1 noetig, wenn EIN_PLAN schon gespeichert ist). */
const GEAENDERT = () => eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage({ 1: ['08:00', '12:00'], 5: ['08:00', '12:00'] }) })])
/** Speichert Zeiten wie der Kunde (die Normalisierung vergibt Kurznamen). */
const mitZeiten = (doc, daten, version = 0) => speichereZeiten({ feature: doc, eingabe: daten, version, now: JETZT })
const aktion = (action, extra = {}, { key = 'zeiten', projectId = 'p1', opts = STAFF } = {}) =>
post(`/api/team/funktionen/c1/${key}`, { projectId, action, ...extra }, opts)
const abschalten = (extra, opts) => aktion('disable', extra, opts)
const einstellungen = (body, opts = STAFF) => send('PATCH', '/api/team/funktionen/c1/zeiten/team', { ...opts, body })
const ansicht = async () => (await send('GET', '/api/team/funktionen/c1', STAFF)).body.projects
const zeitenSicht = async (projectId = 'p1') => (await ansicht()).find((p) => p.projectId === projectId).features.zeiten
let sink
let rebuilds
let logs
const originalRebuild = zeitenDeps.rebuild
beforeEach(async () => {
for (const col of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'workorders', 'websiteProjects']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
}
resetZeitenCacheForTests()
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
aw.seed('websiteProjects', projekt('p1'))
aw.seed('websiteProjects', projekt('p2'))
aw.seed('websiteProjects', projekt('p3', { customerId: 'c2' }))
sink = mailSink()
setMailTransportForTests(sink)
rebuilds = []
zeitenDeps.rebuild = async (p) => {
rebuilds.push({ projektId: p.$id, state: zeitenDoc(p.$id)?.state })
return { queued: true }
}
logs = []
const merke = (...args) => logs.push(args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '))
mock.method(console, 'error', merke)
mock.method(console, 'warn', merke)
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
// ------------------------------------------------------------------------------------------------ Aktivieren
test('Team aktiviert zeiten: setup, unlockedBy team, Neubau, Ticket, kein K24', async () => {
const res = await aktion('activate', { note: 'Pilot' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['p1'])
const { zeiten, news, termine } = res.body.projects[0].features
assert.equal(zeiten.state, 'setup', 'nicht direkt active (das Einbau-Ticket gibt frei)')
assert.equal(zeiten.unlockedBy, 'team')
assert.equal(zeiten.einbau, 'angestossen')
assert.equal(typeof zeiten.ticketWoid, 'string')
assert.equal(zeiten.ticketMissing, false)
assert.match(zeiten.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(news.state, 'off')
assert.equal(termine.state, 'off')
const doc = zeitenDoc()
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'team', false])
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.ticketId)
assert.ok(!doc.k24At, 'keine K24 an den Kunden')
assert.equal(aw.docs('portalFeatures').size, 1, 'kein Dokument fuer News oder Terminplaner')
assert.equal(aw.docs('featureConsents').size, 0, 'kein Zustimmungsnachweis fuer das Team')
// genau ein Neubau (nach dem Speichern des Zustands) und genau ein Einbau-Ticket
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }])
assert.equal(tickets().length, 1)
assert.equal(tickets()[0].type, 'Webpage')
assert.equal(tickets()[0].customerId, 'c1')
assert.match(tickets()[0].details, /data-wk-zeiten/)
assert.equal(sink.sent.length, 0, 'keine Mail')
})
test('Team aktiviert zeiten zweimal: idempotent, kein zweiter Neubau, kein zweites Ticket, keine zweite Notiz', async () => {
const erste = await aktion('activate', { note: 'Pilot' })
const notiz = erste.body.projects[0].features.zeiten.adminNote
const zweite = await aktion('activate', { note: 'nochmal' })
assert.equal(zweite.status, 200, JSON.stringify(zweite.body))
assert.equal(zweite.body.projects[0].features.zeiten.state, 'setup')
assert.equal(zweite.body.projects[0].features.zeiten.adminNote, notiz)
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 0)
})
test('Team aktiviert eine abgeschaltete Funktion wieder: setup, Abschaltgrund geräumt, vorhandenes Ticket bleibt', async () => {
aw.seed('workorders', { $id: 't_alt', woid: '10001', topic: 'alt', status: 'Closed', customerId: 'c1' })
aw.seed('portalFeatures', featureDoc('p1', { state: 'disabled', disabledAt: '2026-09-01T10:00:00.000Z', disabledReason: 'team', ticketId: 't_alt', einbau: 'angestossen', adminNote: 'alte Notiz' }))
const res = await aktion('activate')
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.unlockedBy], ['setup', null, null, 'team'])
assert.equal(doc.ticketId, 't_alt', 'kein zweites Ticket')
assert.equal(tickets().length, 1)
assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'setup' }], 'der Neubau setzt einbau neu')
assert.match(doc.adminNote, / Andrej: activate \| alte Notiz$/)
})
test('Aktivieren: fremdes oder unbekanntes Projekt 403, unbekannte Aktion 400, ohne Anmeldung 401, nichts entsteht', async () => {
assert.equal((await aktion('activate', {}, { projectId: 'p3' })).status, 403)
assert.equal((await aktion('activate', {}, { projectId: 'gibtsnicht' })).status, 403)
assert.equal((await aktion('activate', {}, { opts: {} })).status, 401)
assert.equal((await aktion('einschalten')).status, 400)
assert.equal((await post('/api/team/funktionen/c1/zeiten', { action: 'activate' }, STAFF)).status, 400)
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
})
test('Aktivieren: Ticket nicht anlegbar ⇒ 503, Zustand setup gespeichert, ein zweiter Klick holt nach', async () => {
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
const kaputt = await aktion('activate')
assert.equal(kaputt.status, 503, JSON.stringify(kaputt.body))
assert.equal(zeitenDoc().state, 'setup')
assert.ok(!zeitenDoc().ticketId)
assert.equal(tickets().length, 0)
const heil = await aktion('activate')
assert.equal(heil.status, 200, JSON.stringify(heil.body))
assert.ok(zeitenDoc().ticketId)
assert.equal(tickets().length, 1)
assert.equal(rebuilds.length, 1, 'der Neubau war schon angestossen')
})
test('Aktivieren: Neubau und Ticket laufen ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
let frei = null
zeitenDeps.rebuild = async () => {
const start = Date.now()
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
frei = Date.now() - start < 250
return { queued: true }
}
assert.equal((await aktion('activate')).status, 200)
assert.equal(frei, true)
})
test('News und Terminplaner laufen weiter über den alten Pfad (active, kein Ticket)', async () => {
const res = await aktion('activate', { note: 'Kulanz' }, { key: 'news' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.projects[0].features.news.state, 'active')
assert.equal(res.body.projects[0].features.zeiten.state, 'off')
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
})
// ------------------------------------------------------------------------------------------------ Einstellungen
test('Team-Einstellungen mit Version: gespeichert, Kundendaten unberührt, zweiter Versuch mit alter Version 409', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const gespeichert = await mitZeiten(doc, EIN_PLAN())
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }
const res = await einstellungen(body)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body, { team: { navSelektor: 'header.site-header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 1 })
const nachher = await ladeZeiten(doc.$id)
assert.deepEqual(nachher.team, res.body.team)
assert.equal(nachher.teamVersion, 1)
assert.deepEqual([nachher.version, nachher.daten], [gespeichert.version, gespeichert.daten], 'Daten und Version des Kunden bleiben')
assert.equal(aw.docs('oeffnungszeiten').get(doc.$id).updatedBy, 'team:u_staff2')
// mit der alten Version: 409, nichts geaendert
const veraltet = await einstellungen({ ...body, navSelektor: 'nav', kontaktleisteAus: false })
assert.equal(veraltet.status, 409)
assert.equal(veraltet.body.code, 'ZEITEN_VERALTET')
assert.equal(typeof veraltet.body.error, 'string')
assert.deepEqual((await ladeZeiten(doc.$id)).team, res.body.team)
// ohne Version gilt dasselbe (nie blind schreiben)
const { teamVersion: _weg, ...ohneVersion } = body
assert.equal((await einstellungen(ohneVersion)).status, 409)
// mit der neuen Version geht es weiter; leerer Selektor loescht ihn
const weiter = await einstellungen({ ...body, teamVersion: 1, navSelektor: '' })
assert.equal(weiter.status, 200)
assert.deepEqual(weiter.body, { team: { navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 2 })
})
test('Team-Einstellungen: ungültiger Selektor ⇒ 400 mit Feldfehler, nichts gespeichert', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
for (const navSelektor of ['<script>alert(1)</script>', 'a'.repeat(121), 'div{color:red}', 42]) {
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor, kontaktleisteAus: false, hinweisleisteAus: false })
assert.equal(res.status, 400, String(navSelektor))
assert.equal(typeof res.body.error, 'string')
assert.equal(typeof res.body.fehler?.navSelektor, 'string', String(navSelektor))
}
const res = await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header', kontaktleisteAus: 'ja', hinweisleisteAus: false })
assert.equal(res.status, 400)
assert.equal(typeof res.body.fehler?.kontaktleisteAus, 'string')
assert.equal(await ladeZeiten(doc.$id), null, 'kein Dokument angelegt')
// ein gueltiger Aufruf nach den Fehlern hat noch Version 0
assert.equal((await einstellungen({ projectId: 'p1', teamVersion: 0, navSelektor: 'header' })).status, 200)
})
test('Team-Einstellungen: Anmeldung, Projekt, Funktion und Zustand werden geprüft', async () => {
const body = { projectId: 'p1', teamVersion: 0, navSelektor: 'header' }
assert.equal((await einstellungen(body, {})).status, 401)
assert.equal((await einstellungen({ ...body, projectId: undefined })).status, 400)
assert.equal((await einstellungen({ ...body, projectId: 'p3' })).status, 403)
assert.equal((await einstellungen({ ...body, projectId: 'gibtsnicht' })).status, 403)
// eigenes Projekt ohne Funktionsdokument
const keine = await einstellungen(body)
assert.equal(keine.status, 404)
assert.match(keine.body.error, /Öffnungszeiten live/)
// Funktion aus (zum Beispiel nach der Datenloeschung): keine verwaisten Einstellungen
aw.seed('portalFeatures', featureDoc('p1', { state: 'off' }))
const aus = await einstellungen(body)
assert.equal(aus.status, 409)
assert.equal(aw.docs('oeffnungszeiten').size, 0)
// abgeschaltet: Einstellungen duerfen noch bearbeitet werden (die Daten sind da)
await updateDocument('portalFeatures', 'f_p1_zeiten', { state: 'disabled', disabledReason: 'team', disabledAt: JETZT.toISOString() })
assert.equal((await einstellungen(body)).status, 200)
})
// ------------------------------------------------------------------------------------------------ Abschalten
test('Abschalten durch Team: disabled, Grund team, keine Löschfrist, Daten bleiben, keine Mail', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true, activatedAt: JETZT.toISOString() })
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
const vorher = await ladeZeiten(doc.$id)
await setzeErsatzHash(doc, wochenplanHash(vorher.daten))
const res = await abschalten({ note: 'Kunde wünscht' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const sicht = res.body.projects[0].features.zeiten
assert.equal(sicht.state, 'disabled')
assert.equal(sicht.disabledReason, 'team')
assert.ok(sicht.disabledAt)
assert.equal(sicht.loeschungAm, null, 'bei Grund team gibt es keine Loeschfrist')
assert.match(sicht.adminNote, /Andrej: disable – Kunde wünscht$/)
const nach = zeitenDoc()
assert.deepEqual([nach.state, nach.enabled, nach.disabledReason], ['disabled', false, 'team'])
assert.deepEqual((await ladeZeiten(doc.$id)).daten, vorher.daten, 'die Zeiten bleiben')
assert.equal(sink.sent.length, 0)
assert.equal(res.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 0)
})
test('Abschalten ohne Funktionsdokument: 409; zweites Abschalten verschiebt disabledAt nicht', async () => {
const keine = await abschalten()
assert.equal(keine.status, 409)
assert.match(keine.body.error, /Öffnungszeiten live/)
assert.equal(aw.docs('portalFeatures').size, 0)
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
assert.equal((await abschalten()).status, 200)
const erstes = zeitenDoc().disabledAt
assert.ok(erstes)
assert.equal((await abschalten()).status, 200)
assert.equal(zeitenDoc().disabledAt, erstes)
assert.equal(zeitenDoc().disabledReason, 'team')
})
test('Abschalten mit geändertem Wochenplan ⇒ Ersatz-Ticket mit aktuellem Ersatz-HTML', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
// der Kunde aendert nach dem Einbau den Wochenplan
const jetzt = await mitZeiten(doc, GEAENDERT(), erst.version)
assert.notEqual(wochenplanHash(jetzt.daten), wochenplanHash(erst.daten))
const res = await abschalten({ note: 'Abo gekündigt' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.ersatzTicket, 'angelegt')
assert.equal(res.body.projects[0].features.zeiten.state, 'disabled')
assert.equal(tickets().length, 1)
const [ticket] = tickets()
assert.equal(ticket.topic, 'Ersatz-Tabelle aktualisieren – Salon Haarlinie')
assert.equal(ticket.type, 'Webpage')
assert.equal(ticket.status, 'Open')
assert.equal(ticket.customerId, 'c1')
assert.ok(ticket.details.includes(ersatzHtml(jetzt.daten, hauptplan(jetzt.daten))), 'aktuelles Ersatz-HTML im Ticket')
assert.ok(!ticket.details.includes(ersatzHtml(erst.daten, hauptplan(erst.daten))), 'nicht das alte')
assert.match(ticket.details, /Salon Haarlinie/)
assert.match(ticket.details, new RegExp(`siteKey: ${SITE_KEY}`))
assert.match(ticket.details, /data-webklar-zeiten/)
assert.doesNotMatch(ticket.details, /24:00|24 Uhr/)
// ein zweiter Klick auf Abschalten legt kein zweites Ticket an (der Hash ist jetzt der des Tickets)
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, wochenplanHash(jetzt.daten))
const nochmal = await abschalten()
assert.equal(nochmal.status, 200)
assert.equal(nochmal.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 1)
})
test('Abschalten: das Ersatz-Ticket entsteht ohne die Sperre feature-admin (sie blockiert das Team nicht)', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
await mitZeiten(doc, GEAENDERT(), erst.version)
const echt = globalThis.fetch
let frei = null
mock.method(globalThis, 'fetch', async (url, init) => {
if (init?.method === 'POST' && String(url).includes('/collections/workorders/documents') && frei === null) {
const start = Date.now()
// waitMs: haengt die Sperre, laeuft der Aufruf erst nach waitMs los; ist sie frei, sofort
await withLocks(['feature-admin:p1'], async () => {}, { waitMs: 400 })
frei = Date.now() - start < 250
}
return echt(url, init)
})
const res = await abschalten()
assert.equal(res.body.ersatzTicket, 'angelegt', JSON.stringify(res.body))
assert.equal(frei, true)
})
test('Abschalten ohne Änderung des Wochenplans ⇒ kein Ersatz-Ticket', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
// Name und Ausnahmen aendern den Hash nicht (nur tage des Hauptplans)
const mitAusnahme = EIN_PLAN()
mitAusnahme.plaene[0].name = 'Hauptzeiten'
mitAusnahme.ausnahmen = [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-12-21', bis: '2026-12-27', text: 'Weihnachtsurlaub', modus: 'zu', fenster: [], plan: null }]
await mitZeiten(doc, mitAusnahme, erst.version)
const res = await abschalten()
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.ersatzTicket, 'aktuell')
assert.equal(tickets().length, 0)
})
test('Abschalten ohne gemerkten Ersatz-Hash, aber mit Zeiten ⇒ Ersatz-Ticket (R23: unbekannt gilt als geändert)', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, '')
const res = await abschalten()
assert.equal(res.body.ersatzTicket, 'angelegt')
assert.equal(tickets().length, 1)
})
test('Abschalten: Ticket nicht anlegbar ⇒ trotzdem disabled, ersatzTicket fehler, Hash bleibt, nächster Klick versucht es erneut', async () => {
const doc = featureDoc('p1', { state: 'active', enabled: true })
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
await mitZeiten(doc, GEAENDERT(), erst.version)
const alterHash = wochenplanHash(erst.daten)
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Appwrite kaputt' })
const kaputt = await abschalten()
assert.equal(kaputt.status, 200, JSON.stringify(kaputt.body))
assert.equal(kaputt.body.ersatzTicket, 'fehler')
assert.equal(kaputt.body.projects[0].features.zeiten.state, 'disabled')
assert.equal(tickets().length, 0)
assert.equal((await ladeZeiten(doc.$id)).ersatzHash, alterHash, 'Hash unveraendert: der naechste Versuch legt das Ticket an')
assert.ok(logs.some((zeile) => zeile.includes('Ersatz')), 'steht im Log')
assert.ok(logs.every((zeile) => !/Salon Haarlinie|Standard|Kunde Eins|k@example/.test(zeile)), 'keine Kundendaten im Log')
const heil = await abschalten()
assert.equal(heil.body.ersatzTicket, 'angelegt')
assert.equal(tickets().length, 1)
})
// ------------------------------------------------------------------------------------------------ nachAbschaltung
test('nachAbschaltung: keine Zeiten (kein Dokument, nur Team, nur Nebenplan) ⇒ aktuell, kein Ticket', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'ohne oeffnungszeiten-Dokument')
await speichereTeam({ feature: doc, eingabe: { navSelektor: 'header' }, teamVersion: 0, by: 'team:u_staff2' })
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'nur Team-Einstellungen')
const neben = featureDoc('p2')
aw.seed('portalFeatures', neben)
aw.seed('oeffnungszeiten', {
$id: neben.$id, customerId: 'c1', projectId: 'p2', featureId: neben.$id, version: 1, teamVersion: 0,
daten: JSON.stringify({ land: 'RP', plaene: [{ id: P2, kurz: 'nebenan', name: 'Nebenplan', haupt: false, tage: tage({ 1: ['09:00', '18:00'] }) }] }),
})
assert.equal(await nachAbschaltung(neben, { now: JETZT }), 'aktuell', 'nur ein Nebenplan')
assert.equal(tickets().length, 0)
})
test('nachAbschaltung: wirft nie (Lesefehler, kaputte Eingabe) und liefert fehler', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Lesen kaputt' })
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung(null, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung({}, { now: JETZT }), 'fehler')
assert.equal(await nachAbschaltung(undefined), 'fehler')
assert.equal(tickets().length, 0)
assert.ok(logs.every((zeile) => !/Standard|Salon Haarlinie/.test(zeile)))
})
test('nachAbschaltung: Hash gleich ⇒ aktuell, Hash fehlt oder weicht ab ⇒ angelegt (einmal je Wochenplan-Stand)', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
const erst = await mitZeiten(doc, EIN_PLAN())
await setzeErsatzHash(doc, wochenplanHash(erst.daten))
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell')
await setzeErsatzHash(doc, 'ffffffffffffffff')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'aktuell', 'der Hash ist jetzt der des Tickets')
assert.equal(tickets().length, 1)
})
test('nachAbschaltung: Projekt nicht mehr da ⇒ Ticket ohne Projektnamen, kein Fehler', async () => {
const doc = featureDoc('p1')
aw.seed('portalFeatures', doc)
await mitZeiten(doc, EIN_PLAN())
await deleteDocument('websiteProjects', 'p1')
assert.equal(await nachAbschaltung(doc, { now: JETZT }), 'angelegt')
assert.equal(tickets()[0].topic, 'Ersatz-Tabelle aktualisieren')
})
// ------------------------------------------------------------------------------------------------ Ansicht
test('adminView zeigt Pläne, team und hindernis (nur für zeiten)', async () => {
// p1: eingerichtet mit zwei Plaenen und Team-Einstellungen, p2: setup ohne Zeiten
const eins = featureDoc('p1')
aw.seed('portalFeatures', eins)
await mitZeiten(eins, ZWEI_PLAENE())
await speichereTeam({ feature: eins, eingabe: { navSelektor: 'header', kontaktleisteAus: true }, teamVersion: 0, by: 'team:u_staff2' })
aw.seed('portalFeatures', featureDoc('p2', { siteKey: 'bbbbbbbbbbbbbbbb' }))
const projekte = await ansicht()
const z1 = projekte.find((p) => p.projectId === 'p1').features.zeiten
assert.deepEqual(z1.plaene, [
{ kurz: 'standard', name: 'Standard', haupt: true },
{ kurz: 'weihnachten', name: 'Weihnachten', haupt: false },
])
assert.deepEqual(z1.team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false })
assert.equal(z1.teamVersion, 1)
assert.equal(z1.hindernis, null)
const z2 = projekte.find((p) => p.projectId === 'p2').features.zeiten
assert.deepEqual(z2.plaene, [])
assert.deepEqual(z2.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
assert.equal(z2.teamVersion, 0)
assert.equal(z2.hindernis, 'zeiten_fehlen')
// andere Funktionen bekommen die Felder nicht
for (const key of ['news', 'termine']) {
for (const feld of ['plaene', 'team', 'teamVersion', 'hindernis']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`)
}
})
test('adminView: hindernis nur in setup und nur wenn der Haken freischaltbar nicht erfüllt ist; Hauptplan steht zuerst', async () => {
// Hauptplan nicht an erster Stelle gespeichert
const nebenZuerst = featureDoc('p1')
aw.seed('portalFeatures', nebenZuerst)
await mitZeiten(nebenZuerst, eingabe([planDaten(P2, 'Weihnachten'), planDaten(P1, 'Standard', { haupt: true })]))
assert.deepEqual((await zeitenSicht('p1')).plaene.map((p) => p.kurz), ['standard', 'weihnachten'])
assert.equal((await zeitenSicht('p1')).hindernis, null)
// alle Tage zu: bleibt in setup haengen
await deleteDocument('oeffnungszeiten', nebenZuerst.$id)
await mitZeiten(nebenZuerst, eingabe([planDaten(P1, 'Standard', { haupt: true, tage: tage() })]))
assert.equal((await zeitenSicht('p1')).hindernis, 'zeiten_fehlen')
// gleicher Zustand, aber aktiv oder abgeschaltet: kein Hindernis (es gibt nichts mehr freizuschalten)
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'active', enabled: true })
assert.equal((await zeitenSicht('p1')).hindernis, null)
await updateDocument('portalFeatures', nebenZuerst.$id, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: JETZT.toISOString() })
assert.equal((await zeitenSicht('p1')).hindernis, null)
})
test('adminView ohne Funktionsdokument: leere Pläne, Standard-Team, Version 0, kein Hindernis', async () => {
const z = await zeitenSicht('p1')
assert.equal(z.state, 'off')
assert.deepEqual([z.plaene, z.team, z.teamVersion, z.hindernis], [[], { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, 0, null])
})