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$/) }) // Merge mit TP2: die Aktionen abnahme und ticket gibt es fuer jede Funktion mit ticketFreischaltung; fuer die Zeiten darf // abnahme weder abschalten noch den Gratis-Pfad nehmen, sondern gibt in setup frei (Haken freischaltbar, Ersatz-Hash) test('Team „Freischalten (Abnahme)“ der Zeiten: ohne Zeiten 409 (bleibt setup), mit Zeiten active mit Ersatz-Hash, nie disabled', async () => { const doc = featureDoc('p1', { ticketId: 't_offen', einbau: 'angestossen' }) aw.seed('workorders', { $id: 't_offen', woid: '10002', topic: 'Zeiten', status: 'Open', customerId: 'c1' }) aw.seed('portalFeatures', doc) const zu = await aktion('abnahme') assert.equal(zu.status, 409, JSON.stringify(zu.body)) assert.equal(zeitenDoc().state, 'setup') assert.equal(sink.sent.length, 0) await mitZeiten(doc, EIN_PLAN()) const ok = await aktion('abnahme') assert.equal(ok.status, 200, JSON.stringify(ok.body)) assert.equal(ok.body.projects[0].features.zeiten.state, 'active') assert.equal(zeitenDoc().state, 'active') assert.match((await ladeZeiten(doc.$id)).ersatzHash, /^[0-9a-f]{16}$/) assert.equal(sink.sent.length, 1, 'K16 an den Kunden') assert.equal(ok.body.ersatzTicket, undefined, 'abnahme legt kein Ersatz-Ticket an') }) 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 ['', '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 Zeiten-Felder nicht; hindernis tragen seit TP2 alle (null ausser beim Terminplaner in setup) for (const key of ['news', 'termine']) { for (const feld of ['plaene', 'team', 'teamVersion']) assert.equal(feld in projekte[0].features[key], false, `${key}.${feld}`) assert.equal(projekte[0].features[key].hindernis, null, `${key}.hindernis`) } }) 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]) })