Files
Webklar-Kundenbereich/test/zeitenLoeschen.test.js
T
ClaudeandClaude Opus 5.5 d0691236d5 Öffnungszeiten: Test hält fest, dass ein gescheitertes Löschen der Zeiten den Kunden bis zum nächsten Lauf hält
Seit TP2 bleiben die Abo-Dokumente stehen (held), wenn das Löschen abhängiger Daten scheitert. Der Test für die
Aufbewahrungs-Löschung prüft jetzt auch für die Zeiten: held nennt den Kunden, das Abo-Dokument bleibt, der
nächste Lauf findet den Kunden wieder und erst nach dem Löschen der Zeiten gehen die Abo-Dokumente.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:15:58 +00:00

251 lines
14 KiB
JavaScript

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:/)
})