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' // Freischalten von Oeffnungszeiten live (Aufgabe 18, Spec 3.2 Selbstheilung und 4.4): der 5-Minuten-Job (runActivation) setzt 'active', // wenn das Einbau-Ticket geschlossen ist UND der Kunde Zeiten eingetragen hat (Haken freischaltbar: Hauptplan gespeichert, mindestens // ein Tag nicht "zu"). Bei active: K16 und der Ersatz-Hash (Haken nachFreischaltung). Dazu heilt der Job Dokumente in setup, denen // Ticket oder K27 fehlen (holeZeitenNach), je Dokument getrennt abgesichert. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: { ...ZEITEN_DATETIMES, portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], }, defaultLimit: 25, }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') const { ladeZeiten, speichereZeiten } = await import('../server/services/zeiten/speicher.js') const { wochenplanHash } = await import('../server/services/zeiten/ersatz.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') after(() => aw.close()) after(() => setMailTransportForTests(null)) const Z = config.features.zeiten const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const K16_BETREFF = /bereit/ const K27_BETREFF = 'Öffnungszeiten live ist eingeschaltet' const feld = (id, name) => aw.docs('portalFeatures').get(id)[name] const zustand = (id) => feld(id, 'state') const tickets = () => [...aw.docs('workorders').values()] const k16 = () => sink.sent.filter((m) => K16_BETREFF.test(m.subject)) const k27 = () => sink.sent.filter((m) => m.subject === K27_BETREFF) const logText = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') function tage(modi) { const t = {} for (let d = 1; d <= 7; d += 1) { const modus = modi[d] ?? 'zu' t[d] = { modus, fenster: modus === 'zeiten' ? [['09:00', '18:00']] : [] } } return t } const WOCHE = { 1: 'zeiten', 2: 'zeiten', 3: 'zeiten', 4: 'zeiten', 5: 'zeiten' } const eingabe = (modi = WOCHE) => ({ land: 'RP', plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage(modi) }], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, }) let nr = 0 /** * Dokument der Zeiten im Zustand setup mit Projekt, Kunde und Ticket (Standard: geschlossen), optional mit gespeicherten Zeiten * (zeiten: Wochenplan wie in tage()). Standard: abo, einbau gesetzt und einschaltMailAt gesetzt, damit nur die Freischaltung etwas bewegt. */ async function einrichtung({ customerId = 'c1', ticket = 'Closed', zeiten = null, ...over } = {}) { nr += 1 const projectId = `p${nr}` const id = `f_${projectId}_zeiten` aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}`, subdomain: projectId, repoFullName: `JUSN/${projectId}`, previewMode: 'auto', previewDetected: 'static' }) if (ticket) aw.seed('workorders', { $id: `t_${projectId}`, woid: `WO-${nr}`, status: ticket, topic: 'x' }) aw.seed('portalFeatures', { $id: id, customerId, projectId, featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'angestossen', einschaltMailAt: JETZT.toISOString(), ...(ticket ? { ticketId: `t_${projectId}` } : {}), ...over, }) if (zeiten) await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(zeiten), version: 0, now: JETZT }) return id } let sink let rebuilds const originalRebuild = zeitenDeps.rebuild beforeEach(async () => { for (const col of ['portalFeatures', 'workorders', 'websiteProjects', 'oeffnungszeiten', 'customers']) { 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('customers', { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true }) for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar', 'nachFreischaltung']) delete Z[name] sink = mailSink() setMailTransportForTests(sink) rebuilds = [] zeitenDeps.rebuild = async (p) => { rebuilds.push(p.$id) return { queued: true } } mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) mock.method(console, 'log', () => {}) }) afterEach(() => { mock.restoreAll() zeitenDeps.rebuild = originalRebuild }) // ------------------------------------------------------------------------------------------------ Freischalten test('Ticket zu, aber keine Zeiten ⇒ bleibt setup, kein K16, kein Ersatz-Hash', async () => { const id = await einrichtung() assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(feld(id, 'enabled'), false) assert.equal(sink.sent.length, 0) assert.equal(await ladeZeiten(id), null, 'ohne Zeiten entsteht auch kein Zeiten-Dokument') // jeder weitere Lauf ändert nichts; die Wartenotiz steht genau einmal (kein Schreiben alle 5 Minuten) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(String(feld(id, 'adminNote')).split(' | ').filter((e) => e.includes('Freischaltung wartet')).length, 1) }) test('Ticket zu, Zeiten gespeichert, aber alle Tage „zu“ ⇒ bleibt setup', async () => { const id = await einrichtung({ zeiten: {} }) assert.ok((await ladeZeiten(id)).daten, 'die Zeiten sind gespeichert') assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(sink.sent.length, 0) assert.equal((await ladeZeiten(id)).ersatzHash, '') }) test('Zeiten ohne Hauptplan (nur ein Nebenplan) oder ohne Pläne ⇒ bleibt setup', async () => { const nebenplan = await einrichtung() aw.seed('oeffnungszeiten', { $id: nebenplan, customerId: 'c1', projectId: aw.docs('portalFeatures').get(nebenplan).projectId, featureId: nebenplan, version: 1, teamVersion: 0, daten: JSON.stringify({ land: 'RP', plaene: [{ id: 'p_bbbbbbb1', name: 'Nebenplan', haupt: false, tage: tage(WOCHE) }] }), }) const leer = await einrichtung() aw.seed('oeffnungszeiten', { $id: leer, customerId: 'c1', projectId: aw.docs('portalFeatures').get(leer).projectId, featureId: leer, version: 1, teamVersion: 0, daten: JSON.stringify({ land: 'RP', plaene: [] }), }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.deepEqual([zustand(nebenplan), zustand(leer)], ['setup', 'setup']) assert.equal(sink.sent.length, 0) }) test('Ticket zu und Zeiten gespeichert ⇒ active, genau eine K16, ersatzHash gesetzt', async () => { const id = await einrichtung({ zeiten: WOCHE }) const vorher = await ladeZeiten(id) assert.equal(vorher.ersatzHash, '') const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(feld(id, 'enabled'), true) assert.equal(Date.parse(feld(id, 'activatedAt')), JETZT.getTime()) assert.equal(k16().length, 1) assert.deepEqual(k16()[0].to.map((a) => a.address), ['k@example.invalid']) assert.equal(k27().length, 0) const nachher = await ladeZeiten(id) assert.match(nachher.ersatzHash, /^[0-9a-f]{16}$/) assert.equal(nachher.ersatzHash, wochenplanHash(vorher.daten)) // der Ersatz-Hash fasst weder Daten noch Versionen an: ein offener Editor wird nicht veraltet assert.deepEqual([nachher.version, nachher.teamVersion, nachher.daten], [vorher.version, vorher.teamVersion, vorher.daten]) // ein zweiter Lauf: nichts mehr zu tun, keine zweite Mail assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(k16().length, 1) }) test('ein einziger Tag, der nicht „zu“ ist, reicht (auch „rund“ oder „nach Vereinbarung“)', async () => { const rund = await einrichtung({ zeiten: { 7: 'rund' } }) const vereinbarung = await einrichtung({ zeiten: { 3: 'vereinbarung' } }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id).sort(), [rund, vereinbarung].sort()) assert.equal(k16().length, 2) }) test('Ticket noch offen ⇒ bleibt setup, auch mit Zeiten (der Job schaltet nur nach dem Schließen frei)', async () => { const id = await einrichtung({ ticket: 'Open', zeiten: WOCHE }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(sink.sent.length, 0) assert.equal((await ladeZeiten(id)).ersatzHash, '') }) test('Zeiten kommen nach dem Schließen des Tickets ⇒ der nächste Lauf schaltet frei', async () => { const id = await einrichtung() assert.deepEqual(await sf.runActivation({ now: JETZT }), []) await speichereZeiten({ feature: aw.docs('portalFeatures').get(id), eingabe: eingabe(), version: 0, now: JETZT }) assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(k16().length, 1) }) test('Lesefehler im Haken ⇒ bleibt setup, kein Wurf, kein K16; der nächste Lauf schaltet frei', async () => { const id = await einrichtung({ zeiten: WOCHE }) aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'Appwrite down' }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(sink.sent.length, 0) assert.equal(feld(id, 'enabled'), false) assert.deepEqual((await sf.runActivation({ now: JETZT })).map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') }) test('Haken freischaltbar der Zeiten: Hauptplan mit offenem Tag ⇒ true, sonst false, Lesefehler ⇒ false (fail-closed)', async () => { const { freischaltbar } = reg.featureHaken('zeiten') assert.equal(typeof freischaltbar, 'function') const offen = await einrichtung({ zeiten: { 2: 'zeiten' } }) const zu = await einrichtung({ zeiten: {} }) const ohne = await einrichtung() assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), true) assert.equal(await freischaltbar(aw.docs('portalFeatures').get(zu)), false) assert.equal(await freischaltbar(aw.docs('portalFeatures').get(ohne)), false) assert.equal(await freischaltbar({ $id: 'gibt-es-nicht' }), false) aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500 }) assert.equal(await freischaltbar(aw.docs('portalFeatures').get(offen)), false) }) // ----------------------------------------------------------------------------------------------- Ersatz-Hash test('nachFreischaltung scheitert (Ersatz-Hash nicht schreibbar) ⇒ trotzdem active, K16 geht raus, Fehler im Log', async () => { const id = await einrichtung({ zeiten: WOCHE }) aw.failNext({ collection: 'oeffnungszeiten', method: 'PATCH', status: 500, message: 'Schreiben kaputt' }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(k16().length, 1) assert.equal((await ladeZeiten(id)).ersatzHash, '', 'nicht gesetzt') assert.match(logText(), /Schreiben kaputt/) }) test('nachFreischaltung: nur Zeiten haben den Haken (News und Terminplaner nicht)', () => { assert.equal(typeof reg.featureHaken('zeiten').nachFreischaltung, 'function') assert.equal(reg.featureHaken('news').nachFreischaltung, undefined) assert.equal(reg.featureHaken('termine').nachFreischaltung, undefined) }) test('nachFreischaltung des Registers wird je freigeschaltetem Dokument mit dem aktiven Dokument gerufen; ein Wurf stoppt die übrigen nicht', async () => { const a = await einrichtung({ zeiten: WOCHE }) const b = await einrichtung({ zeiten: WOCHE }) const gerufen = [] Z.nachFreischaltung = async (doc) => { gerufen.push([doc.$id, doc.state]) if (doc.$id === a) throw new Error('Haken kaputt') } const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id).sort(), [a, b].sort()) assert.deepEqual(gerufen.sort(), [[a, 'active'], [b, 'active']].sort()) assert.equal(k16().length, 2, 'beide Kunden bekommen ihre K16') assert.match(logText(), /Haken kaputt/) }) // ------------------------------------------------------------------------------ Freischalten (Abnahme) durch das Team // Merge mit TP2: freischaltenNachAbnahme (WOMS "Freischalten (Abnahme)") gilt fuer jede Funktion mit ticketFreischaltung. Die // Voraussetzung der Zeiten ist ihr Haken freischaltbar (eingetragene Zeiten), nicht Abo und Einstellung des Terminplaners. test('Abnahme der Zeiten: ohne eingetragene Zeiten 409 und alles bleibt setup, auch ohne laufendes Wartungs-Abo', async () => { const id = await einrichtung({ ticket: 'Open' }) const projectId = feld(id, 'projectId') await assert.rejects(sf.freischaltenNachAbnahme({ customerId: 'c1', projectId, key: 'zeiten', by: 'Kenso', now: JETZT }), (err) => { assert.equal(err.status, 409) assert.match(err.message, /Voraussetzungen/) return true }) assert.equal(zustand(id), 'setup') assert.equal(sink.sent.length, 0) }) test('Abnahme der Zeiten: mit eingetragenen Zeiten active, K16, Ersatz-Hash (nachFreischaltung) und ohne Abo-Prüfung des Terminplaners', async () => { const id = await einrichtung({ ticket: 'Open', zeiten: WOCHE }) const projectId = feld(id, 'projectId') const vorher = await ladeZeiten(id) assert.equal(vorher.ersatzHash, '') const { doc, activated } = await sf.freischaltenNachAbnahme({ customerId: 'c1', projectId, key: 'zeiten', by: 'Kenso', now: JETZT }) assert.equal(activated, true) assert.equal(doc.state, 'active') assert.equal(zustand(id), 'active') assert.equal(k16().length, 1) assert.equal((await ladeZeiten(id)).ersatzHash, wochenplanHash(vorher.daten)) // schon aktiv: Erfolg ohne zweite Mail assert.equal((await sf.freischaltenNachAbnahme({ customerId: 'c1', projectId, key: 'zeiten', now: JETZT })).activated, false) assert.equal(k16().length, 1) }) // ------------------------------------------------------------------------------------------- Selbstheilung test('Selbstheilung: setup ohne ticketId ⇒ der Job legt das Ticket an (und sendet die K27)', async () => { const id = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null }) assert.deepEqual(await sf.runActivation({ now: JETZT }), []) assert.equal(zustand(id), 'setup') assert.equal(tickets().length, 1) assert.equal(feld(id, 'ticketId'), tickets()[0].$id) assert.match(tickets()[0].topic, /^Öffnungszeiten live einbauen – Projekt \d+$/) assert.equal(feld(id, 'einbau'), 'angestossen') assert.equal(rebuilds.length, 1, 'einbau war leer: ein Neubau') assert.equal(k27().length, 1) assert.ok(feld(id, 'einschaltMailAt')) // kein zweites Ticket, kein zweiter Neubau, keine zweite Mail await sf.runActivation({ now: JETZT }) assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 1, 1]) }) test('Selbstheilung: unlockedBy abo ohne einschaltMailAt ⇒ der Job sendet die K27 genau einmal', async () => { const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null }) await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT }) await sf.runActivation({ now: JETZT }) assert.equal(k27().length, 1) assert.deepEqual(k27()[0].to.map((a) => a.address), ['k@example.invalid']) assert.equal(Date.parse(feld(id, 'einschaltMailAt')), JETZT.getTime()) assert.equal(tickets().length, 1, 'das Ticket war da: kein neues') assert.deepEqual(rebuilds, []) assert.equal(zustand(id), 'setup') }) test('Selbstheilung: K27 nicht gesendet (Mail scheitert) ⇒ einschaltMailAt bleibt leer, der nächste Lauf sendet nach', async () => { const id = await einrichtung({ ticket: 'Open', einschaltMailAt: null }) setMailTransportForTests({ async sendMail() { throw new Error('SMTP down') } }) await sf.runActivation({ now: JETZT }) assert.ok(!feld(id, 'einschaltMailAt')) setMailTransportForTests(sink) await sf.runActivation({ now: JETZT }) assert.equal(k27().length, 1) assert.ok(feld(id, 'einschaltMailAt')) }) test('Selbstheilung nur für setup und nicht für kauf; Team bekommt Ticket, aber keine K27', async () => { const kauf = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'kauf', unlockedByPurchase: true }) const aktiv = await einrichtung({ ticket: null, state: 'active', enabled: true, einschaltMailAt: null }) const aus = await einrichtung({ ticket: null, state: 'off', einschaltMailAt: null }) const team = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, unlockedBy: 'team' }) await sf.runActivation({ now: JETZT }) assert.equal(tickets().length, 1, 'nur das Team-Dokument bekam ein Ticket') assert.equal(tickets()[0].$id, feld(team, 'ticketId')) assert.equal(sink.sent.length, 0) for (const id of [kauf, aktiv, aus]) assert.ok(!feld(id, 'ticketId'), id) assert.ok(!feld(team, 'einschaltMailAt')) }) test('Selbstheilung nur für Zeiten: ein Terminplaner in setup ohne Ticket bleibt unberührt', async () => { aw.seed('websiteProjects', { $id: 'pt', customerId: 'c1', projectName: 'Termine' }) aw.seed('portalFeatures', { $id: 'f_pt_termine', customerId: 'c1', projectId: 'pt', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop' }) await sf.runActivation({ now: JETZT }) assert.equal(tickets().length, 0) assert.equal(sink.sent.length, 0) }) test('Selbstheilung: ein Dokument scheitert (Ticketsystem down) ⇒ die übrigen werden geheilt, der Job wirft nicht, das Log nennt keine Kundendaten', async () => { const kaputt = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null }) const heil = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null, customerId: 'c2' }) aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done, []) assert.ok(!feld(kaputt, 'ticketId')) assert.ok(feld(heil, 'ticketId'), 'das zweite Dokument wurde trotzdem geheilt') assert.equal(k27().length, 1) assert.deepEqual(k27()[0].to.map((a) => a.address), ['k2@example.invalid']) const log = logText() assert.match(log, /Ticketsystem down/) assert.doesNotMatch(log, /example\.invalid|Kunde Eins|Kunde Zwei/) // der nächste Lauf holt das gescheiterte nach await sf.runActivation({ now: JETZT }) assert.ok(feld(kaputt, 'ticketId')) assert.equal(k27().length, 2) }) test('Selbstheilung: Listenfehler beim Suchen der Dokumente ⇒ kein Wurf, K16 der schon freigeschalteten bleibt', async () => { const id = await einrichtung({ zeiten: WOCHE }) // GET auf portalFeatures: 1 Liste der Freischaltung, 2 frisches Lesen unter der Sperre, 3 Liste der Selbstheilung aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, after: 2 }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(k16().length, 1) assert.match(logText(), /Selbstheilung Öffnungszeiten: Dokumente nicht lesbar/, 'der Fehler traf die Liste der Selbstheilung') }) test('Projektliste eines Kunden (runActivation mit customerId) schaltet nur frei: keine Selbstheilung, kein Neubau, kein Ticket, keine Mail', async () => { const unvollstaendig = await einrichtung({ ticket: null, einbau: '', einschaltMailAt: null }) const fertig = await einrichtung({ zeiten: WOCHE }) const done = await sf.runActivation({ now: JETZT, customerId: 'c1' }) assert.deepEqual(done.map((d) => d.$id), [fertig], 'Freischalten gilt weiter für den Kunden') assert.ok(!feld(unvollstaendig, 'ticketId')) assert.deepEqual([tickets().length, rebuilds.length, k27().length], [1, 0, 0], 'nur das geschlossene Ticket der fertigen Funktion') assert.equal(k16().length, 1) // der Lauf des Jobs holt es dann nach await sf.runActivation({ now: JETZT }) assert.ok(feld(unvollstaendig, 'ticketId')) assert.deepEqual([tickets().length, rebuilds.length, k27().length], [2, 1, 1]) }) test('Heilen und Freischalten im selben Lauf: aktiv gewordene Dokumente bekommen keine K27 mehr nachgeschoben', async () => { const id = await einrichtung({ zeiten: WOCHE, einschaltMailAt: null }) const done = await sf.runActivation({ now: JETZT }) assert.deepEqual(done.map((d) => d.$id), [id]) assert.equal(zustand(id), 'active') assert.equal(k27().length, 0) assert.equal(k16().length, 1) })