import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { createHash } from 'node:crypto' 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' // Feiertags-Erinnerung von Öffnungszeiten live (Aufgabe 23, Spec 7 "Feiertags-Erinnerung", Z18): erinnereFeiertage(now) schickt je Funktion // (setup/active) eine Mail K28 mit allen neuen Tagen in 1–7 Tagen, die Feiertag oder "bitte prüfen" sind. Je Tag zuerst der Merker // sha1(featureId|datum) (409 = schon erinnert), danach erst die Mail: lieber einmal zu wenig als doppelt. Nur Schalter pilot/on (pilot: nur // freigegebene Kunden), nur 08:00 bis 20:00 Berliner Zeit, kein BCC ans Team, Merker älter als 30 Tage werden gelöscht. // Aufbau wie zeitenAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit als Parameter. const DATETIMES = { ...ZEITEN_DATETIMES, portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], } const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: DATETIMES, defaultLimit: 25, }) after(() => aw.close()) 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 { speichereZeiten } = await import('../server/services/zeiten/speicher.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const { erinnereFeiertage } = await import('../server/services/zeiten/erinnerung.js') after(() => setMailTransportForTests(null)) const EIN = new Date('2026-09-28T07:00:00.000Z') // Montag 28.09.2026, 09:00 Berlin (Sommerzeit): in 7 Tagen liegt Sa 03.10. (Einheit) const ZWEI = new Date('2027-03-22T08:00:00.000Z') // Montag 22.03.2027, 09:00 Berlin: Karfreitag 26.03. und Ostermontag 29.03. const KUNDE = 'c1' const MODUS_VORHER = config.zeiten.mode const PILOT_VORHER = [...config.zeiten.pilotEmails] const BASIS_VORHER = config.publicBaseUrl const plusMs = (von, ms) => new Date(von.getTime() + ms) const STUNDE = 3600 * 1000 const sha36 = (featureId, datum) => createHash('sha1').update(`${featureId}|${datum}`).digest('hex').slice(0, 36) let sink let log async function raeumeAuf() { for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } resetZeitenCacheForTests() aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) } beforeEach(async () => { await raeumeAuf() aw.resetListCalls() config.zeiten.mode = 'on' config.zeiten.pilotEmails = [] config.publicBaseUrl = 'https://project.webklar.com' sink = mailSink() setMailTransportForTests(sink) log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) } }) afterEach(() => { // Das Log enthält nie Adresse oder Namen des Kunden const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') mock.restoreAll() config.zeiten.mode = MODUS_VORHER config.zeiten.pilotEmails = PILOT_VORHER config.publicBaseUrl = BASIS_VORHER assert.doesNotMatch(text, /silke@example\.com|Silke|Müller|salon\.example/i, 'keine Adresse und kein Name des Kunden im Log') }) // ---------------------------------------------------------------- Hilfen const funktionsId = (nr = 1) => `f_p${nr}_zeiten` /** Wochenplan: jeden Tag (auch Samstag und Sonntag) 09:00–18:00, damit jeder Feiertag eine Erinnerung auslöst. */ const OFFEN = () => { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = { modus: 'zeiten', fenster: [['09:00', '18:00']] } return t } const eingabe = (tage = OFFEN()) => ({ land: 'RP', plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage }], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, }) /** Funktion samt gespeicherten Zeiten (zeiten false: Dokument ohne Eingaben des Kunden). */ async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, zeiten = true, tage = OFFEN() } = {}) { aw.seed('websiteProjects', { $id: `p${nr}`, customerId: kunde, projectName: `Praxis ${nr}`, subdomain: `praxis${nr}` }) aw.seed('portalFeatures', { $id: funktionsId(nr), customerId: kunde, projectId: `p${nr}`, featureKey: 'zeiten', state: 'active', enabled: true, unlockedBy: 'abo', siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`, ...over, }) const doc = aw.docs('portalFeatures').get(funktionsId(nr)) if (zeiten) await speichereZeiten({ feature: doc, eingabe: eingabe(tage), version: 0, now: new Date('2026-09-20T10:00:00.000Z') }) return doc } const merker = () => [...aw.docs('zeitenErinnerungen').values()] const mails = () => sink.sent const logText = () => Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') /** Ein Merker wie ihn ein früherer Lauf angelegt hat. */ const legeMerker = (featureId, datum, createdAt = '2026-09-27T10:00:00.000Z') => aw.seed('zeitenErinnerungen', { $id: sha36(featureId, datum), featureId, datum, createdAt }) const ergebnis = (over) => ({ geprueft: 0, mails: 0, merker: 0, fehler: 0, ...over }) // ---------------------------------------------------------------- Mail und Merker test('eine Mail für zwei Feiertage im Fenster', async () => { await legeFunktion(1) assert.deepEqual(await erinnereFeiertage(ZWEI), ergebnis({ geprueft: 1, mails: 1, merker: 2 })) assert.equal(mails().length, 1, 'eine Mail, nicht eine je Tag') const mail = mails()[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.equal(mail.subject, 'Feiertage stehen an – stimmen Deine Zeiten?') assert.match(mail.text, /Hallo Silke Müller,/) assert.match(mail.text, /Fr 26\.03\. – Karfreitag\nMo 29\.03\. – Ostermontag/, 'beide Tage, in Datumsfolge') assert.match(mail.text, /Praxis 1/, 'der Projektname steht in der Mail') assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Knopf zum Editor') assert.ok(!mail.bcc?.length, 'K28 geht ohne Kopie ans Team (R17c)') assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29']) }) test('Merker: Dokument-ID sha1(featureId|datum), 36 Zeichen, mit featureId, Datum und createdAt', async () => { await legeFunktion(1) await erinnereFeiertage(EIN) assert.equal(merker().length, 1) const m = merker()[0] assert.equal(m.$id, sha36(funktionsId(1), '2026-10-03')) assert.match(m.$id, /^[0-9a-f]{36}$/) assert.equal(m.featureId, funktionsId(1)) assert.equal(m.datum, '2026-10-03') assert.equal(new Date(m.createdAt).getTime(), EIN.getTime(), 'createdAt = Zeitpunkt des Laufs') }) test('Sa 03.10. wird erinnert, wenn der Samstag im Hauptplan offen ist (Tag der Deutschen Einheit)', async () => { await legeFunktion(1) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) assert.match(mails()[0].text, /Sa 03\.10\. – Tag der Deutschen Einheit/) assert.doesNotMatch(mails()[0].text, /mehrere Tage/, 'ein Tag: Einzahl') }) test('Tag, an dem der Hauptplan ohnehin geschlossen ist: keine Mail, kein Merker', async () => { const tage = OFFEN() tage[6] = { modus: 'zu', fenster: [] } // Samstag zu await legeFunktion(1, {}, { tage }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0) assert.equal(merker().length, 0) }) test('zweiter Lauf: keine zweite Mail (Merker), auch eine Stunde und einen Tag später', async () => { await legeFunktion(1) assert.equal((await erinnereFeiertage(EIN)).mails, 1) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) assert.deepEqual(await erinnereFeiertage(plusMs(EIN, STUNDE)), ergebnis({ geprueft: 1 })) assert.deepEqual(await erinnereFeiertage(plusMs(EIN, 24 * STUNDE)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) assert.equal(merker().length, 1) }) test('Neustart zwischen Merker und Mail: Merker existiert, Mail fehlt ⇒ nichts senden', async () => { await legeFunktion(1) legeMerker(funktionsId(1), '2026-10-03') assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0, 'lieber einmal zu wenig als doppelt') assert.equal(merker().length, 1) }) test('nur die neuen Tage stehen in der Mail: ein Tag war schon vorgemerkt', async () => { await legeFunktion(1) legeMerker(funktionsId(1), '2027-03-26') // Karfreitag war schon dran assert.deepEqual(await erinnereFeiertage(ZWEI), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) assert.equal(mails().length, 1) assert.match(mails()[0].text, /Mo 29\.03\. – Ostermontag/) assert.doesNotMatch(mails()[0].text, /Karfreitag/) assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29']) }) test('Heiligabend ist „bitte prüfen“: mit Hinweis in der Mail, alle drei Tage in einer Mail', async () => { await legeFunktion(1) assert.deepEqual(await erinnereFeiertage(new Date('2026-12-22T08:00:00.000Z')), ergebnis({ geprueft: 1, mails: 1, merker: 3 })) assert.match(mails()[0].text, /Do 24\.12\. – Heiligabend \(bitte prüfen\)\nFr 25\.12\. – 1\. Weihnachtstag\nSa 26\.12\. – 2\. Weihnachtstag/) }) test('zwei Läufe gleichzeitig: genau eine Mail (der Merker entscheidet, wer sendet)', async () => { await legeFunktion(1) const [a, b] = await Promise.all([erinnereFeiertage(EIN), erinnereFeiertage(EIN)]) assert.equal(a.mails + b.mails, 1) assert.equal(a.merker + b.merker, 1) assert.equal(mails().length, 1) }) test('zwei Funktionen eines Kunden: je Dokument eine Mail mit dem eigenen Projekt', async () => { await legeFunktion(1) await legeFunktion(2) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) assert.equal(mails().length, 2) assert.deepEqual(mails().map((m) => /Praxis (\d)/.exec(m.text)[1]).sort(), ['1', '2']) assert.deepEqual(merker().map((m) => m.featureId).sort(), [funktionsId(1), funktionsId(2)]) }) // ---------------------------------------------------------------- Fenster und Schalter test('außerhalb 08–20 Uhr Berliner Zeit: nichts (keine Abfrage, kein Merker, keine Mail)', async () => { await legeFunktion(1) for (const [uhr, utc] of [['07:59', '05:59'], ['20:00', '18:00'], ['23:30', '21:30'], ['03:00', '01:00']]) { aw.resetListCalls() const jetzt = new Date(`2026-09-28T${utc}:00.000Z`) assert.deepEqual(await erinnereFeiertage(jetzt), ergebnis(), `${uhr} Berlin`) assert.equal(aw.listCalls().length, 0, `${uhr}: nicht einmal eine Abfrage`) } assert.equal(mails().length, 0) assert.equal(merker().length, 0) // Grenzen: 08:00 und 19:59 gehören noch zum Fenster assert.equal((await erinnereFeiertage(new Date('2026-09-28T06:00:00.000Z'))).mails, 1, '08:00 Berlin') await raeumeAuf() await legeFunktion(1) assert.equal((await erinnereFeiertage(new Date('2026-09-28T17:59:00.000Z'))).mails, 1, '19:59 Berlin') }) test('Schalter off: nichts, auch keine Abfrage', async () => { await legeFunktion(1) config.zeiten.mode = 'off' assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) assert.equal(aw.listCalls().length, 0) assert.equal(mails().length, 0) assert.equal(merker().length, 0) config.zeiten.mode = 'unbekannt' assert.deepEqual(await erinnereFeiertage(EIN), ergebnis(), 'ein unbekannter Wert gilt als off') assert.equal(mails().length, 0) }) test('pilot: nur Kunden der Pilotliste; die anderen bekommen weder Mail noch Merker', async () => { aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) await legeFunktion(1) await legeFunktion(2, {}, { kunde: 'c2' }) config.zeiten.mode = 'pilot' config.zeiten.pilotEmails = ['silke@example.com'] assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) assert.deepEqual(mails()[0].to.map((a) => a.address), ['silke@example.com']) assert.deepEqual(merker().map((m) => m.featureId), [funktionsId(1)]) // Pilotliste leer: niemand await raeumeAuf() aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) await legeFunktion(1) config.zeiten.pilotEmails = [] const vorher = mails().length assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) assert.equal(mails().length, vorher) // '*' = alle config.zeiten.pilotEmails = ['*'] assert.equal((await erinnereFeiertage(EIN)).mails, 1) }) test('Schalter on: alle Kunden', async () => { aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) await legeFunktion(1) await legeFunktion(2, {}, { kunde: 'c2' }) config.zeiten.pilotEmails = [] // die Pilotliste zählt bei on nicht assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) assert.deepEqual(mails().flatMap((m) => m.to.map((a) => a.address)).sort(), ['peter@salon.example', 'silke@example.com']) }) // ---------------------------------------------------------------- Welche Funktionen test('nur Zustand setup und active; setup zählt, andere Zustände und andere Funktionen nicht', async () => { await legeFunktion(1, { state: 'setup' }) await legeFunktion(2, { state: 'active' }) await legeFunktion(3, { state: 'disabled', disabledReason: 'abo_ende' }) await legeFunktion(4, { state: 'ordered' }) await legeFunktion(5, { state: 'off' }) await legeFunktion(6, { featureKey: 'termine' }) await legeFunktion(7, { featureKey: 'news' }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) assert.deepEqual(merker().map((m) => m.featureId).sort(), [funktionsId(1), funktionsId(2)]) }) test('Funktion ohne gespeicherte Zeiten: übersprungen (nichts zu erinnern)', async () => { await legeFunktion(1, {}, { zeiten: false }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) // Dokument vom Team angelegt, aber noch ohne Eingaben des Kunden (daten null) aw.seed('oeffnungszeiten', { $id: funktionsId(1), customerId: KUNDE, projectId: 'p1', featureId: funktionsId(1), version: 0, teamVersion: 1, team: '{}', updatedAt: EIN.toISOString(), updatedBy: 'team:s1' }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) assert.equal(mails().length, 0) assert.equal(merker().length, 0) }) test('Kunde fehlt oder ohne E-Mail-Adresse: übersprungen, kein Merker, Log ohne Daten', async () => { await legeFunktion(1, {}, { kunde: 'c_weg' }) // Kundendokument fehlt aw.seed('customers', { $id: 'c_leer', name: 'Ohne Adresse', email: '' }) await legeFunktion(2, {}, { kunde: 'c_leer' }) const erg = await erinnereFeiertage(EIN) assert.equal(erg.mails, 0) assert.equal(erg.merker, 0, 'ohne Adresse wird kein Merker verbraucht: der Tag kann später erinnert werden') assert.equal(erg.fehler, 0, 'kein Fehler des Laufs') assert.equal(mails().length, 0) assert.equal(merker().length, 0) assert.match(logText(), /Funktion f_p1_zeiten/) assert.match(logText(), /Funktion f_p2_zeiten/) // Adresse nachgetragen: der nächste Lauf erinnert aw.seed('customers', { $id: 'c_leer', name: 'Ohne Adresse', email: 'neu@example.com' }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 1, merker: 1 })) assert.deepEqual(mails()[0].to.map((a) => a.address), ['neu@example.com']) }) // ---------------------------------------------------------------- Fehler test('Mail scheitert: Merker bleibt (höchstens einmal), Fehler gezählt, der Lauf geht weiter', async () => { aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) await legeFunktion(1) await legeFunktion(2, {}, { kunde: 'c2' }) // der erste Versand scheitert, der zweite geht durch const roh = sink.sendMail.bind(sink) let aufrufe = 0 sink.sendMail = async (mail) => { aufrufe += 1 if (aufrufe === 1) throw new Error('SMTP down für silke@example.com') return roh(mail) } assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 1, merker: 2, fehler: 1 })) assert.equal(mails().length, 1) assert.equal(merker().length, 2, 'beide Merker stehen, auch der der gescheiterten Mail') // der nächste Lauf wiederholt nicht assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2 })) assert.equal(mails().length, 1) }) test('Lesefehler bei einem Dokument stoppt weder die anderen noch den Lauf', async () => { await legeFunktion(1) await legeFunktion(2) // die erste Abfrage der Zeiten (Dokument f_p1_zeiten, Reihenfolge nach $id) scheitert aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'kaputt' }) const erg = await erinnereFeiertage(EIN) assert.equal(erg.fehler, 1) assert.equal(erg.mails, 1, 'das zweite Dokument wird trotzdem erinnert') assert.equal(merker().length, 1) }) test('Merker lässt sich nicht anlegen (500): keine Mail für den Tag, Fehler gezählt, nächster Lauf versucht es wieder', async () => { await legeFunktion(1) aw.failNext({ collection: 'zeitenErinnerungen', method: 'POST', status: 500, message: 'db down' }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mails().length, 0, 'ohne Merker keine Mail') assert.equal(merker().length, 0) assert.deepEqual(await erinnereFeiertage(plusMs(EIN, STUNDE)), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) }) test('Funktionen nicht lesbar: kein Wurf, Fehler gezählt', async () => { await legeFunktion(1) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'db down' }) assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ fehler: 1 })) assert.equal(mails().length, 0) }) test('ohne gültiges now: TypeError (wie der Abo-Abgleich, die Jobs fangen ihn)', async () => { await assert.rejects(() => erinnereFeiertage(undefined), TypeError) await assert.rejects(() => erinnereFeiertage(new Date('kaputt')), TypeError) }) // ---------------------------------------------------------------- Aufräumen test('alte Merker werden aufgeräumt: Datum mehr als 30 Tage her', async () => { await legeFunktion(1) // heute (Berlin) = 2026-09-28; Grenze: 2026-08-29 bleibt (genau 30 Tage), 2026-08-28 und älter geht legeMerker('f_alt', '2026-08-28') legeMerker('f_alt', '2026-01-02') legeMerker('f_alt', '2026-08-29') legeMerker('f_alt', '2026-09-01') legeMerker('f_neu', '2026-10-03') await erinnereFeiertage(EIN) assert.deepEqual(merker().map((m) => m.datum).sort(), ['2026-08-29', '2026-09-01', '2026-10-03', '2026-10-03']) assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-08-28'))) assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02'))) assert.ok(aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-08-29'))) }) test('Aufräumen auch ohne Funktionen, aber nur im Fenster und nur bei Schalter pilot/on', async () => { legeMerker('f_alt', '2026-01-02') config.zeiten.mode = 'off' await erinnereFeiertage(EIN) assert.equal(merker().length, 1, 'Schalter off: nichts angefasst') config.zeiten.mode = 'on' await erinnereFeiertage(new Date('2026-09-28T05:00:00.000Z')) // 07:00 Berlin assert.equal(merker().length, 1, 'außerhalb des Fensters: nichts angefasst') await erinnereFeiertage(EIN) assert.equal(merker().length, 0) }) test('mehr alte Merker als eine Seite: alle werden gelöscht', async () => { for (let i = 0; i < 130; i += 1) legeMerker(`f_alt${i}`, '2026-01-02') legeMerker('f_neu', '2026-10-03') await erinnereFeiertage(EIN) assert.deepEqual(merker().map((m) => m.datum), ['2026-10-03']) }) test('Löschen eines alten Merkers scheitert: Fehler gezählt, die Erinnerung läuft trotzdem', async () => { await legeFunktion(1) legeMerker('f_alt', '2026-01-02') aw.failNext({ collection: 'zeitenErinnerungen', method: 'DELETE', status: 500, message: 'db down' }) const erg = await erinnereFeiertage(EIN) assert.equal(erg.mails, 1) assert.equal(erg.fehler, 1) assert.ok(aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02')), 'bleibt für den nächsten Lauf') assert.equal((await erinnereFeiertage(plusMs(EIN, STUNDE))).fehler, 0) assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02'))) })