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' // Abo-Abgleich von Öffnungszeiten live (Aufgabe 21, Spec 5 "Abo-Abgleich" Schritte 1 bis 4 und Z11): gleicheZeitenAboAb(now) koppelt die im // Wartungs-Abo enthaltenen Zeiten (unlockedBy 'abo') an das Abo: Abo-Ende erkennen (aboEndeAt, K29), nach 30 Tagen abschalten (K30, Ersatz-Ticket), // bei Rückkehr des Abos wieder einschalten (K27 "wieder", Neubau nach dem Abschalten), 30 Tage nach dem Abschalten die Daten löschen. // Aufbau wie termineAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit JETZT; Tage zählen nach Berliner Kalendertagen. // Datumsfelder wie in Appwrite: UTC mit "+00:00", Unlesbares oder '' wäre dort ein 400 (leeren geht nur mit null) const DATETIMES = { ...ZEITEN_DATETIMES, portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], } const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), 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 { 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 { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = await import('../server/services/siteFeatures.js') const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js') const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js') const { heute, plusTage, uhrzeit, zuUtc } = await import('../server/services/termine/zeit.js') after(() => setMailTransportForTests(null)) const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (Sommerzeit) const KUNDE = 'c1' const ZUVOR = '2026-09-20T10:00:00.000Z' const BASIS_VORHER = config.publicBaseUrl const originalRebuild = zeitenDeps.rebuild const ms = (wert) => new Date(wert).getTime() /** Der Tag `tage` Kalendertage nach basis, zur selben Berliner Uhrzeit (nie Vielfache von 24 Stunden: am 25.10. endet die Sommerzeit). */ const nach = (tage, basis = JETZT) => zuUtc(plusTage(heute(basis), tage), uhrzeit(basis)) let sink let log let rebuilds let sperrenBeimVersand /** Ist die Sperre gerade frei? Eine gehaltene Sperre lässt die Probe warten: nach 60 ms gilt sie als gehalten. */ const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))]) /** Alle Dokumente der Tests entfernen (auch mitten im Test, für eine zweite Runde). */ async function raeumeAuf() { for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects', 'workorders']) { 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() resetZeitenCacheForTests() config.publicBaseUrl = 'https://project.webklar.com' sink = mailSink() // Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (die Mails gehen erst nach der Sperre raus) sperrenBeimVersand = [] const roh = sink.sendMail.bind(sink) sink.sendMail = async (mail) => { sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1') }) return roh(mail) } setMailTransportForTests(sink) rebuilds = [] zeitenDeps.rebuild = async (projekt) => { rebuilds.push({ projektId: projekt.$id, state: aw.docs('portalFeatures').get(`f_${projekt.$id}_zeiten`)?.state, adminFrei: await sperreFrei(`feature-admin:${projekt.$id}`) }) return { queued: true } } 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.timers.reset() mock.restoreAll() zeitenDeps.rebuild = originalRebuild 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${nr}` const funktionsId = (nr = 1) => `f_p${nr}_zeiten` const funktion = (nr = 1) => aw.docs('portalFeatures').get(funktionsId(nr)) function tage(woche = {}) { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } return t } const EIN_PLAN = () => ({ land: 'RP', plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, }) /** * Funktion "Öffnungszeiten live" von kunde für Projekt nr, wie nach dem Einschalten im Wartungs-Abo: aktiv, Ticket geschlossen, K27 gesendet, * Zeiten gespeichert (daten, ersatzHash leer). Das Projekt "Praxis Muster" und das Einbau-Ticket legt die Hilfe mit an. */ async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, projekt = 'Praxis Muster', zeiten = true } = {}) { aw.seed('websiteProjects', { $id: projektId(nr), customerId: kunde, projectName: projekt, subdomain: `praxis${nr}`, repoFullName: `JUSN/praxis${nr}`, previewMode: 'auto', previewDetected: 'static' }) aw.seed('workorders', { $id: `t_${nr}`, woid: `WO-${4710 + nr}`, status: 'Closed', topic: 'Öffnungszeiten live einbauen' }) const doc = aw.seed('portalFeatures', { $id: funktionsId(nr), customerId: kunde, projectId: projektId(nr), featureKey: 'zeiten', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`, ticketId: `t_${nr}`, einbau: 'angestossen', einschaltMailAt: ZUVOR, activatedAt: ZUVOR, ...over, }) if (zeiten) await speichereZeiten({ feature: funktion(nr), eingabe: EIN_PLAN(), version: 0, now: new Date(ZUVOR) }) return doc } /** Wartungs-Abo (ein Abo-Dokument je Kunde; ein zweiter Aufruf ersetzt es). */ const wartung = (status, over = {}, kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...wartungDoc, customerId: kunde, status, ...over })) const hosting = (status = 'active', kunde = KUNDE) => aw.seed('subscriptions', subscriptionDoc(`sub_${kunde}`, { ...hostingDoc, customerId: kunde, status })) const abgelaufen = () => wartung('canceled', { endedAt: ZUVOR, canceledAt: ZUVOR }) const laeuft = () => wartung('active') /** Der Abgleich läuft "zur Zeit now": die Wanduhr steht fest auf now, damit disable() und activate() (new Date()) denselben Zeitpunkt schreiben. */ async function laufe(now = JETZT) { mock.timers.enable({ apis: ['Date'], now: now.getTime() }) try { return await gleicheZeitenAboAb(now) } finally { mock.timers.reset() } } const ERGEBNIS_LEER = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 } const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) const K29 = /^Dein Wartungs-Abo ist beendet/ const K30 = 'Öffnungszeiten live ist abgeschaltet' const K27 = 'Öffnungszeiten live ist eingeschaltet' const mails = () => sink.sent const mailsMit = (betreff) => mails().filter((m) => (betreff instanceof RegExp ? betreff.test(m.subject) : m.subject === betreff)) const tickets = () => [...aw.docs('workorders').values()] const ersatzTickets = () => tickets().filter((t) => String(t.topic).startsWith('Ersatz-Tabelle aktualisieren')) const abfragenAufAbos = () => aw.listCalls('subscriptions').length /** Die Zustände eines Dokuments, die der Abgleich bewegt (für Vergleiche "unverändert"). */ const stand = (nr = 1) => structuredClone({ ...funktion(nr) }) /** * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, läuft 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) /** Die volle Folge bis zum Abschalten: Tag 0 Abo-Ende erkannt (K29), Tag 30 abgeschaltet (K30). */ async function bisAbgeschaltet() { await legeFunktion(1) abgelaufen() await laufe(JETZT) await laufe(nach(30)) assert.equal(funktion().state, 'disabled') } // ---------------------------------------------------------------- Schritt 1: Abo-Ende erkennen, K29 test('Abo endet: aboEndeAt gesetzt, K29 einmal', async () => { await legeFunktion(1) abgelaufen() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'active', 'die Funktion läuft bis zur Frist weiter') assert.equal(doc.enabled, true) assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt = Zeitpunkt der Erkennung') assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker der K29 erst nach dem Versand') assert.equal(doc.unlockedBy, 'abo') assert.equal(mails().length, 1) const mail = mails()[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.match(mail.subject, K29) assert.match(mail.text, /Silke Müller/) assert.match(mail.text, /Dein Wartungs-Abo ist beendet\./, 'Variante beendet') assert.match(mail.text, /Läuft weiter bis: 04\.11\.2026/, 'Frist = Tag der Erkennung + 30 Tage') assert.match(mail.text, /einmalig 30 €/) assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Behalten-Link zum Editor') assert.ok(mail.bcc?.length, 'Kopie ans Team wie bei jeder Kundenmail') assert.equal(rebuilds.length, 0) // am selben Tag und am Folgetag: nichts mehr, keine zweite Mail, aboEndeAt bleibt (die Frist wird nie verschoben) const vorher = stand() assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1 })) assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 })) assert.deepEqual(stand(), vorher) assert.equal(mails().length, 1) }) test('Läuft das Abo, geschieht nichts: kein Schreiben, keine Mail', async () => { await legeFunktion(1) laeuft() const vorher = stand() assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) assert.deepEqual(stand(), vorher) assert.equal(mails().length, 0) }) test('K29-Variante bei Wechsel zu Hosting und bei widerrufenem Wechsel', async () => { const varianten = [ ['Hosting-Abo', () => hosting('active'), /Dein Abo ist ein Hosting-Abo\./, /Du hast den Wechsel in das Wartungs-Abo widerrufen/], [ 'Wechsel widerrufen (der Spiegel sagt noch "Wartung läuft")', () => { wartung('active') aw.seed('contractEvents', { $id: 'ev_1', customerId: KUNDE, subscriptionId: `sub_${KUNDE}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen' }) }, /Du hast den Wechsel in das Wartungs-Abo widerrufen/, /Dein Wartungs-Abo ist beendet\./, ], ] for (const [name, abo, passt, passtNicht] of varianten) { await raeumeAuf() sink.sent.length = 0 await legeFunktion(1) abo() assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1, mails: 1 }), name) assert.match(mails()[0].subject, K29, name) assert.match(mails()[0].text, passt, name) assert.doesNotMatch(mails()[0].text, passtNicht, name) assert.equal(funktion().state, 'active', `${name}: die Frist läuft`) } }) test('K29 geht nur an die Adresse des Kundenkontos; ohne Adresse setzt der Lauf den Marker ohne Fehler', async () => { await legeFunktion(1) aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: '' }) abgelaufen() assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, aboEnde: 1 })) assert.equal(mails().length, 0) assert.equal(ms(funktion().aboEndeMailAt), JETZT.getTime(), 'es gibt nichts zu senden, also auch nichts zu wiederholen') assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1 })) }) test('K29 scheitert (Mail): aboEndeAt steht, der Marker fehlt, der nächste Lauf sendet nach (einmal)', async () => { await legeFunktion(1) abgelaufen() const roh = sink.sendMail sink.sendMail = async () => { throw new Error('SMTP down für silke@example.com') } assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, aboEnde: 1, fehler: 1 })) assert.equal(ms(funktion().aboEndeAt), JETZT.getTime()) assert.ok(!funktion().aboEndeMailAt, 'ohne Versand kein Marker') assert.equal(mails().length, 0) sink.sendMail = roh assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mailsMit(K29).length, 1) assert.equal(ms(funktion().aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt: die Frist wächst nicht') assert.match(mails()[0].text, /Läuft weiter bis: 04\.11\.2026/, 'dieselbe Frist wie beim ersten Versuch') assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) }) // ---------------------------------------------------------------- Schritt 2: nach 30 Tagen abschalten, K30, Ersatz-Ticket test('Tag 29: noch aktiv; Tag 30: disabled abo_ende, K30, Ersatz-Ticket', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) mails().length = 0 const tag29 = await laufe(nach(29)) assert.deepEqual(tag29, ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') assert.equal(mails().length, 0) const tag30 = nach(30) assert.equal(heute(tag30), '2026-11-04') assert.deepEqual(await laufe(tag30), 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), tag30.getTime()) assert.equal(ms(doc.aboEndeAt), JETZT.getTime(), 'aboEndeAt bleibt als Spur stehen') assert.equal(ms(doc.aboEndeMailAt), tag30.getTime(), 'Marker gilt jetzt der K30') assert.match(doc.adminNote, /System: Abgeschaltet: Wartungs-Abo beendet/) // K30: Datum der Löschung = Abschalten + 30 Tage assert.equal(mailsMit(K30).length, 1) const mail = mailsMit(K30)[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.match(mail.text, /Eingaben gespeichert bis: 04\.12\.2026/) assert.match(mail.text, /30 €/) assert.ok(mail.bcc?.length) // Ersatz-Ticket (R23: ohne gemerkten Hash gilt der Stand als unbekannt, Zeiten sind gespeichert) assert.equal(ersatzTickets().length, 1) assert.match(ersatzTickets()[0].topic, /Praxis Muster/) assert.ok((await ladeZeiten(funktionsId(1))).ersatzHash, 'der Hash wurde nach dem Ticket gemerkt') assert.equal(rebuilds.length, 0) // Folgetag: nichts mehr (kein zweites Ticket, keine zweite Mail, disabledAt bleibt) const vorher = stand() assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 })) assert.deepEqual(stand(), vorher) assert.equal(ersatzTickets().length, 1) assert.equal(mails().length, 1) }) test('Frist nach Berliner Kalendertagen, nicht nach 24-Stunden-Schritten (Ende der Sommerzeit 25.10.)', async () => { // Abo-Ende erkannt am 24.10. um 23:30 Berliner Zeit (21:30 UTC); Frist = 23.11. const erkannt = new Date('2026-10-24T21:30:00.000Z') await legeFunktion(1, { aboEndeAt: erkannt.toISOString(), aboEndeMailAt: erkannt.toISOString() }) abgelaufen() // 22.11., 23:59 Berliner Zeit: noch ein Tag assert.deepEqual(await laufe(new Date('2026-11-22T22:59:00.000Z')), ergebnis({ geprueft: 1 })) assert.equal(funktion().state, 'active') // 23.11., 00:01 Berliner Zeit: der Tag der Frist ist da, auch vor 23:30 (24 x 30 Stunden später wäre erst 22:30 am 23.11.) assert.deepEqual(await laufe(new Date('2026-11-22T23:01:00.000Z')), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) assert.equal(funktion().state, 'disabled') assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 23\.12\.2026/) }) test('Tag 30 mit zurückgekehrtem Abo: nicht abgeschaltet, sondern die Rückkehr', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) laeuft() assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') assert.equal(ersatzTickets().length, 0) assert.equal(mailsMit(K30).length, 0) }) test('K30 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste Lauf sendet nach; das Ersatz-Ticket entsteht nur einmal', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) const roh = sink.sendMail sink.sendMail = async () => { throw new Error('SMTP down') } assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K29 gilt nicht für die K30') assert.equal(ersatzTickets().length, 1) sink.sendMail = roh assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mailsMit(K30).length, 1) assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung, nicht von heute') assert.equal(ersatzTickets().length, 1) assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) }) test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K30 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K30 genau einmal', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) mails().length = 0 aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.equal(ersatzTickets().length, 0) assert.equal(mailsMit(K30).length, 0, 'K30 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus') assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K30-Marker') // der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K30 einmal assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(ersatzTickets().length, 1) assert.equal(mailsMit(K30).length, 1) assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung') assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K30') assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 })) assert.equal(ersatzTickets().length, 1) assert.equal(mailsMit(K30).length, 1) }) test('Ersatz-Ticket scheitert mehrere Läufe: kein K30, jeder Lauf zählt fehler; sobald es klappt, genau eine K30', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) mails().length = 0 aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) await laufe(nach(30)) aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' }) assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(mailsMit(K30).length, 0) assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mailsMit(K30).length, 1) assert.equal(ersatzTickets().length, 1) }) test('Abschalten gelingt, die Folge scheitert (Marker der K29 steht noch, älter als disabledAt): der nächste Lauf holt K30 und das Ersatz-Ticket nach, einmal', async () => { // So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K29 await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR }) abgelaufen() assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mailsMit(K30).length, 1) assert.equal(ersatzTickets().length, 1) assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt)) assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) assert.equal(ersatzTickets().length, 1) }) test('Wanduhr nach dem Laufstart (disabledAt später als now): der K30-Marker liegt nie davor, K30 geht nicht täglich raus', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) mails().length = 0 const tag30 = nach(30) mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs try { assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) } finally { mock.timers.reset() } assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt') assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 })) assert.equal(mailsMit(K30).length, 1) }) // ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos test('Rückkehr an Tag 45: zurück auf active, Neubau, K27 wieder', async () => { await bisAbgeschaltet() mails().length = 0 laeuft() const tag45 = nach(45) assert.deepEqual(await laufe(tag45), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'active', 'das Ticket war geschlossen (activatedAt)') assert.equal(doc.enabled, true) assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, '']) assert.equal(doc.unlockedBy, 'abo') assert.equal(doc.einbau, 'angestossen', 'Ergebnis des neuen Neubaus') assert.match(doc.adminNote, /Wieder eingeschaltet: Wartungs-Abo läuft wieder/) // Neubau nach dem Schreiben des Zustands und ausserhalb der Sperre assert.deepEqual(rebuilds, [{ projektId: 'p1', state: 'active', adminFrei: true }]) // K27 mit dem Block wieder und dem Neubau-Text assert.equal(mailsMit(K27).length, 1) const mail = mailsMit(K27)[0] assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) assert.match(mail.text, /nach dem nächsten Veröffentlichen/) assert.doesNotMatch(mail.text, /\(Ticket #/, 'kein neues Ticket, keine Ticketnummer') // die Daten sind da (nie angefasst) und danach ist wieder Ruhe assert.ok((await ladeZeiten(funktionsId(1))).daten) assert.deepEqual(await laufe(nach(46)), ergebnis({ geprueft: 1 })) assert.equal(rebuilds.length, 1) }) test('Rückkehr aus disabled: ein nie freigeschaltetes Dokument (kein activatedAt) kommt auf setup zurück; einbau manuell sagt K27 "das Team baut ein"', async () => { await legeFunktion(1, { state: 'setup', enabled: false, activatedAt: null }) abgelaufen() await laufe(JETZT) await laufe(nach(30)) assert.equal(funktion().state, 'disabled') mails().length = 0 zeitenDeps.rebuild = async () => ({ queued: false }) laeuft() assert.deepEqual(await laufe(nach(40)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) const doc = funktion() assert.equal(doc.state, 'setup') assert.equal(doc.enabled, false) assert.equal(doc.einbau, 'manuell') assert.equal(doc.ticketId, 't_1', 'das Einbau-Ticket bleibt, der 5-Minuten-Job gibt frei, sobald es zu ist') assert.match(mails()[0].text, /baut unser Team/) }) test('Rückkehr vor dem Abschalten (Tag 10): aboEndeAt leer, Zustand bleibt, kein Neubau, K27 wieder ohne "Wie geht es weiter"', async () => { for (const [name, over] of [ ['aktiv', {}], ['in Einrichtung', { state: 'setup', enabled: false, activatedAt: null }], ]) { await raeumeAuf() mails().length = 0 rebuilds.length = 0 await legeFunktion(1, over, { zeiten: false }) abgelaufen() await laufe(JETZT) // eine offene Behalten-Zahlung (Sitzung) ist mit der Rückkehr hinfällig aw.seed('portalFeatures', { ...funktion(1), behaltenSessionId: 'cs_test_offen' }) assert.equal(funktion().behaltenSessionId, 'cs_test_offen', name) mails().length = 0 laeuft() assert.deepEqual(await laufe(nach(10)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 }), name) const doc = funktion() assert.equal(doc.state, over.state ?? 'active', `${name}: Zustand bleibt`) assert.deepEqual([doc.aboEndeAt ?? null, doc.aboEndeMailAt ?? null, doc.behaltenSessionId ?? ''], [null, null, ''], `${name}: aboEndeAt, Marker und Sitzung geleert`) assert.equal(doc.einbau, 'angestossen', name) assert.equal(rebuilds.length, 0, `${name}: die Leisten waren nie weg, kein Neubau`) assert.equal(mailsMit(K27).length, 1, name) const text = mailsMit(K27)[0].text assert.match(text, /Dein Wartungs-Abo läuft wieder/, name) assert.doesNotMatch(text, /Wie geht es weiter/, `${name}: beide Blöcke aus, der Kasten entfällt`) assert.doesNotMatch(text, /nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten/, name) } }) test('Rückkehr ohne vorherige K29 (kein Marker): die Funktion wird bereinigt, der Kunde bekommt keine Mail über etwas, das er nie erfuhr', async () => { await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), behaltenSessionId: 'cs_test_offen' }) laeuft() assert.deepEqual(await laufe(nach(3)), ergebnis({ geprueft: 1, wiederEin: 1 })) assert.equal(mails().length, 0) assert.deepEqual([funktion().aboEndeAt ?? null, funktion().behaltenSessionId ?? ''], [null, '']) }) test('Rückkehr: das Abo kommt zwischen Vorauswahl und Sperre zurück ⇒ der frisch gelesene Stand entscheidet, nicht abgeschaltet', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) mails().length = 0 const erg = await mitEinwurf(liste('subscriptions'), async () => laeuft(), () => laufe(nach(30))) assert.deepEqual(erg, ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') assert.equal(mailsMit(K30).length, 0) assert.equal(mailsMit(K27).length, 1) assert.equal(ersatzTickets().length, 0) }) test('Rückkehr am Tag der Löschung, Daten noch da: die Rückkehr gewinnt vor der Löschung', async () => { await bisAbgeschaltet() laeuft() assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, wiederEin: 1, mails: 1 })) assert.equal(funktion().state, 'active') assert.ok((await ladeZeiten(funktionsId(1))).daten, 'Daten erhalten') }) test('Löschung fällig, aber die Daten sind schon weg (Lauf nach dem Löschen unterbrochen) und das Abo läuft: nicht wieder einschalten, sondern abschliessen', async () => { await bisAbgeschaltet() await deleteDocument('oeffnungszeiten', funktionsId(1)) laeuft() assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, geloescht: 1 })) assert.equal(funktion().state, 'off') assert.equal(rebuilds.length, 0) assert.equal(mailsMit(K27).length, 0) }) // ---------------------------------------------------------------- Schritt 4: Löschung test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht, state off', async () => { await bisAbgeschaltet() const feature = funktion() aw.seed('zeitenErinnerungen', { $id: 'm1', featureId: feature.$id, datum: '2026-11-10', createdAt: JETZT.toISOString() }) aw.seed('zeitenErinnerungen', { $id: 'm2', featureId: 'f_andere_zeiten', datum: '2026-11-10', createdAt: JETZT.toISOString() }) mails().length = 0 const tag59 = nach(59) assert.equal(heute(tag59), '2026-12-03') assert.deepEqual(await laufe(tag59), ergebnis({ geprueft: 1 }), 'Tag 59 nach dem Abo-Ende: noch nichts') assert.ok(await ladeZeiten(feature.$id)) const tag60 = nach(60) assert.equal(heute(tag60), '2026-12-04', '30 Tage nach der Abschaltung am 04.11.') assert.deepEqual(await laufe(tag60), ergebnis({ geprueft: 1, geloescht: 1 })) const doc = funktion() assert.equal(doc.state, 'off') assert.equal(doc.enabled, false) assert.deepEqual( [doc.ticketId ?? '', doc.einbau ?? '', doc.einschaltMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null], ['', '', null, null, '', null], 'ticketId, einbau, einschaltMailAt, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)' ) assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/) assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht') assert.equal(aw.docs('oeffnungszeiten').size, 0) assert.equal(aw.docs('zeitenErinnerungen').has('m1'), false, 'Erinnerungsmerker der Funktion gelöscht') assert.equal(aw.docs('zeitenErinnerungen').has('m2'), true, 'fremde Merker bleiben') assert.equal(mails().length, 0, 'die K30 hat die Löschung angekündigt, es gibt keine weitere Mail') // aus ist aus: der nächste Lauf liest das Dokument nicht mehr assert.deepEqual(await laufe(nach(61)), ergebnis()) }) test('nach der Löschung: ticketId, einbau, einschaltMailAt, ersatzHash leer; Neubestellung legt ein neues Ticket an', async () => { await bisAbgeschaltet() await laufe(nach(60)) assert.equal(funktion().state, 'off') assert.equal(await ladeZeiten(funktionsId(1)), null, 'ersatzHash lebte im gelöschten Dokument') const ticketsVorher = tickets().length mails().length = 0 rebuilds.length = 0 // Das Abo läuft wieder, der Kunde schaltet neu ein (Gratisweg): neues Ticket, Neubau, K27 laeuft() const { doc, changed } = await einschaltenZeiten({ customerId: KUNDE, projectId: 'p1', by: 'kunde', now: nach(61) }) assert.equal(changed, true) assert.equal(doc.state, 'setup') assert.ok(doc.ticketId && doc.ticketId !== 't_1', 'ein neues Einbau-Ticket') assert.equal(tickets().length, ticketsVorher + 1) assert.equal(doc.einbau, 'angestossen') assert.ok(doc.einschaltMailAt, 'K27 neu gesendet') assert.equal(mailsMit(K27).length, 1) assert.equal(rebuilds.length, 1) }) test('Löschung scheitert (Zeiten-Dokument nicht löschbar): state bleibt disabled, fehler zählt, der nächste Lauf versucht es wieder', async () => { await bisAbgeschaltet() aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'Löschen kaputt' }) assert.deepEqual(await laufe(nach(60)), ergebnis({ geprueft: 1, fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.deepEqual(await laufe(nach(61)), ergebnis({ geprueft: 1, geloescht: 1 })) assert.equal(funktion().state, 'off') }) test('Abo-Lesefehler vor der Löschung: es wird nie gelöscht', async () => { await bisAbgeschaltet() aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) assert.deepEqual(await laufe(nach(60)), ergebnis({ fehler: 1 })) assert.equal(funktion().state, 'disabled') assert.ok(await ladeZeiten(funktionsId(1))) }) // ---------------------------------------------------------------- Nur unlockedBy abo test('gekaufte und Team-Freischaltungen bleiben unberührt (das Abo wird nicht einmal gelesen)', async () => { await legeFunktion(1, { unlockedBy: 'kauf', unlockedByPurchase: true, purchaseStatus: 'paid' }) await legeFunktion(2, { unlockedBy: 'team' }) await legeFunktion(3, { unlockedBy: undefined, unlockedByPurchase: true, purchaseStatus: 'paid' }) // Altbestand ohne unlockedBy: Kauf await legeFunktion(4, { unlockedBy: undefined }) // Altbestand ohne unlockedBy und ohne Kauf: Team await legeFunktion(5, { state: 'disabled', enabled: false, unlockedBy: 'kauf', unlockedByPurchase: true, disabledReason: 'abo_ende', disabledAt: '2026-08-01T08:00:00.000Z' }) await legeFunktion(6, { state: 'setup', enabled: false, unlockedBy: 'team', aboEndeAt: ZUVOR }) abgelaufen() const vorher = [1, 2, 3, 4, 5, 6].map((nr) => stand(nr)) aw.resetListCalls() for (const now of [JETZT, nach(40), nach(100)]) assert.deepEqual(await laufe(now), ergebnis()) assert.deepEqual([1, 2, 3, 4, 5, 6].map((nr) => stand(nr)), vorher) assert.equal(abfragenAufAbos(), 0, 'kein gekoppeltes Dokument: keine Abo-Abfrage') assert.equal(mails().length, 0) assert.equal(ersatzTickets().length, 0) assert.equal(rebuilds.length, 0) assert.equal(aw.docs('oeffnungszeiten').size, 6) }) test('News und Terminplaner (andere featureKey) und Entwürfe (off) bleiben unberührt', async () => { await legeFunktion(1, { state: 'off', enabled: false }) aw.seed('portalFeatures', { $id: 'f_p9_termine', customerId: KUNDE, projectId: 'p9', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoz' }) aw.seed('portalFeatures', { $id: 'f_p9_news', customerId: KUNDE, projectId: 'p9', featureKey: 'news', state: 'active', enabled: true, unlockedBy: 'abo', unlockedByPurchase: false, siteKey: 'abcdefghijklmnoy' }) abgelaufen() const vorher = [funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })) assert.deepEqual(await laufe(nach(40)), ergebnis()) assert.deepEqual([funktion(), aw.docs('portalFeatures').get('f_p9_termine'), aw.docs('portalFeatures').get('f_p9_news')].map((d) => structuredClone({ ...d })), vorher) assert.equal(abfragenAufAbos(), 0) }) test('Grund team (und alle anderen Gründe ausser abo_ende) wird nie gelöscht', async () => { const alt = '2026-06-01T08:00:00.000Z' await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: alt, aboEndeAt: alt }) await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: alt }) await legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: alt }) await legeFunktion(4, { state: 'disabled', enabled: false, disabledAt: alt }) // Altbestand ohne Grund abgelaufen() const vorher = [1, 2, 3, 4].map((nr) => stand(nr)) assert.deepEqual(await laufe(nach(200)), ergebnis()) assert.deepEqual([1, 2, 3, 4].map((nr) => stand(nr)), vorher) assert.equal(aw.docs('oeffnungszeiten').size, 4, 'alle Daten bleiben') // auch bei zurückgekehrtem Abo bleibt die Team-Abschaltung abgeschaltet laeuft() assert.deepEqual(await laufe(nach(201)), ergebnis()) assert.equal(funktion(1).state, 'disabled') assert.equal(mails().length, 0) }) // ---------------------------------------------------------------- Fehler und Fail-closed test('Abo-Lesefehler: überspringen, nie abschalten (der andere Kunde ist trotzdem dran, der nächste Lauf holt es nach)', async () => { // c1: Frist verstrichen, würde abgeschaltet; c2: Löschtag erreicht, würde gelöscht await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }, { kunde: 'c2' }) aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) wartung('canceled', { endedAt: ZUVOR }) wartung('canceled', { endedAt: ZUVOR }, 'c2') const vorher = [stand(1), stand(2)] // das Lesen des Abos von c1 scheitert (Vorauswahl, der erste Abo-Abruf): nichts wird abgeschaltet und nichts gesendet aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' }) assert.deepEqual(await laufe(nach(200)), ergebnis({ geprueft: 1, geloescht: 1, fehler: 1 })) assert.deepEqual(stand(1), vorher[0], 'c1 unverändert') assert.equal(funktion(2).state, 'off', 'c2 war lesbar und fällig') assert.equal(mails().length, 0) assert.equal(ersatzTickets().length, 0) // das Lesen des Abos von c2 scheitert, c1 ist lesbar: c2 bleibt, wie es ist (nie löschen, wenn unklar ist, ob das Abo zurück ist) await raeumeAuf() await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) wartung('canceled', { endedAt: ZUVOR }) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' }) assert.deepEqual(await laufe(nach(200)), ergebnis({ fehler: 1 })) assert.equal(funktion(1).state, 'disabled') assert.ok(await ladeZeiten(funktionsId(1)), 'Daten erhalten') // der nächste Lauf mit lesbarem Abo holt es nach assert.deepEqual(await laufe(nach(201)), ergebnis({ geprueft: 1, geloescht: 1 })) assert.equal(funktion(1).state, 'off') }) test('Abo-Lesefehler unter der Sperre (zweites Lesen): nichts wird geschrieben', async () => { await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) abgelaufen() const vorher = stand() aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, frisches Lesen scheitert assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, fehler: 1 })) assert.deepEqual(stand(), vorher) assert.equal(mails().length, 0) assert.equal(ersatzTickets().length, 0) }) test('Liste nicht lesbar: der Abgleich entfällt, kein Wurf', async () => { await legeFunktion(1) aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Appwrite down' }) assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) assert.equal(mails().length, 0) }) test('ein unlesbares Datum bei einem Dokument stoppt die anderen nicht (je Dokument gefangen)', async () => { await legeFunktion(1, { aboEndeAt: JETZT.toISOString(), aboEndeMailAt: JETZT.toISOString() }) await legeFunktion(2, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString() }, { kunde: 'c2' }) await legeFunktion(3, {}, { kunde: 'c3' }) aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) aw.seed('customers', { $id: 'c3', name: 'Eva Schmidt', email: 'eva@example.com' }) for (const kunde of [KUNDE, 'c2', 'c3']) wartung('canceled', { endedAt: ZUVOR }, kunde) // Datumsfelder, die Appwrite nie liefern würde: der Nachbau prüft sie hier nicht const feldNamen = DATETIMES.portalFeatures DATETIMES.portalFeatures = feldNamen.filter((f) => f !== 'aboEndeAt' && f !== 'disabledAt') try { aw.seed('portalFeatures', { ...funktion(1), aboEndeAt: 'kein-datum' }) aw.seed('portalFeatures', { ...funktion(2), disabledAt: 'kein-datum' }) } finally { DATETIMES.portalFeatures = feldNamen } const vorher = [stand(1), stand(2)] const erg = await laufe(nach(30)) assert.deepEqual(erg, ergebnis({ geprueft: 3, aboEnde: 1, mails: 1, fehler: 2 }), 'das dritte Dokument ist trotzdem dran: Abo-Ende erkannt, K29') assert.deepEqual([stand(1), stand(2)], vorher, 'die kaputten Dokumente bleiben, wie sie sind') assert.deepEqual(mails().map((m) => m.to[0].address), ['eva@example.com']) assert.equal(ersatzTickets().length, 0) assert.equal(aw.docs('oeffnungszeiten').size, 3, 'nichts gelöscht') }) test('Kunde nicht lesbar beim Versand: fehler, das nächste Dokument ist trotzdem dran, der nächste Lauf sendet die K29 nach', async () => { await legeFunktion(1) await legeFunktion(2, {}, { kunde: 'c2' }) aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) for (const kunde of [KUNDE, 'c2']) wartung('canceled', { endedAt: ZUVOR }, kunde) aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite down' }) assert.deepEqual(await laufe(), ergebnis({ geprueft: 2, aboEnde: 2, mails: 1, fehler: 1 })) assert.equal(ms(funktion(1).aboEndeAt), JETZT.getTime(), 'das Abo-Ende steht, die Frist läuft') assert.ok(!funktion(1).aboEndeMailAt) assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com']) assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 2, mails: 1 })) assert.deepEqual(mails().map((m) => m.to[0].address), ['karl@example.com', 'silke@example.com']) assert.match(mails()[1].text, /Läuft weiter bis: 04\.11\.2026/, 'Frist vom ersten Lauf, nicht von heute') }) test('Mails und Neubau gehen ohne gehaltene Sperre feature-admin raus', async () => { await legeFunktion(1) abgelaufen() await laufe(JETZT) // K29 await laufe(nach(30)) // K30 laeuft() await laufe(nach(45)) // Neubau und K27 assert.deepEqual( sperrenBeimVersand.map((s) => [s.subject === K30 ? 'K30' : s.subject === K27 ? 'K27' : 'K29', s.admin]), [['K29', true], ['K30', true], ['K27', true]] ) assert.equal(rebuilds[0].adminFrei, true) }) // ---------------------------------------------------------------- Zähler test('Abo-Abfragen: die Vorauswahl liest je Kunde einmal, dazu das frische Lesen je bearbeitetem Dokument; mehrere Projekte eines Kunden werden einzeln bearbeitet', async () => { await legeFunktion(1) await legeFunktion(2) await legeFunktion(3, {}, { kunde: 'c2' }) aw.seed('customers', { $id: 'c2', name: 'Karl Weber', email: 'karl@example.com' }) wartung('canceled', { endedAt: ZUVOR }, KUNDE) wartung('active', {}, 'c2') aw.resetListCalls() assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, aboEnde: 2, mails: 2 })) assert.equal(abfragenAufAbos(), 2 + 2, 'zwei Kunden in der Vorauswahl; frisch nur für die zwei Dokumente von c1 (das von c2 braucht nichts)') assert.equal(mailsMit(K29).length, 2) assert.deepEqual(mails().map((m) => m.to[0].address), ['silke@example.com', 'silke@example.com']) assert.ok(funktion(1).aboEndeAt && funktion(2).aboEndeAt) assert.ok(!funktion(3).aboEndeAt) }) test('now muss ein gültiges Datum sein (Programmierfehler, wie bei den Terminplaner-Jobs)', async () => { await assert.rejects(() => gleicheZeitenAboAb(undefined), TypeError) await assert.rejects(() => gleicheZeitenAboAb(new Date('x')), TypeError) }) // ---------------------------------------------------------------- R25: activate() räumt das Abo-Ende ab test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Marker und disabledAt); zahlungMeldung bleibt', async () => { await legeFunktion(1, { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeMailAt: ZUVOR, aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen', zahlungMeldung: 'abo_ende', }) // der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection) const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER }) assert.deepEqual([doc.state, doc.enabled], ['active', true]) assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, '']) assert.equal(doc.zahlungMeldung, 'abo_ende') // Folge: ein abgeschalteter Lauf des Abgleichs sieht ein aktives Dokument ohne Abo-Ende laeuft() assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 })) }) test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => { const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' } await legeFunktion(1, offen) const ohne = await activate(funktionsId(1), { adminNote: 'Test' }) assert.equal(ohne.state, 'active') assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen']) await aw.seed('portalFeatures', { ...funktion(1), ...offen }) const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' }) assert.equal(team.state, 'active') assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, '']) }) test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K30 und das Ersatz-Ticket nach', async () => { await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR }) abgelaufen() assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 })) assert.equal(mailsMit(K30).length, 1) assert.match(mailsMit(K30)[0].text, /Eingaben gespeichert bis: 04\.11\.2026/, 'Löschdatum = disabledAt + 30 Tage') assert.equal(ersatzTickets().length, 1) assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 })) assert.equal(mails().length, 1) })