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' // Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4): aktive Terminplaner ohne laufendes // Wartungs-Abo abschalten (Schritt 1) und die Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von // Appwrite mit Schema und Standard-Limit 25, Mails in der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), termineConfig: schemaKeys('termineConfig'), }, defaultLimit: 25, }) after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { withLocks } = await import('../server/services/keyedLock.js') const { disable } = await import('../server/services/siteFeatures.js') const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js') after(() => setMailTransportForTests(null)) const TAG = 24 * 60 * 60 * 1000 const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const KUNDE = 'c_kunde01' const F1 = 'f_p_projekt01_termine' const BASIS_VORHER = config.publicBaseUrl const plus = (von, ms) => new Date(von.getTime() + ms) const ms = (wert) => new Date(wert).getTime() let sink let log beforeEach(async () => { for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } aw.resetListCalls() config.publicBaseUrl = 'https://project.webklar.com' sink = mailSink() setMailTransportForTests(sink) log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) }) afterEach(() => { // Logs enthalten nie Adresse oder Namen des Kunden (und keine Gaeste: der Abgleich kennt keine) const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') mock.timers.reset() mock.restoreAll() config.publicBaseUrl = BASIS_VORHER assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log') }) // ---------------------------------------------------------------- Hilfen const projektId = (nr) => `p_projekt0${nr}` const funktionsId = (nr) => `f_p_projekt0${nr}_termine` /** Aktiver, bezahlter Terminplaner von KUNDE fuer Projekt nr (samt Projekt "Praxis Muster" bzw. eigenem Namen). */ function legeFunktion(nr = 1, over = {}, { projekt = 'Praxis Muster', kunde = KUNDE } = {}) { aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt }) aw.seed('portalFeatures', { $id: funktionsId(nr), customerId: kunde, projectId: projektId(nr), featureKey: 'termine', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid', siteKey: `abcdefghijklmn0${nr}`, ...over, }) return funktionsId(nr) } const funktion = (id = F1) => aw.docs('portalFeatures').get(id) /** Wartungs-Abo von KUNDE (ersetzt nichts: ein einziges Abo-Dokument je Test). */ const wartung = (status, over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status, ...over })) const hosting = (status = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, customerId: KUNDE, status, ...over })) /** Der Abgleich laeuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() (new Date()) denselben Zeitpunkt schreibt. */ async function laufe(now = JETZT) { mock.timers.enable({ apis: ['Date'], now: now.getTime() }) try { return await gleicheAboAb(now) } finally { mock.timers.reset() } } const ERGEBNIS_LEER = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) const mails = () => sink.sent const abfragenAufAbos = () => aw.listCalls('subscriptions').length /** * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende, dann * erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen. */ async function mitEinwurf(passt, dazwischen, fn) { const echt = globalThis.fetch let offen = true globalThis.fetch = async (url, optionen) => { const antwort = await echt(url, optionen) if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) { offen = false globalThis.fetch = echt await dazwischen() } return antwort } try { return await fn() } finally { globalThis.fetch = echt } } const liste = (collection) => (url, methode) => methode === 'GET' && new RegExp(`/collections/${collection}/documents(\\?|$)`).test(url) // ---------------------------------------------------------------- Schritt 1 und 2: Abo beendet test('Abo beendet: Terminplaner abgeschaltet, K22 an den Kunden, am Folgetag nichts mehr (die Frist wird nie verschoben)', async () => { legeFunktion(1) wartung('canceled', { endedAt: JETZT.toISOString(), canceledAt: JETZT.toISOString() }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.enabled, false) assert.equal(doc.disabledReason, 'abo_ende') assert.equal(ms(doc.disabledAt), JETZT.getTime()) assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet') assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach dem Versand, mit now') assert.equal(mails().length, 1) const mail = mails()[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.equal(mail.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') assert.match(mail.text, /Silke Müller/) assert.match(mail.text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Block abo_ende, Enddatum = Berliner Tag von disabledAt') assert.match(mail.text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt + 30 Tage') assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/) assert.match(mail.text, /149 €/, 'Preis aus dem Register (priceLabel)') assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#termine/) assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht der Hosting-Block') assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') // Folgetag: nichts mehr, disabledAt und Marker bleiben, keine zweite Mail const vorher = { ...funktion() } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.deepEqual(funktion(), vorher) assert.equal(mails().length, 1) }) test('Kein Abo-Dokument: abgeschaltet, K22 mit dem Abo-Ende-Block', async () => { legeFunktion(1) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/) }) test('Nur ein Hosting-Abo aktiv: abgeschaltet, K22 mit dem Block hosting ("Hosting-Abo")', async () => { legeFunktion(1) hosting('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') const text = mails()[0].text assert.match(text, /weil Dein Abo ein Hosting-Abo ist/) assert.doesNotMatch(text, /Wartungs-Abo am/, 'nicht der Block abo_ende') assert.match(text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'der Kasten "Was jetzt gilt" gehoert auch zum Hosting-Fall') }) test('Widerrufener Wechsel ins Wartungs-Abo (Spiegel sagt "Wartung"): abgeschaltet, K22 mit dem Block hosting', async () => { legeFunktion(1) wartung('active') aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: 'sub_w', type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.match(mails()[0].text, /weil Dein Abo ein Hosting-Abo ist/) }) // ---------------------------------------------------------------- Laeuft weiter / endet: die Status-Tabelle const LAEUFT_WEITER = [ ['past_due', {}], ['trialing', {}], ['active', { cancelAtPeriodEnd: true, cancelAt: new Date(Date.now() + 10 * TAG).toISOString() }], ] for (const [status, zusatz] of LAEUFT_WEITER) { test(`Abo ${status}${zusatz.cancelAtPeriodEnd ? ' mit vorgemerkter Kuendigung in 10 Tagen' : ''}: laeuft weiter, Terminplaner bleibt aktiv, keine Mail`, async () => { legeFunktion(1) wartung(status, zusatz) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().enabled, true) assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) }) } for (const status of ['unpaid', 'incomplete_expired', 'canceled']) { test(`Abo ${status}: Terminplaner abgeschaltet`, async () => { legeFunktion(1) wartung(status) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(funktion().disabledReason, 'abo_ende') }) } // ---------------------------------------------------------------- Lesefehler: nie abschalten test('Lesefehler beim Abo: der Terminplaner bleibt aktiv, fehler 1, keine Mail; der naechste Lauf schaltet ab', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 }), 'nicht lesbar heisst nicht bewertet: geprueft 0') assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Lesefehler erst bei der frischen Pruefung unter der Sperre: bleibt aktiv; der folgende Lauf schaltet ab', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Fehler beim Lesen der Ereignisse (Wechsel-Regel): bleibt aktiv, fehler 1', async () => { legeFunktion(1) wartung('active') aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) test('Fehler beim Lesen der Dokumentliste: kein Wurf, Ergebnis mit fehler, nichts geaendert', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) assert.deepEqual(await gleicheAboAb(JETZT), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(abfragenAufAbos(), 0, 'ohne Liste wird kein Abo gelesen') assert.equal(mails().length, 0) }) test('Ein fehlgeschlagenes Abschalten stoppt den Lauf nicht: das Dokument bleibt aktiv, die anderen Kunden kommen dran', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1) legeFunktion(2, {}, { projekt: 'Zweites Projekt', kunde: 'c_kunde02' }) aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'canceled' })) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500 }) const erg = await laufe() assert.deepEqual(erg, ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1, fehler: 1 })) assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state].sort(), ['active', 'disabled']) assert.equal(mails().length, 1) // der naechste Lauf holt das Fehlgeschlagene nach assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 2, abgeschaltet: 1, mails: 1 })) assert.equal(funktion(funktionsId(1)).state, 'disabled') assert.equal(funktion(funktionsId(2)).state, 'disabled') }) // ---------------------------------------------------------------- Pilot/Kulanz, Entwuerfe, andere Funktionen test('Pilot und Kulanz (unlockedByPurchase false oder fehlend): unveraendert aktiv, nicht geprueft, als ausgenommen gezaehlt, ohne Abo-Abfrage und Mail', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1, { unlockedByPurchase: false }) const ohneFeld = legeFunktion(2, { unlockedByPurchase: undefined }, { kunde: 'c_kunde02' }) // das Feld fehlt (Altbestand, Staff-Freischaltung) assert.notEqual(funktion(ohneFeld).unlockedByPurchase, true) assert.deepEqual(await laufe(), ergebnis({ ausgenommen: 2 })) assert.equal(funktion(funktionsId(1)).state, 'active') assert.equal(funktion(ohneFeld).state, 'active') assert.equal(abfragenAufAbos(), 0, 'fuer ausgenommene Dokumente wird das Abo nicht gelesen') assert.equal(mails().length, 0) }) test('Entwuerfe (off) werden nicht gelesen: eine Abfrage mit featureKey termine und notEqual state off; andere Funktionen bleiben unberuehrt', async () => { legeFunktion(1, { state: 'off', enabled: false }) aw.seed('portalFeatures', { $id: 'f_p_projekt09_news', customerId: KUNDE, projectId: 'p_projekt09', featureKey: 'news', state: 'active', enabled: true, unlockedByPurchase: true, purchaseStatus: 'paid' }) assert.deepEqual(await laufe(), ergebnis()) assert.equal(funktion().state, 'off') assert.equal(funktion('f_p_projekt09_news').state, 'active', 'News fasst der Abgleich nie an') const listen = aw.listCalls('portalFeatures') assert.equal(listen.length, 1, 'eine einzige Listenabfrage') const abfragen = listen[0].queries assert.ok(abfragen.some((q) => q.method === 'equal' && q.attribute === 'featureKey' && q.values[0] === 'termine')) assert.ok(abfragen.some((q) => q.method === 'notEqual' && q.attribute === 'state' && q.values[0] === 'off'), JSON.stringify(abfragen)) assert.equal(abfragenAufAbos(), 0) }) test('ordered, setup und abo_ende_vor_einrichtung zaehlen als geprueft, E4 aendert daran nichts (E5 erweitert)', async () => { legeFunktion(1, { state: 'ordered', enabled: false }) legeFunktion(2, { state: 'setup', enabled: false }) legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 })) assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) }) test('Alt-disabled ohne Grund, team und kunde_geloescht: nie angefasst, nie K22', async () => { const tag = plus(JETZT, -3 * TAG).toISOString() legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: tag }) // Altbestand ohne Grund legeFunktion(2, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'team' }) legeFunktion(3, { state: 'disabled', enabled: false, disabledAt: tag, disabledReason: 'kunde_geloescht' }) const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis()) assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr))), vorher) assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 0) }) test('Dokument ohne Kunden-ID wird nie als "kein Abo" gedeutet: bleibt aktiv, fehler 1', async () => { legeFunktion(1, { customerId: '' }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'active') assert.equal(abfragenAufAbos(), 0) }) // ---------------------------------------------------------------- Rennen test('Rennen: zwischen Vorauswahl und Sperre kommt ein aktives Abo - die frische Pruefung laesst den Terminplaner aktiv', async () => { legeFunktion(1) wartung('canceled') const erg = await mitEinwurf( liste('subscriptions'), async () => wartung('active'), // das Abo kommt zurueck, nachdem die Vorauswahl "beendet" gelesen hat () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) assert.equal(abfragenAufAbos(), 2, 'Vorauswahl und frische Pruefung') }) test('Rennen: zwischen Liste und Sperre schaltet das Team ab - Grund team und disabledAt bleiben, kein K22', async () => { legeFunktion(1) wartung('canceled') await mitEinwurf( liste('portalFeatures'), () => disable(F1, { reason: 'team', adminNote: 'Abgeschaltet von Kenso' }), () => gleicheAboAb(JETZT), ).then((erg) => assert.deepEqual(erg, ergebnis({ geprueft: 1 }))) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'team') assert.equal(doc.adminNote, 'Abgeschaltet von Kenso') assert.ok(Math.abs(ms(doc.disabledAt) - Date.now()) < 60_000, 'disabledAt vom Team, nicht vom Abgleich') assert.equal(doc.aboEndeMailAt, undefined) assert.equal(mails().length, 0) const vorher = { ...doc } assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis(), 'auch am Folgetag nichts') assert.deepEqual(funktion(), vorher) }) test('Rennen: zwischen Liste und Sperre wird das Dokument zum Kulanz-Dokument (nicht vom Kauf freigeschaltet) - bleibt aktiv, nichts passiert', async () => { legeFunktion(1) wartung('canceled') const erg = await mitEinwurf( liste('portalFeatures'), async () => aw.seed('portalFeatures', { ...funktion(), unlockedByPurchase: false }), () => laufe(), ) assert.deepEqual(erg, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(funktion().disabledAt, undefined) assert.equal(mails().length, 0) }) test('Sperre: haelt jemand feature-admin:, bleibt der Abgleich offen, bis sie frei ist', async () => { legeFunktion(1) wartung('canceled') let fertig = false let lauf await withLocks([`feature-admin:${'p_projekt01'}`], async () => { lauf = gleicheAboAb(JETZT).then((erg) => { fertig = true return erg }) await new Promise((resolve) => setTimeout(resolve, 250)) assert.equal(fertig, false, 'der Abgleich wartet auf die Sperre') assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) }) assert.deepEqual(await lauf, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') }) test('Sperre: ein anderes Projekt blockiert nicht', async () => { legeFunktion(1) wartung('canceled') const erg = await withLocks(['feature-admin:p_fremd'], () => gleicheAboAb(JETZT)) assert.deepEqual(erg, ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) }) // ---------------------------------------------------------------- Memo test('Memo: ein Kunde mit drei Projekten - die Vorauswahl liest das Abo einmal, drei Dokumente abgeschaltet, je Projekt eine K22', async () => { legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) legeFunktion(2, {}, { projekt: 'Projekt Beta' }) legeFunktion(3, {}, { projekt: 'Projekt Gamma' }) wartung('canceled') assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 3 })) for (const nr of [1, 2, 3]) { assert.equal(funktion(funktionsId(nr)).state, 'disabled') assert.equal(funktion(funktionsId(nr)).disabledReason, 'abo_ende') } assert.deepEqual(mails().map((m) => m.subject).sort(), [ 'Dein Terminplaner wurde beendet – Projekt Alpha', 'Dein Terminplaner wurde beendet – Projekt Beta', 'Dein Terminplaner wurde beendet – Projekt Gamma', ]) // eine Abfrage fuer die Vorauswahl (nicht eine je Projekt) plus je abzuschaltendem Dokument die frische Pruefung unter seiner Sperre assert.equal(abfragenAufAbos(), 1 + 3) }) test('Memo: mehrere Kunden - je Kunde eine Vorauswahl, die Ausnahmen kosten keine Abfrage', async () => { aw.seed('customers', { $id: 'c_kunde02', name: 'Hans Zweit', email: 'hans@example.com' }) legeFunktion(1) legeFunktion(2, {}, { kunde: 'c_kunde02' }) legeFunktion(3, { unlockedByPurchase: false }, { kunde: 'c_kunde03' }) wartung('active') aw.seed('subscriptions', subscriptionDoc('sub_w2', { ...wartungDoc, customerId: 'c_kunde02', status: 'active' })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, ausgenommen: 1 })) assert.equal(abfragenAufAbos(), 2) }) // ---------------------------------------------------------------- K22-Nachlauf test('K22-Nachlauf: der Transport wirft - disabled, Marker leer, fehler 1; der naechste Tag sendet K22 mit unveraenderten Daten', async () => { legeFunktion(1) wartung('canceled') setMailTransportForTests({ sendMail: async () => { throw new Error('550 5.1.1 Recipient address rejected') }, }) assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) const doc = funktion() assert.equal(doc.state, 'disabled') assert.equal(doc.disabledReason, 'abo_ende') assert.ok(!doc.aboEndeMailAt, 'Marker erst nach Erfolg') assert.ok(log.error.mock.calls.length >= 1, 'der Fehler steht im Log (ohne Adresse, das prueft afterEach)') setMailTransportForTests(sink) const disabledAt = doc.disabledAt const naechster = plus(JETZT, TAG) assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mails().length, 1) assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 05\.10\.2026 beendet wurde/, 'Enddatum aus disabledAt, nicht vom Folgetag') assert.match(mails()[0].text, /Bis 04\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum bleibt: die Frist wuchs nicht') assert.equal(funktion().disabledAt, disabledAt) assert.equal(ms(funktion().aboEndeMailAt), naechster.getTime()) assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'am dritten Tag nichts mehr') assert.equal(mails().length, 1) }) test('K22-Nachlauf: Marker-Schreiben scheitert nach dem Versand - fehler 1, die Mail ist raus, der naechste Tag meldet erneut und setzt den Marker', async () => { legeFunktion(1) wartung('canceled') aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, after: 1 }) // 1. PATCH = abschalten, 2. = Marker assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) assert.equal(mails().length, 1) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }), 'lieber doppelt als gar nicht') assert.equal(ms(funktion().aboEndeMailAt), plus(JETZT, TAG).getTime()) assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) }) for (const [fall, vorbereitung] of [ ['Kunde geloescht', () => deleteDocument('customers', KUNDE)], ['Kunde ohne E-Mail', () => aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' })], ]) { test(`K22-Nachlauf: ${fall} - kein Wurf, keine Mail, Marker gesetzt, am Folgetag nichts`, async () => { legeFunktion(1) wartung('canceled') await vorbereitung() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'kein Wiederholen') assert.equal(mails().length, 0) assert.ok(log.warn.mock.calls.length >= 1, 'ein Hinweis im Log, ohne Adresse (das prueft afterEach)') assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0) }) } test('K22-Nachlauf: das Abo laeuft wieder - Schritt 2 uebergeben (E6), keine Mail, Marker bleibt leer', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) wartung('active') assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt) assert.equal(mails().length, 0) }) test('K22-Nachlauf: mit Marker (aboEndeMailAt) nie eine zweite Mail', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString(), aboEndeMailAt: plus(JETZT, -2 * TAG).toISOString() }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 0) }) test('K22-Nachlauf: Fehler beim frischen Lesen des Abos - keine Mail, fehler 1, der naechste Tag holt sie nach', async () => { legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -TAG).toISOString() }) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frische Pruefung scheitert assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mails().length, 0) assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) assert.match(mails()[0].text, /weil Dein Wartungs-Abo am 04\.10\.2026 beendet wurde/, 'Enddatum = Tag von disabledAt (gestern)') assert.match(mails()[0].text, /Bis 03\.11\.2026 kannst Du Deine Termine/, 'Loeschdatum = disabledAt (04.10.) + 30 Tage') }) // ---------------------------------------------------------------- meldeAboEnde (E5/E9 rufen nur ihn) test('meldeAboEnde: sendet K22 und setzt den Marker (true), mit Marker nichts mehr (true, keine Mail), Fehler im Versand false', async () => { const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) const lage = { laeuft: false, art: 'beendet', endedAt: null } const spaeter = plus(JETZT, 2 * TAG) setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter }), false) assert.ok(!funktion(id).aboEndeMailAt) setMailTransportForTests(sink) let gesendet = 0 assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) assert.equal(gesendet, 1) assert.equal(mails().length, 1) assert.equal(ms(funktion(id).aboEndeMailAt), spaeter.getTime()) assert.equal(await meldeAboEnde(funktion(id), { lage, now: spaeter, onGesendet: () => (gesendet += 1) }), true) assert.equal(gesendet, 1) assert.equal(mails().length, 1, 'der Marker verhindert die zweite Mail') }) test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, ein Lesefehler beim Projekt wiederholt am naechsten Tag', async () => { const id = legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) await deleteDocument('websiteProjects', 'p_projekt01') const lage = { laeuft: false, art: 'beendet', endedAt: null } assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) assert.equal(mails()[0].subject, 'Dein Terminplaner wurde beendet – Dein Projekt') const id2 = legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }) aw.failNext({ collection: 'websiteProjects', method: 'GET', status: 500 }) assert.equal(await meldeAboEnde(funktion(id2), { lage, now: JETZT }), false) assert.ok(!funktion(id2).aboEndeMailAt) assert.equal(mails().length, 1) })