401 lines
24 KiB
JavaScript
401 lines
24 KiB
JavaScript
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'),
|
|
},
|
|
})
|
|
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')
|
|
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'))
|
|
// der nächste Lauf holt den Rest nach; 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, [])
|
|
})
|