import { test, after } from 'node:test' import assert from 'node:assert/strict' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' 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 { mailSink } from './support/mailSink.js' // Nachbauten und temporaerer Upload-Ordner (F41d), BEVOR config.js geladen wird const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), takeoverCredits: schemaKeys('takeoverCredits'), newsPosts: schemaKeys('newsPosts'), portalFeatures: schemaKeys('portalFeatures'), featureConsents: schemaKeys('featureConsents'), shopConfig: schemaKeys('shopConfig'), shopProducts: schemaKeys('shopProducts'), shopOrders: schemaKeys('shopOrders'), shopWiderrufe: schemaKeys('shopWiderrufe'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), ukUnterkuenfte: schemaKeys('ukUnterkuenfte'), ukPreise: schemaKeys('ukPreise'), ukSperren: schemaKeys('ukSperren'), ukAnfragen: schemaKeys('ukAnfragen'), ukRechnungen: schemaKeys('ukRechnungen'), ukEinstellungen: schemaKeys('ukEinstellungen'), }, }) const st = await startFakeStripe() const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'loeschfristen-')) process.env.TAKEOVER_UPLOAD_DIR = uploadDir process.env.NEWS_UPLOAD_DIR = path.join(uploadDir, 'news') process.env.SHOP_UPLOAD_DIR = path.join(uploadDir, 'shop') useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { findDueDeletions, applyDeletions, formatReport, CONSENT_METADATA_KEYS } = await import('../server/services/retention.js') const { run } = await import('../scripts/loeschfristen.mjs') const { stripeSubscription } = await import('./support/fixtures.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) rmSync(uploadDir, { recursive: true, force: true }) return Promise.all([aw.close(), st.close()]) }) // 01.01.2026, 12:00 Berlin const JAN_2026 = new Date('2026-01-01T12:00:00+01:00') const DEC_2025 = new Date('2025-12-31T12:00:00+01:00') const sub = (id, customerId, fields) => aw.seed('subscriptions', { $id: id, stripeSubscriptionId: id, customerId, ...fields }) const event = (id, customerId, subscriptionId) => aw.seed('contractEvents', { $id: id, customerId, subscriptionId, type: 'abschluss', status: 'erledigt', consentIp: '10.0.0.1', consentUserAgent: 'x' }) const stripeSub = (id, customerId, extra = {}) => st.subscriptions.set(id, stripeSubscription({ id, customerId, status: 'canceled', ...extra })) const dueIds = (plan) => plan.customers.map((c) => c.customerId).sort() test('Abo beendet am 15.03.2022: fällig am 01.01.2026, nicht am 31.12.2025', async () => { sub('r1_sub', 'r1', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) event('r1_ev', 'r1', 'r1_sub') assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1')) const plan = await findDueDeletions(JAN_2026) const entry = plan.customers.find((c) => c.customerId === 'r1') assert.deepEqual(entry, { customerId: 'r1', lastEndAt: '2022-03-15T10:00:00.000Z', subscriptionIds: ['r1_sub'], eventIds: ['r1_ev'] }) }) test('Ende zählt in deutscher Zeit: 31.12.2021 23:30 UTC ist schon 2022', async () => { sub('r1b_sub', 'r1b', { status: 'canceled', endedAt: '2021-12-31T23:30:00.000Z' }) assert.ok(!dueIds(await findDueDeletions(DEC_2025)).includes('r1b')) assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('r1b')) }) test('Abo beendet vor zwei Jahren: nicht fällig', async () => { sub('r2_sub', 'r2', { status: 'canceled', endedAt: new Date(JAN_2026.getTime() - 2 * 365 * 86400000).toISOString() }) assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r2')) }) test('altes beendetes Abo und ein laufendes Abo desselben Kunden: nicht fällig', async () => { sub('r3_old', 'r3', { status: 'canceled', endedAt: '2019-05-01T00:00:00.000Z' }) sub('r3_new', 'r3', { status: 'active', endedAt: null }) assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('r3')) }) test('das letzte Ende zählt; Ende = endedAt, sonst canceledAt, sonst currentPeriodEnd bei canceled', async () => { sub('r4_a', 'r4', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' }) sub('r4_b', 'r4', { status: 'canceled', endedAt: null, canceledAt: '2022-06-01T00:00:00.000Z', currentPeriodEnd: '2010-01-01T00:00:00.000Z' }) sub('r5_a', 'r5', { status: 'canceled', endedAt: null, canceledAt: null, currentPeriodEnd: '2022-02-01T00:00:00.000Z' }) sub('r6_a', 'r6', { status: 'past_due', endedAt: null, canceledAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' }) const plan = await findDueDeletions(JAN_2026) const r4 = plan.customers.find((c) => c.customerId === 'r4') assert.equal(r4.lastEndAt, '2022-06-01T00:00:00.000Z') assert.deepEqual(r4.subscriptionIds.sort(), ['r4_a', 'r4_b']) assert.ok(dueIds(plan).includes('r5')) assert.ok(!dueIds(plan).includes('r6'), 'nicht beendet (past_due) zählt nicht') }) test('applyDeletions: löscht Dokumente, leert alle Zustimmungs-Metadaten bei Stripe, lässt Rechnungen und andere Kunden', async () => { sub('a1_sub', 'a1', { status: 'canceled', endedAt: '2021-01-10T00:00:00.000Z' }) event('a1_ev', 'a1', 'a1_sub') event('a1_ev2', 'a1', 'a1_sub') sub('a2_sub', 'a2', { status: 'active' }) event('a2_ev', 'a2', 'a2_sub') stripeSub('a1_sub', 'a1', { metadata: { switchedAt: '2021-01-01', switchedFrom: 'hosting' }, }) st.invoices.set('in_a1', { id: 'in_a1', object: 'invoice', subscription: 'a1_sub' }) const callsBefore = st.calls.length const plan = await findDueDeletions(JAN_2026) assert.ok(plan.customers.some((c) => c.customerId === 'a1')) const result = await applyDeletions({ customers: plan.customers.filter((c) => c.customerId === 'a1'), takeover: [] }) assert.deepEqual(result.manual, []) assert.equal(result.deleted.subscriptions, 1) assert.equal(result.deleted.contractEvents, 2) assert.ok(!aw.docs('subscriptions').has('a1_sub')) assert.ok(!aw.docs('contractEvents').has('a1_ev') && !aw.docs('contractEvents').has('a1_ev2')) assert.ok(aw.docs('subscriptions').has('a2_sub') && aw.docs('contractEvents').has('a2_ev')) const update = st.calls.slice(callsBefore).find((c) => c.method === 'POST' && c.path === '/v1/subscriptions/a1_sub') assert.ok(update, 'Stripe-Abo wurde geändert') for (const key of CONSENT_METADATA_KEYS) assert.equal(update.body[`metadata[${key}]`], '', `${key} geleert`) assert.deepEqual(CONSENT_METADATA_KEYS.slice().sort(), [ 'consentIp', 'consentTextVersion', 'consentUserAgent', 'earlyStartConsentAt', 'startChoice', 'switchedAt', 'switchedFrom', 'termsAcceptedAt', 'termsVersion', ]) const meta = st.subscriptions.get('a1_sub').metadata for (const key of CONSENT_METADATA_KEYS) assert.ok(!(key in meta), `${key} weg`) assert.equal(meta.webklarCustomerId, 'a1', 'übrige Angaben (Zuordnung) bleiben') assert.ok(st.invoices.has('in_a1'), 'Rechnungen bleiben') }) test('abgelehnte Stripe-Änderung landet in manual, die Appwrite-Dokumente werden trotzdem gelöscht', async () => { sub('m1_sub', 'm1', { status: 'canceled', endedAt: '2020-02-02T00:00:00.000Z' }) event('m1_ev', 'm1', 'm1_sub') stripeSub('m1_sub', 'm1') st.failNext({ method: 'POST', path: '/v1/subscriptions/m1_sub', status: 400, message: 'A canceled subscription can only update its cancellation_details.' }) const result = await applyDeletions({ customers: [{ customerId: 'm1', lastEndAt: '2020-02-02T00:00:00.000Z', subscriptionIds: ['m1_sub'], eventIds: ['m1_ev'] }], takeover: [], }) assert.equal(result.manual.length, 1) assert.equal(result.manual[0].subscriptionId, 'm1_sub') assert.equal(result.manual[0].customerId, 'm1') assert.match(result.manual[0].reason, /canceled subscription/) assert.ok(!aw.docs('subscriptions').has('m1_sub') && !aw.docs('contractEvents').has('m1_ev')) assert.ok('consentIp' in st.subscriptions.get('m1_sub').metadata, 'bei Stripe unverändert, Hinweis im Bericht') }) test('Übernahme-Anträge: abgelehnte nur melden, genehmigte erst nach 8 Jahren ab Jahresende löschen', async () => { const withFiles = (id) => { mkdirSync(path.join(uploadDir, id), { recursive: true }) writeFileSync(path.join(uploadDir, id, 'nachweis.pdf'), 'x') return JSON.stringify([{ id: 'f1', name: 'nachweis.pdf' }]) } const tc = (id, fields) => aw.seed('takeoverCredits', { $id: id, customerId: 't1', ...fields }) // abgelehnt vor >6 Monaten, Nachweise noch da (tägliche Bereinigung hat sie nicht erwischt): nur melden tc('tk_rej', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: withFiles('tk_rej') }) // abgelehnt, Nachweise schon gelöscht: nichts zu melden tc('tk_rej_clean', { status: 'abgelehnt', decidedAt: '2025-05-01T00:00:00.000Z', files: '[]' }) // genehmigt 2017: Frist endet mit Ablauf 2025 (8 Jahre ab Jahresende 2017), am 01.01.2026 fällig tc('tk_old', { status: 'genehmigt', decidedAt: '2017-11-20T00:00:00.000Z', files: withFiles('tk_old') }) // genehmigt 2018: erst am 01.01.2027 fällig tc('tk_2018', { status: 'genehmigt', decidedAt: '2018-03-01T00:00:00.000Z', files: withFiles('tk_2018') }) tc('tk_open', { status: 'offen', files: withFiles('tk_open') }) const plan = await findDueDeletions(JAN_2026) const byId = Object.fromEntries(plan.takeover.map((t) => [t.id, t])) assert.deepEqual(Object.keys(byId).sort(), ['tk_old', 'tk_rej']) assert.equal(byId.tk_rej.action, 'melden') assert.match(byId.tk_rej.reason, /abgelehnt/) assert.equal(byId.tk_old.action, 'loeschen') assert.match(byId.tk_old.reason, /acht Jahre|8 Jahre/) const result = await applyDeletions({ customers: [], takeover: plan.takeover }) assert.equal(result.deleted.takeover, 1) assert.ok(aw.docs('takeoverCredits').has('tk_rej') && existsSync(path.join(uploadDir, 'tk_rej')), 'abgelehnter Antrag bleibt der täglichen Bereinigung überlassen') assert.ok(!aw.docs('takeoverCredits').has('tk_old') && !existsSync(path.join(uploadDir, 'tk_old'))) assert.ok(aw.docs('takeoverCredits').has('tk_2018') && existsSync(path.join(uploadDir, 'tk_2018'))) // 2027: auch 2018 ist fällig const later = await findDueDeletions(new Date('2027-01-02T12:00:00+01:00')) assert.ok(later.takeover.some((t) => t.id === 'tk_2018' && t.action === 'loeschen')) }) test('Skript: Probelauf ändert nichts und schickt nichts', async () => { sub('s1_sub', 's1', { status: 'canceled', endedAt: '2021-03-03T00:00:00.000Z' }) event('s1_ev', 's1', 's1_sub') stripeSub('s1_sub', 's1') const callsBefore = st.calls.length const sentBefore = mails.sent.length const lines = [] const result = await run({ mode: '', now: JAN_2026, log: (l) => lines.push(l) }) assert.equal(result.applied, false) assert.ok(aw.docs('subscriptions').has('s1_sub') && aw.docs('contractEvents').has('s1_ev')) assert.equal(st.calls.length, callsBefore) assert.equal(mails.sent.length, sentBefore) const text = lines.join('\n') assert.match(text, /Probelauf/) assert.match(text, /s1/) }) test('Skript: LOESCHFRISTEN=mail schickt den Bericht als T14 ans Team, löscht nichts', async () => { const sentBefore = mails.sent.length const result = await run({ mode: 'mail', now: JAN_2026, log: () => {} }) assert.equal(result.applied, false) assert.equal(mails.sent.length, sentBefore + 1) const mail = mails.sent.at(-1) assert.match(mail.subject, /^\[Datenschutz\] Löschfristen fällig/) assert.match(mail.text, /s1/) assert.match(mail.text, /Probelauf|Vorschau/) assert.ok(aw.docs('subscriptions').has('s1_sub')) }) test('Skript: LOESCHFRISTEN=apply löscht', async () => { const result = await run({ mode: 'apply', now: JAN_2026, log: () => {} }) assert.equal(result.applied, true) assert.ok(!aw.docs('subscriptions').has('s1_sub') && !aw.docs('contractEvents').has('s1_ev')) assert.ok(aw.docs('subscriptions').has('a2_sub'), 'laufendes Abo bleibt') }) test('Skript: unbekannter Modus wird abgelehnt', async () => { await assert.rejects(run({ mode: 'loeschen', now: JAN_2026, log: () => {} }), /LOESCHFRISTEN/) }) test('abgeschnittene Liste: Abbruch vor jeder Planung', async () => { sub('l1_a', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) sub('l1_b', 'l1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) await assert.rejects(findDueDeletions(JAN_2026, { limit: 2 }), /Liste abgeschnitten, Paginierung nötig/) // mit ausreichendem Limit läuft es assert.ok(Array.isArray((await findDueDeletions(JAN_2026)).customers)) }) test('applyDeletions: Fehler bei einem Dokument stoppen den Rest nicht und landen in failed', async () => { sub('f1_sub', 'f1', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) sub('f2_sub', 'f2', { status: 'canceled', endedAt: '2019-01-01T00:00:00.000Z' }) event('f1_ev', 'f1', 'f1_sub') event('f1_ev2', 'f1', 'f1_sub') stripeSub('f1_sub', 'f1') stripeSub('f2_sub', 'f2') aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' }) const plan = { customers: [ { customerId: 'f1', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f1_sub'], eventIds: ['f1_ev', 'f1_ev2'] }, { customerId: 'f2', lastEndAt: '2019-01-01T00:00:00.000Z', subscriptionIds: ['f2_sub'], eventIds: [] }, ], takeover: [], } const result = await applyDeletions(plan) assert.equal(result.failed.length, 1) assert.equal(result.failed[0].id, 'f1_ev') assert.match(result.failed[0].reason, /kaputt/) assert.equal(result.deleted.contractEvents, 1) assert.ok(!aw.docs('subscriptions').has('f2_sub'), 'zweiter Kunde wurde trotzdem bearbeitet') assert.match(formatReport(plan, result), /Fehlgeschlagen[\s\S]*f1_ev/) }) test('incomplete_expired zählt als beendet, incomplete hält auf und steht im Bericht', async () => { sub('i1_sub', 'i1', { status: 'incomplete_expired', endedAt: null, canceledAt: null, startedAt: '2020-01-01T00:00:00.000Z', $updatedAt: '2021-05-05T00:00:00.000Z' }) sub('i2_sub', 'i2', { status: 'incomplete_expired', endedAt: null, canceledAt: '2020-02-02T00:00:00.000Z' }) sub('i3_old', 'i3', { status: 'canceled', endedAt: '2018-01-01T00:00:00.000Z' }) sub('i3_inc', 'i3', { status: 'incomplete', endedAt: null }) const plan = await findDueDeletions(JAN_2026) assert.ok(dueIds(plan).includes('i1')) assert.ok(dueIds(plan).includes('i2')) assert.equal(plan.customers.find((c) => c.customerId === 'i2').lastEndAt, '2020-02-02T00:00:00.000Z') assert.ok(!dueIds(plan).includes('i3')) assert.ok(plan.pending.some((p) => p.customerId === 'i3' && p.subscriptionIds.includes('i3_inc'))) assert.match(formatReport(plan), /Hält Löschung auf:[\s\S]*i3_inc/) }) test('aktives Abo mit cancelAtPeriodEnd und altem canceledAt zählt nicht als beendet', async () => { sub('c1_sub', 'c1', { status: 'active', cancelAtPeriodEnd: true, canceledAt: '2010-01-01T00:00:00.000Z', endedAt: null, currentPeriodEnd: '2010-02-01T00:00:00.000Z' }) assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('c1')) }) test('genehmigter Antrag ohne decidedAt: übersprungen und gemeldet, kein Rückgriff auf appliedAt', async () => { aw.seed('takeoverCredits', { $id: 'nd_tk', customerId: 'nd', status: 'genehmigt', appliedAt: '2010-01-01T00:00:00.000Z', files: '[]' }) const plan = await findDueDeletions(JAN_2026) assert.ok(!plan.takeover.some((t) => t.id === 'nd_tk')) assert.ok(plan.skipped.some((t) => t.id === 'nd_tk')) assert.match(formatReport(plan), /Übersprungen[\s\S]*nd_tk/) }) test('retention_loescht_news: Beiträge, Fotos und Funktionen fälliger Kunden', async () => { const { storeNewsImage, newsImagePath } = await import('../server/services/newsImages.js') const sharp = (await import('sharp')).default const png = () => sharp({ create: { width: 40, height: 30, channels: 3, background: '#c33' } }).png().toBuffer() sub('rn_sub', 'rn', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) sub('rn2_sub', 'rn2', { status: 'active' }) // laufendes Abo: nichts davon wird angefasst stripeSub('rn_sub', 'rn') const { imageId } = await storeNewsImage({ customerId: 'rn', projectId: 'rnp', buffer: await png() }) const other = await storeNewsImage({ customerId: 'rn2', projectId: 'rntwop', buffer: await png() }) aw.seed('newsPosts', { $id: 'rn_post1', customerId: 'rn', projectId: 'rnp', type: 'photo', imageId, title: '', text: '' }) aw.seed('newsPosts', { $id: 'rn_post2', customerId: 'rn', projectId: 'rnp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' }) aw.seed('newsPosts', { $id: 'rn2_post', customerId: 'rn2', projectId: 'rntwop', type: 'photo', imageId: other.imageId, title: '', text: '' }) aw.seed('portalFeatures', { $id: 'rn_feat', customerId: 'rn', projectId: 'rnp', featureKey: 'news', state: 'active', enabled: true }) aw.seed('portalFeatures', { $id: 'rn2_feat', customerId: 'rn2', projectId: 'rntwop', featureKey: 'news', state: 'active', enabled: true }) const plan = await findDueDeletions(JAN_2026) const entry = plan.news.find((n) => n.customerId === 'rn') assert.deepEqual(entry.posts.map((p) => p.id).sort(), ['rn_post1', 'rn_post2']) assert.deepEqual(entry.posts.find((p) => p.id === 'rn_post1'), { id: 'rn_post1', projectId: 'rnp', imageId }) assert.deepEqual(entry.featureIds, ['rn_feat']) assert.ok(!plan.news.some((n) => n.customerId === 'rn2')) assert.match(formatReport(plan), /Kunde rn: 2 News-Beitrag\/-Beiträge mit 1 Foto\(s\) zu löschen, 1 Funktion\(en\) abzuschalten/) const result = await applyDeletions(plan) assert.deepEqual(result.failed, []) assert.equal(result.deleted.newsPosts, 2) assert.equal(result.deleted.newsImages, 1) assert.equal(result.deleted.features, 1) assert.match(formatReport(plan, result), /News: 2 Beitrag\/Beiträge und 1 Foto\(s\) gelöscht, 1 Funktion\(en\) abgeschaltet/) assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn').length, 0) assert.equal([...aw.docs('newsPosts').values()].filter((d) => d.customerId === 'rn2').length, 1) assert.equal(existsSync(newsImagePath('rnp', imageId, 'l')), false) assert.equal(existsSync(newsImagePath('rntwop', other.imageId, 'l')), true) const feat = [...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn_feat') assert.equal(feat.state, 'disabled') assert.equal(feat.enabled, false) assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn2_feat').state, 'active') }) test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () => { const { analyseDeps } = await import('../server/services/analyse.js') sub('ra_sub', 'ra', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) stripeSub('ra_sub', 'ra') aw.seed('websiteProjects', { $id: 'rap', customerId: 'ra', projectName: 'Alt', subdomain: 'altprojekt', isPublic: true }) aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' }) const puts = [] const ok = analyseDeps.client analyseDeps.client = { putSite: async (k, b) => { puts.push(['put', k, b]) }, stopSite: async (k) => { puts.push(['stop', k]) } } try { const plan = await findDueDeletions(JAN_2026) assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat')) const result = await applyDeletions(plan) assert.deepEqual(result.failed, []) assert.deepEqual(puts, [['stop', 'abcdefghij234567']]) assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled') } finally { analyseDeps.client = ok } }) test('retention_loescht_featureConsents: Zustimmungsnachweise fälliger Kunden werden geplant, gelöscht und berichtet; andere bleiben', async () => { const consent = (id, customerId, projectId, featureKey) => aw.seed('featureConsents', { $id: id, customerId, projectId, featureKey, stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00', fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com', }) // fälliger Kunde: Abo endete 2022, zwei Funktionsbestellungen (Terminplaner und News) in zwei Projekten, sonst nichts sub('fc_sub', 'fc', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) stripeSub('fc_sub', 'fc') consent('fc_c1', 'fc', 'fcp1', 'termine') consent('fc_c2', 'fc', 'fcp2', 'news') // nicht fälliger Kunde (laufendes Abo) mit Nachweis, und ein Kunde ohne Abo (nur News) mit Nachweis: beide bleiben wie bisher unberührt sub('fc2_sub', 'fc2', { status: 'active' }) consent('fc2_c1', 'fc2', 'fc2p1', 'termine') consent('fc3_c1', 'fc3', 'fc3p1', 'news') aw.seed('newsPosts', { $id: 'fc3_post', customerId: 'fc3', projectId: 'fc3p1', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' }) // die Abfrage nach customerId hat im Schema (scripts/feature-setup.mjs) einen Index const { FEATURE_CONSENT_INDEXES } = await import('../scripts/feature-setup.mjs') assert.ok(FEATURE_CONSENT_INDEXES.some((i) => i.attributes.length === 1 && i.attributes[0] === 'customerId'), 'Index auf featureConsents.customerId fehlt') const plan = await findDueDeletions(JAN_2026) const eintrag = plan.news.find((n) => n.customerId === 'fc') assert.ok(eintrag, 'ein fälliger Kunde nur mit Nachweisen (ohne Beiträge und Funktionen) steht im Plan') assert.deepEqual(eintrag.consentIds.sort(), ['fc_c1', 'fc_c2']) assert.deepEqual(eintrag.posts, []) assert.deepEqual(eintrag.featureIds, []) assert.ok(!plan.news.some((n) => n.customerId === 'fc2' || n.customerId === 'fc3'), 'nicht fälliger Kunde und Kunde ohne Abo bleiben außerhalb') assert.match(formatReport(plan), /Kunde fc: 0 News-Beitrag\/-Beiträge mit 0 Foto\(s\) zu löschen, 0 Funktion\(en\) abzuschalten, 2 Zustimmungsnachweis\(e\) zu löschen/) // Probelauf löscht nichts const probe = await run({ mode: '', now: JAN_2026, log: () => {} }) assert.equal(probe.applied, false) assert.ok(aw.docs('featureConsents').has('fc_c1') && aw.docs('featureConsents').has('fc_c2')) const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'fc'), news: plan.news.filter((n) => n.customerId === 'fc') }) assert.deepEqual(result.failed, []) assert.equal(result.deleted.featureConsents, 2) assert.ok(!aw.docs('featureConsents').has('fc_c1') && !aw.docs('featureConsents').has('fc_c2')) assert.ok(aw.docs('featureConsents').has('fc2_c1') && aw.docs('featureConsents').has('fc3_c1'), 'Nachweise anderer Kunden bleiben') assert.match(formatReport(plan, result), /Zustimmungsnachweise zu Funktionsbestellungen: 2 gelöscht\./) // ein zweiter Lauf findet nichts mehr für diesen Kunden const danach = await findDueDeletions(JAN_2026) assert.ok(!danach.news.some((n) => n.customerId === 'fc')) }) test('retention_loescht_featureConsents: Ein Fehler beim Löschen eines Nachweises stoppt den Rest nicht und landet in failed; ältere Pläne ohne consentIds laufen weiter', async () => { const consent = (id, customerId) => aw.seed('featureConsents', { $id: id, customerId, projectId: 'fcxp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2021-02-01T10:00:00.000+00:00', fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'Mitarbeiter: Test', }) consent('fcx_c1', 'fcx') consent('fcx_c2', 'fcx') aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' }) const result = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] }) assert.equal(result.failed.length, 1) assert.equal(result.failed[0].what, 'featureConsents') assert.equal(result.failed[0].id, 'fcx_c1') assert.match(result.failed[0].reason, /kaputt/) assert.equal(result.deleted.featureConsents, 1) assert.ok(aw.docs('featureConsents').has('fcx_c1') && !aw.docs('featureConsents').has('fcx_c2')) // ein zweiter Aufruf mit demselben Plan holt den Rest nach (in einem Lauf mit Abo-Dokument plant ihn findDueDeletions erneut, siehe die Tests // „Fehlschlag … hält den Kunden“); ein Eintrag, der inzwischen weg ist (404), zählt nicht const nochmal = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [], consentIds: ['fcx_c1', 'fcx_c2'] }] }) assert.deepEqual(nochmal.failed, []) assert.equal(nochmal.deleted.featureConsents, 1) // Pläne ohne consentIds (ältere Form) bleiben gültig const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'fcx', posts: [], featureIds: [] }] }) assert.equal(alt.deleted.featureConsents, 0) assert.deepEqual(alt.failed, []) }) // ------------------------------------------------------- C1: IP, Browser, Text und Zeit der Bestellung an portalFeatures const KLICK = { consentIp: '203.0.113.7', consentUserAgent: 'Mozilla/5.0 Test', consentText: 'Ich stimme zu.', consentAt: '2022-02-01T10:00:00.000Z' } const LEER = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null } const feld = (id) => { const d = aw.docs('portalFeatures').get(id) return { consentIp: d.consentIp, consentUserAgent: d.consentUserAgent, consentText: d.consentText, consentAt: d.consentAt } } test('C1 retention: IP, Browser, Text und Zeit werden auf ALLEN Funktionsdokumenten eines fälligen Kunden geleert, auch auf abgeschalteten; andere Kunden und Felder bleiben', async () => { sub('cf_sub', 'cf', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) stripeSub('cf_sub', 'cf') aw.seed('portalFeatures', { $id: 'cf_aktiv', customerId: 'cf', projectId: 'cfp1', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k1', ...KLICK }) aw.seed('portalFeatures', { $id: 'cf_aus', customerId: 'cf', projectId: 'cfp2', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k2', disabledAt: '2022-03-15T10:00:00.000Z', ...KLICK }) aw.seed('portalFeatures', { $id: 'cf_leer', customerId: 'cf', projectId: 'cfp3', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k3', consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }) sub('cf2_sub', 'cf2', { status: 'active' }) aw.seed('portalFeatures', { $id: 'cf2_aktiv', customerId: 'cf2', projectId: 'cf2p', featureKey: 'termine', state: 'active', enabled: true, siteKey: 'k4', ...KLICK }) const plan = await findDueDeletions(JAN_2026) const eintrag = plan.news.find((n) => n.customerId === 'cf') assert.ok(eintrag, 'ein fälliger Kunde mit gespeicherten Bestellangaben steht im Plan') assert.deepEqual(eintrag.featureIds, ['cf_aktiv'], 'abschalten: nur das noch nicht abgeschaltete') assert.deepEqual([...eintrag.featureFieldIds].sort(), ['cf_aktiv', 'cf_aus'], 'leeren: alle mit Angaben, auch das abgeschaltete; das leere Dokument nicht') assert.ok(!plan.news.some((n) => n.customerId === 'cf2')) assert.match(formatReport(plan), /Kunde cf: .*2 Funktionsdokument\(e\) mit Zustimmungsangaben zu leeren/) // Probelauf ändert nichts await run({ mode: '', now: JAN_2026, log: () => {} }) assert.deepEqual(feld('cf_aus'), { ...KLICK }) const result = await applyDeletions(planVon(plan, 'cf')) assert.deepEqual(result.failed, []) assert.equal(result.deleted.featureFields, 2) assert.deepEqual(feld('cf_aktiv'), LEER) assert.deepEqual(feld('cf_aus'), LEER) assert.equal(aw.docs('portalFeatures').get('cf_aktiv').state, 'disabled', 'abgeschaltet wie bisher') assert.equal(aw.docs('portalFeatures').get('cf_aus').siteKey, 'k2', 'übrige Felder unberührt') assert.deepEqual(feld('cf2_aktiv'), { ...KLICK }, 'laufender Kunde bleibt') assert.match(formatReport(plan, result), /Zustimmungsangaben an Funktionsdokumenten \(IP, Browser, Text, Zeit\): 2 geleert\./) // ein zweiter Lauf findet nichts mehr für diesen Kunden assert.ok(!(await findDueDeletions(JAN_2026)).news.some((n) => n.customerId === 'cf')) }) test('C1 retention: scheitert das Leeren, hält es den Kunden (Abo bleibt), der nächste Lauf plant es erneut; ältere Pläne ohne featureFieldIds laufen weiter', async () => { sub('cg_sub', 'cg', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) stripeSub('cg_sub', 'cg') aw.seed('portalFeatures', { $id: 'cg_aus', customerId: 'cg', projectId: 'cgp', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'k5', ...KLICK }) const plan = await findDueDeletions(JAN_2026) aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) const result = await applyDeletions(planVon(plan, 'cg')) assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['portalFeatures', 'cg_aus']]) assert.deepEqual(result.held, ['cg']) assert.ok(aw.docs('subscriptions').has('cg_sub'), 'das Abo-Dokument bleibt, sonst fände der nächste Lauf den Kunden nicht') assert.deepEqual(feld('cg_aus'), { ...KLICK }) const nochmal = await findDueDeletions(JAN_2026) assert.deepEqual(nochmal.news.find((n) => n.customerId === 'cg').featureFieldIds, ['cg_aus']) const zweiter = await applyDeletions(planVon(nochmal, 'cg')) assert.deepEqual(zweiter.failed, []) assert.deepEqual(feld('cg_aus'), LEER) assert.ok(!aw.docs('subscriptions').has('cg_sub')) const alt = await applyDeletions({ customers: [], takeover: [], news: [{ customerId: 'cgx', posts: [], featureIds: [], consentIds: [] }] }) assert.equal(alt.deleted.featureFields, 0) assert.deepEqual(alt.failed, []) }) // ---------------------------------------------------------------- Reihenfolge: abhängige Daten vor dem Abo (Fix Runde 1, R119) /** Kunde mit beendetem Abo (2022), einem Vertragsereignis und einem Zustimmungsnachweis, ohne Beiträge und Funktionen. */ function seedFaelligMitNachweis(id) { sub(`${id}_sub`, id, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) event(`${id}_ev`, id, `${id}_sub`) stripeSub(`${id}_sub`, id) aw.seed('featureConsents', { $id: `${id}_c1`, customerId: id, projectId: `${id}p`, featureKey: 'termine', stripeSessionId: `cs_${id}`, acceptedAt: '2022-02-01T10:00:00.000+00:00', fassungen: '{}', texte: '[]', textHash: 'x', ip: '10.0.0.1', userAgent: 'Browser', konto: 'k@example.com', }) } const planVon = (plan, customerId) => ({ ...plan, customers: plan.customers.filter((c) => c.customerId === customerId), news: plan.news.filter((n) => n.customerId === customerId), takeover: [], }) test('Fehlschlag beim Löschen eines Zustimmungsnachweises hält den Kunden: Abo und Vertragsereignisse bleiben, der nächste Lauf plant den Nachweis erneut', async () => { seedFaelligMitNachweis('ro') const plan = await findDueDeletions(JAN_2026) assert.ok(dueIds(plan).includes('ro')) aw.failNext({ collection: 'featureConsents', method: 'DELETE', status: 500, message: 'kaputt' }) const result = await applyDeletions(planVon(plan, 'ro')) assert.equal(result.failed.length, 1) assert.deepEqual([result.failed[0].what, result.failed[0].id], ['featureConsents', 'ro_c1']) assert.match(result.failed[0].reason, /kaputt/) assert.deepEqual(result.held, ['ro'], 'der Kunde wird als gehalten gemeldet') assert.equal(result.deleted.featureConsents, 0) assert.equal(result.deleted.subscriptions, 0) assert.equal(result.deleted.contractEvents, 0) assert.ok(aw.docs('subscriptions').has('ro_sub') && aw.docs('contractEvents').has('ro_ev') && aw.docs('featureConsents').has('ro_c1'), 'nichts davon ist weg') assert.match(formatReport(planVon(plan, 'ro'), result), /Abo-Daten bleiben[\s\S]*ro/) // der nächste Lauf findet den Kunden und den Nachweis wieder (das Abo-Dokument ist ja noch da) ... const zweiter = await findDueDeletions(JAN_2026) assert.ok(dueIds(zweiter).includes('ro')) assert.deepEqual(zweiter.news.find((n) => n.customerId === 'ro').consentIds, ['ro_c1']) // ... und löscht jetzt alles const fertig = await applyDeletions(planVon(zweiter, 'ro')) assert.deepEqual(fertig.failed, []) assert.deepEqual(fertig.held, []) assert.equal(fertig.deleted.featureConsents, 1) assert.equal(fertig.deleted.subscriptions, 1) assert.ok(!aw.docs('subscriptions').has('ro_sub') && !aw.docs('contractEvents').has('ro_ev') && !aw.docs('featureConsents').has('ro_c1')) assert.ok(!dueIds(await findDueDeletions(JAN_2026)).includes('ro')) }) test('Fehlschlag beim Löschen eines News-Beitrags hält den Kunden ebenso (dasselbe Muster wie bei den Nachweisen)', async () => { sub('rp_sub', 'rp', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) event('rp_ev', 'rp', 'rp_sub') stripeSub('rp_sub', 'rp') aw.seed('newsPosts', { $id: 'rp_post', customerId: 'rp', projectId: 'rpp', type: 'note', imageId: null, title: 'Hi', text: 'Hallo' }) const plan = await findDueDeletions(JAN_2026) assert.deepEqual(plan.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post']) aw.failNext({ collection: 'newsPosts', method: 'DELETE', status: 500, message: 'kaputt' }) const result = await applyDeletions(planVon(plan, 'rp')) assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['newsPosts', 'rp_post']]) assert.deepEqual(result.held, ['rp']) assert.ok(aw.docs('subscriptions').has('rp_sub') && aw.docs('contractEvents').has('rp_ev') && aw.docs('newsPosts').has('rp_post')) const zweiter = await findDueDeletions(JAN_2026) assert.ok(dueIds(zweiter).includes('rp')) assert.deepEqual(zweiter.news.find((n) => n.customerId === 'rp').posts.map((p) => p.id), ['rp_post']) const fertig = await applyDeletions(planVon(zweiter, 'rp')) assert.deepEqual(fertig.failed, []) assert.ok(!aw.docs('subscriptions').has('rp_sub') && !aw.docs('newsPosts').has('rp_post')) }) test('Fehlschlag beim Löschen eines Vertragsereignisses hält das Abo ebenfalls (sonst bliebe das Ereignis als Waise liegen)', async () => { sub('rq_sub', 'rq', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) event('rq_ev', 'rq', 'rq_sub') stripeSub('rq_sub', 'rq') const plan = await findDueDeletions(JAN_2026) aw.failNext({ collection: 'contractEvents', method: 'DELETE', status: 500, message: 'kaputt' }) const result = await applyDeletions(planVon(plan, 'rq')) assert.deepEqual(result.failed.map((f) => [f.what, f.id]), [['contractEvents', 'rq_ev']]) assert.deepEqual(result.held, ['rq']) assert.ok(aw.docs('subscriptions').has('rq_sub'), 'das Abo-Dokument bleibt, damit der nächste Lauf den Kunden wieder findet') assert.ok(dueIds(await findDueDeletions(JAN_2026)).includes('rq')) const fertig = await applyDeletions(planVon(await findDueDeletions(JAN_2026), 'rq')) assert.deepEqual(fertig.failed, []) assert.ok(!aw.docs('subscriptions').has('rq_sub') && !aw.docs('contractEvents').has('rq_ev')) }) test('Reihenfolge: Beiträge, Funktionen und Nachweise gehen vor den Vertragsereignissen und Abo-Dokumenten', async () => { seedFaelligMitNachweis('rr') const plan = await findDueDeletions(JAN_2026) const reihenfolge = [] const echt = globalThis.fetch globalThis.fetch = async (url, optionen) => { if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1]) return echt(url, optionen) } try { await applyDeletions(planVon(plan, 'rr')) } finally { globalThis.fetch = echt } assert.deepEqual(reihenfolge, ['featureConsents', 'contractEvents', 'subscriptions']) }) test('Nachweis-Liste an der Grenze: findDueDeletions bricht ab, bevor etwas geplant wird (fail-closed)', async () => { sub('rs_sub', 'rs', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) stripeSub('rs_sub', 'rs') const grenze = Math.max(...['subscriptions', 'contractEvents', 'newsPosts', 'portalFeatures', 'takeoverCredits'].map((c) => aw.docs(c).size)) + 1 for (let i = 0; i < grenze; i += 1) { aw.seed('featureConsents', { $id: `rs_c${i}`, customerId: 'rs', projectId: 'rsp', featureKey: 'termine', stripeSessionId: '', acceptedAt: '2022-02-01T10:00:00.000+00:00', fassungen: '{}', texte: '[]', textHash: 'x', ip: '', userAgent: '', konto: 'k', }) } await assert.rejects(findDueDeletions(JAN_2026, { limit: grenze }), /Liste abgeschnitten, Paginierung nötig \(featureConsents/) assert.equal([...aw.docs('featureConsents').values()].filter((c) => c.customerId === 'rs').length, grenze, 'nichts gelöscht') }) // ------------------------------------------------------------------------------------------------ Webshop (J3) const SHOP_KEY = 'abcdefghijklmnop' function seedFaelligMitShop(kunde, siteKey = SHOP_KEY) { sub(`${kunde}_sub`, kunde, { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' }) event(`${kunde}_ev`, kunde, `${kunde}_sub`) stripeSub(`${kunde}_sub`, kunde) const shopId = `f_${kunde}_shop` aw.seed('shopConfig', { $id: shopId, customerId: kunde, projectId: `${kunde}p`, siteKey, geoeffnet: false }) aw.seed('shopProducts', { $id: `${kunde}_prod`, shopId, customerId: kunde, aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' }) aw.seed('shopOrders', { $id: `${kunde}_ord1`, shopId, customerId: kunde, bestellnummer: '26-A', status: 'versendet', kaeuferEmail: 'k@example.com', bestellung: '{}' }) aw.seed('shopOrders', { $id: `${kunde}_ord2`, shopId, customerId: kunde, bestellnummer: '26-B', status: 'storniert', kaeuferEmail: 'k@example.com', bestellung: '{}' }) aw.seed('shopWiderrufe', { $id: `${kunde}_wid`, shopId, customerId: kunde, bestellnummer: '26-A', name: 'K', email: 'k@example.com', nachricht: 'x' }) aw.seed('shopRechtstexteArchiv', { $id: `${kunde}_rt`, shopId, customerId: kunde, hash: 'a'.repeat(64), texte: '{}', bestaetigtVon: 'K' }) const ordner = path.join(uploadDir, 'shop', siteKey) mkdirSync(ordner, { recursive: true }) writeFileSync(path.join(ordner, 'bild-l.webp'), 'x') return shopId } test('Webshop: fällige Kunden verlieren auch Bestellungen, Artikel, Widerrufe, Bilder und shopConfig; andere Kunden bleiben; Bericht nennt es', async () => { seedFaelligMitShop('sh1') seedFaelligMitShop('sh2', 'bcdefghijklmnopq') // nicht fälliger Kunde (laufendes Abo) mit Shop sub('sh3_sub', 'sh3', { status: 'active' }) aw.seed('shopConfig', { $id: 'f_sh3_shop', customerId: 'sh3', projectId: 'sh3p', siteKey: 'cdefghijklmnopqr' }) aw.seed('shopOrders', { $id: 'sh3_ord', shopId: 'f_sh3_shop', customerId: 'sh3', bestellnummer: '26-C', status: 'versendet', bestellung: '{}' }) const plan = await findDueDeletions(JAN_2026) const eintrag = plan.news.find((n) => n.customerId === 'sh1') assert.deepEqual(eintrag.shops, [{ id: 'f_sh1_shop', siteKey: SHOP_KEY }]) assert.ok(!plan.news.some((n) => n.customerId === 'sh3'), 'nicht fälliger Kunde bleibt außerhalb') assert.match(formatReport(plan), /Kunde sh1: .*1 Webshop\(s\) samt Bestellungen, Artikeln, Widerrufen und Bildern zu löschen/) // ältere Einträge ohne Shop tragen den Schlüssel nicht (Plan bleibt für andere Daten gleich) assert.ok(plan.news.filter((n) => !n.shops).every((n) => !('shops' in n))) const probe = await run({ mode: '', now: JAN_2026, log: () => {} }) assert.equal(probe.applied, false) assert.ok(aw.docs('shopOrders').has('sh1_ord1')) const reihenfolge = [] const echt = globalThis.fetch globalThis.fetch = async (url, optionen) => { if (optionen?.method === 'DELETE') reihenfolge.push(String(url).match(/collections\/([^/]+)\/documents/)?.[1]) return echt(url, optionen) } let result try { result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'sh1'), news: plan.news.filter((n) => n.customerId === 'sh1'), takeover: [] }) } finally { globalThis.fetch = echt } assert.deepEqual(result.failed, []) assert.deepEqual(result.held, []) assert.equal(result.deleted.shops, 1) assert.equal(result.deleted.shopDocs, 5) for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['shopRechtstexteArchiv', ['sh1_rt']], ['shopConfig', ['f_sh1_shop']]]) { for (const docId of ids) assert.ok(!aw.docs(col).has(docId), `${col} ${docId} weg`) } assert.ok(!existsSync(path.join(uploadDir, 'shop', SHOP_KEY)), 'Bilder weg') assert.ok(aw.docs('shopOrders').has('sh2_ord1') && aw.docs('shopConfig').has('f_sh2_shop'), 'anderer fälliger Kunde (nicht im Lauf) bleibt') assert.ok(existsSync(path.join(uploadDir, 'shop', 'bcdefghijklmnopq'))) assert.ok(aw.docs('shopOrders').has('sh3_ord') && aw.docs('shopConfig').has('f_sh3_shop'), 'laufender Kunde bleibt') // Reihenfolge: Shopdaten vor den Vertragsereignissen und Abo-Dokumenten assert.ok(reihenfolge.lastIndexOf('shopConfig') < reihenfolge.indexOf('contractEvents')) assert.ok(reihenfolge.indexOf('contractEvents') < reihenfolge.indexOf('subscriptions')) assert.match(formatReport(plan, result), /Webshop: 1 Shop\(s\) gelöscht \(5 Dokument\(e\)/) }) test('Webshop: scheitert das Löschen der Shopdaten, bleiben die Abo-Dokumente stehen (held) und der nächste Lauf setzt fort', async () => { seedFaelligMitShop('sh4', 'defghijklmnopqrs') const plan = await findDueDeletions(JAN_2026) aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' }) const result = await applyDeletions(planVon(plan, 'sh4')) assert.deepEqual(result.held, ['sh4']) assert.equal(result.failed[0].what, 'shopConfig') assert.ok(aw.docs('subscriptions').has('sh4_sub') && aw.docs('contractEvents').has('sh4_ev'), 'Abo-Daten bleiben') assert.ok(aw.docs('shopConfig').has('f_sh4_shop'), 'shopConfig bleibt, bis alles weg ist') const zweiter = await findDueDeletions(JAN_2026) assert.ok(zweiter.customers.some((c) => c.customerId === 'sh4'), 'der Kunde ist weiter fällig') const nochmal = await applyDeletions(planVon(zweiter, 'sh4')) assert.deepEqual(nochmal.failed, []) assert.deepEqual(nochmal.held, []) assert.ok(!aw.docs('shopConfig').has('f_sh4_shop') && !aw.docs('subscriptions').has('sh4_sub')) }) // ------------------------------------------------------------------------------------------------ Buchungskalender (Task 10) test('Buchungskalender: fällige Kunden verlieren alle uk*-Daten ihrer Funktion; andere Kunden bleiben; Bericht nennt es', async () => { for (const [kunde, faellig] of [['uk1', true], ['uk2', false]]) { sub(`${kunde}_sub`, kunde, faellig ? { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' } : { status: 'active' }) if (faellig) stripeSub(`${kunde}_sub`, kunde) const f = `f_${kunde}` aw.seed('portalFeatures', { $id: f, customerId: kunde, projectId: `${kunde}p`, featureKey: 'unterkunft', siteKey: 'defghijklmnopqrs', state: 'disabled', enabled: false }) aw.seed('ukEinstellungen', { $id: f, featureId: f, firma: 'Haus' }) aw.seed('ukUnterkuenfte', { $id: `${f}_u`, featureId: f, customerId: kunde, name: 'W', aktiv: false }) aw.seed('ukAnfragen', { $id: `${f}_a`, featureId: f, customerId: kunde, unterkunftId: `${f}_u`, nummer: 'A-X', status: 'bezahlt', gast: '{}' }) aw.seed('ukRechnungen', { $id: `${f}_r`, featureId: f, anfrageId: `${f}_a`, art: 'rechnung', nummer: 'R', datum: '2022-01-01', bruttoCent: 1, inhalt: '{}' }) } const plan = await findDueDeletions(JAN_2026) assert.deepEqual(plan.news.find((n) => n.customerId === 'uk1').ukIds, ['f_uk1']) assert.ok(!plan.news.some((n) => n.customerId === 'uk2')) assert.match(formatReport(plan), /Kunde uk1: .*1 Buchungskalender samt Anfragen, Rechnungen und Unterkünften zu löschen/) const result = await applyDeletions({ ...plan, customers: plan.customers.filter((c) => c.customerId === 'uk1'), news: plan.news.filter((n) => n.customerId === 'uk1'), takeover: [] }) assert.equal(result.deleted.unterkuenfte, 1) assert.equal(result.deleted.ukDocs, 4) for (const c of ['ukEinstellungen', 'ukUnterkuenfte', 'ukAnfragen', 'ukRechnungen']) assert.equal([...aw.docs(c).values()].filter((d) => d.$id.startsWith('f_uk1')).length, 0, c) assert.ok(aw.docs('ukAnfragen').has('f_uk2_a')) assert.match(formatReport(plan, result), /Buchungskalender: 1 Funktion\(en\) gelöscht \(4 Dokument/) })