import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' // Oeffnungszeiten live, Kunde geloescht (Spec 5): deleteCustomerWithPortalAccess schaltet 'zeiten' wie 'termine' ab (Notiz "Kunde gelöscht", // Grund kunde_geloescht) und loescht Daten und Erinnerungsmerker sofort; die Loeschfristen (retention.js) loeschen die Daten ebenfalls, // bevor sie die Funktion eines faelligen Kunden abschalten. Fehler halten die Kundenloeschung nie auf. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), takeoverCredits: schemaKeys('takeoverCredits'), newsPosts: schemaKeys('newsPosts'), termineConfig: schemaKeys('termineConfig'), }, datetimes: ZEITEN_DATETIMES, defaultLimit: 25, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) after(() => Promise.all([aw.close(), st.close()])) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') const { oeffentlicheZeitenAntwort } = await import('../server/services/zeiten/oeffentlich.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const { findDueDeletions, applyDeletions, formatReport } = await import('../server/services/retention.js') const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 const JAN_2026 = new Date('2026-01-01T12:00:00+01:00') const MODUS_VORHER = config.zeiten.mode const 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 eingabe = () => ({ land: 'RP', plaene: [{ id: 'p_aaaaaaa1', kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, }) let nr = 0 /** * Kunde mit Projekten und je einer Zeiten-Funktion (state), gespeicherten Zeiten und Erinnerungsmerker; mit news: eine aktive News-Funktion. * Liefert { customerId, features: [{ $id, projectId, siteKey, ... }] }. */ async function legeKunde({ projekte = 1, state = 'active', news = false } = {}) { nr += 1 const customerId = `zl${nr}` aw.seed('customers', { $id: customerId, name: `Salon ${nr}`, companyName: `Salon ${nr} GmbH`, email: `salon${nr}@example.invalid` }) const features = [] for (let i = 1; i <= projekte; i += 1) { const projectId = `zlp${nr}_${i}` // eindeutiger siteKey je Funktion: 16 Zeichen aus [a-z2-7] const siteKey = `zeitenkey${'abcdefghijklmnop'[nr]}${'abcdefghijklmnop'[i]}aaaaa` const feature = { $id: `f_${projectId}_zeiten`, customerId, projectId, featureKey: 'zeiten', state, enabled: state === 'active', siteKey } aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}-${i}` }) aw.seed('portalFeatures', feature) await speichereZeiten({ feature, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) aw.seed('zeitenErinnerungen', { $id: `m_${feature.$id}`, featureId: feature.$id, datum: '2026-12-24', createdAt: '2026-12-01T08:00:00.000Z' }) features.push(feature) } if (news) aw.seed('portalFeatures', { $id: `f_${features[0].projectId}_news`, customerId, projectId: features[0].projectId, featureKey: 'news', state: 'active', enabled: true }) return { customerId, features } } const zeitenDocs = (customerId) => [...aw.docs('oeffnungszeiten').values()].filter((d) => d.customerId === customerId) const merkerVon = (features) => [...aw.docs('zeitenErinnerungen').values()].filter((m) => features.some((f) => f.$id === m.featureId)) const featureDoc = (id) => aw.docs('portalFeatures').get(id) beforeEach(() => { config.zeiten.mode = 'on' resetZeitenCacheForTests() }) afterEach(() => { config.zeiten.mode = MODUS_VORHER mock.restoreAll() }) // ---------------------------------------------------------------- deleteCustomerWithPortalAccess test('Kundenlöschung schaltet zeiten ab und löscht Daten und Merker', async () => { const kunde = await legeKunde({ projekte: 2, news: true }) const fremd = await legeKunde() const schonAus = await legeKunde({ state: 'disabled' }) const frueher = '2026-09-01T10:00:00.000Z' aw.seed('portalFeatures', { ...featureDoc(schonAus.features[0].$id), disabledAt: frueher, disabledReason: 'abo_ende' }) assert.equal(zeitenDocs(kunde.customerId).length, 2) assert.equal(merkerVon(kunde.features).length, 2) await deleteCustomerWithPortalAccess(kunde.customerId) await deleteCustomerWithPortalAccess(schonAus.customerId) assert.equal(aw.docs('customers').has(kunde.customerId), false) for (const f of kunde.features) { const doc = featureDoc(f.$id) assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], f.projectId) assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, f.projectId) } assert.deepEqual(zeitenDocs(kunde.customerId), [], 'Zeiten des Kunden gelöscht') assert.deepEqual(merkerVon(kunde.features), [], 'Erinnerungsmerker des Kunden gelöscht') assert.equal(featureDoc(`f_${kunde.features[0].projectId}_news`).state, 'active', 'News ist nicht Sache der Zeiten') // ein schon abgeschaltetes Dokument behält den Beginn seiner Frist, seine Daten gehen trotzdem mit dem Kunden assert.equal(featureDoc(schonAus.features[0].$id).disabledAt, frueher) assert.equal(featureDoc(schonAus.features[0].$id).disabledReason, 'abo_ende') assert.deepEqual(zeitenDocs(schonAus.customerId), []) // ein anderer Kunde bleibt unberührt assert.equal(featureDoc(fremd.features[0].$id).state, 'active') assert.equal(zeitenDocs(fremd.customerId).length, 1) assert.equal(merkerVon(fremd.features).length, 1) }) test('öffentliche Antwort danach aktiv false', async () => { const kunde = await legeKunde() const fremd = await legeKunde() const [eigene] = kunde.features assert.equal((await oeffentlicheZeitenAntwort(eigene.siteKey, JETZT)).aktiv, true, 'vorher beantwortet (und im Speicher)') assert.equal((await oeffentlicheZeitenAntwort(fremd.features[0].siteKey, JETZT)).aktiv, true) await deleteCustomerWithPortalAccess(kunde.customerId) assert.deepEqual(await oeffentlicheZeitenAntwort(eigene.siteKey, JETZT), { aktiv: false }, 'Speicher geleert, Kunde und Funktion weg') assert.deepEqual(await oeffentlicheZeitenAntwort(eigene.siteKey, new Date(JETZT.getTime() + 60000)), { aktiv: false }) assert.equal((await oeffentlicheZeitenAntwort(fremd.features[0].siteKey, JETZT)).aktiv, true, 'ein anderer Kunde antwortet weiter') }) test('Fehler beim Löschen der Zeiten bricht die Kundenlöschung nicht ab', async (t) => { const log = t.mock.method(console, 'error', () => {}) const kunde = await legeKunde({ projekte: 2 }) aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'kaputt' }) await deleteCustomerWithPortalAccess(kunde.customerId) assert.equal(aw.docs('customers').has(kunde.customerId), false, 'der Kunde ist gelöscht') const zeilen = log.mock.calls.map((c) => c.arguments.map(String).join(' ')) const zeile = zeilen.find((z) => /Öffnungszeiten von Kunde .* nicht gelöscht/.test(z)) assert.ok(zeile, 'Logzeile') assert.ok(zeile.includes(kunde.customerId) && zeile.includes('kaputt')) assert.ok(!zeile.includes('Salon') && !zeile.includes('example.invalid'), 'keine Kundendaten im Log') for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'disabled', 'abgeschaltet wurde trotzdem') assert.equal(zeitenDocs(kunde.customerId).length, 1, 'ein Dokument mit Fehler bleibt, das andere ist weg') assert.equal(await oeffentlicheZeitenAntwort(kunde.features[0].siteKey, JETZT).then((a) => a.aktiv), false) }) test('Fehler beim Abschalten der Zeiten hält weder das Löschen der Daten noch die Kundenlöschung auf', async (t) => { const log = t.mock.method(console, 'error', () => {}) const kunde = await legeKunde() aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) await deleteCustomerWithPortalAccess(kunde.customerId) assert.equal(aw.docs('customers').has(kunde.customerId), false) assert.ok(log.mock.calls.some((c) => /Öffnungszeiten von Kunde .* nicht abgeschaltet/.test(String(c.arguments[0])))) assert.deepEqual(zeitenDocs(kunde.customerId), [], 'die Daten sind trotzdem weg') assert.deepEqual(merkerVon(kunde.features), []) }) // ---------------------------------------------------------------- Loeschfristen (retention.js) const abo = (id, customerId, fields) => aw.seed('subscriptions', { $id: id, stripeSubscriptionId: id, customerId, ...fields }) test('Löschfristen: Daten und Merker der Zeiten eines fälligen Kunden werden gelöscht, dann die Funktion abgeschaltet', async () => { const faellig = await legeKunde({ projekte: 2, news: true }) const laufend = await legeKunde() abo(`sub_${faellig.customerId}`, faellig.customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) abo(`sub_${laufend.customerId}`, laufend.customerId, { status: 'active', endedAt: null }) const plan = await findDueDeletions(JAN_2026) const eintrag = plan.news.find((n) => n.customerId === faellig.customerId) assert.deepEqual(eintrag.zeitenIds.slice().sort(), faellig.features.map((f) => f.$id).sort()) assert.equal(eintrag.featureIds.length, 3, 'zwei Zeiten und News') assert.ok(!plan.news.some((n) => n.customerId === laufend.customerId)) assert.match(formatReport(plan), new RegExp(`Kunde ${faellig.customerId}: .*3 Funktion\\(en\\) abzuschalten.*Öffnungszeiten`)) const result = await applyDeletions(plan) assert.deepEqual(result.failed, []) assert.equal(result.deleted.zeiten, 2) assert.equal(result.deleted.features, 3) assert.match(formatReport(plan, result), /Öffnungszeiten: 2 Datensatz\/Datensätze gelöscht/) assert.deepEqual(zeitenDocs(faellig.customerId), []) assert.deepEqual(merkerVon(faellig.features), []) for (const f of faellig.features) assert.equal(featureDoc(f.$id).state, 'disabled') assert.equal(featureDoc(`f_${faellig.features[0].projectId}_news`).state, 'disabled') assert.equal(zeitenDocs(laufend.customerId).length, 1, 'laufendes Abo: nichts angefasst') assert.equal(featureDoc(laufend.features[0].$id).state, 'active') }) test('Löschfristen: scheitert das Löschen der Zeiten, bleibt die Funktion aktiv (Meldung unter Fehlgeschlagen), ein erneuter Lauf holt es nach', async () => { const kunde = await legeKunde({ projekte: 2 }) abo(`sub_${kunde.customerId}`, kunde.customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'kaputt' }) const plan = await findDueDeletions(JAN_2026) const erster = await applyDeletions(plan) assert.equal(erster.failed.length, 1) assert.equal(erster.failed[0].what, 'oeffnungszeiten') assert.equal(erster.failed[0].id, kunde.customerId) assert.match(erster.failed[0].reason, /kaputt/) assert.equal(erster.deleted.features, 0, 'die Funktionen werden nicht abgeschaltet, solange ihre Daten noch da sind') assert.match(formatReport(plan, erster), /Fehlgeschlagen[\s\S]*oeffnungszeiten .*kaputt/) for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'active') assert.equal(zeitenDocs(kunde.customerId).length, 1, 'ein Dokument ist schon weg, eines geblieben') // TP2: ein gescheitertes Löschen abhängiger Daten hält das Abo-Dokument, damit der nächste Lauf den Kunden wiederfindet (gilt auch für die Zeiten) assert.deepEqual(erster.held, [kunde.customerId]) assert.ok(aw.docs('subscriptions').has(`sub_${kunde.customerId}`), 'Abo-Dokument bleibt stehen') assert.ok((await findDueDeletions(JAN_2026)).customers.some((c) => c.customerId === kunde.customerId), 'der nächste Lauf findet den Kunden wieder') const zweiter = await applyDeletions(plan) assert.deepEqual(zweiter.failed, []) assert.deepEqual(zweiter.held, []) assert.equal(aw.docs('subscriptions').has(`sub_${kunde.customerId}`), false, 'erst nach dem Löschen der Zeiten gehen die Abo-Dokumente') assert.equal(zweiter.deleted.zeiten, 1) assert.deepEqual(zeitenDocs(kunde.customerId), []) assert.deepEqual(merkerVon(kunde.features), []) for (const f of kunde.features) assert.equal(featureDoc(f.$id).state, 'disabled') }) test('Löschfristen: Kunde ohne Zeiten-Funktion bleibt wie bisher (kein zeitenIds, keine Zeile im Bericht)', async () => { nr += 1 const customerId = `zl${nr}` abo(`sub_${customerId}`, customerId, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) aw.seed('portalFeatures', { $id: `f_${customerId}_news`, customerId, projectId: `p_${customerId}`, featureKey: 'news', state: 'active', enabled: true }) const plan = await findDueDeletions(JAN_2026) const eintrag = plan.news.find((n) => n.customerId === customerId) assert.deepEqual(eintrag.zeitenIds, []) assert.doesNotMatch(formatReport(plan), new RegExp(`Kunde ${customerId}.*Öffnungszeiten`)) const result = await applyDeletions(plan) assert.deepEqual(result.failed, []) assert.equal(result.deleted.zeiten, 0) assert.equal(result.deleted.features, 1) assert.doesNotMatch(formatReport(plan, result), /Öffnungszeiten:/) })