Shop J3: Tagesjob und Stundenjob, Löschfristen, Kundenlöschung
- shop/jobs.js: taeglicherJob (Käuferdaten bezahlter Bestellungen und Widerrufe 3 Jahre nach Ende des Bestelljahres anonymisieren, Grenze 1. Januar Berlin; unbezahlte/stornierte Bestellungen nach 30 Tagen löschen; Shops seit 90 Tagen disabled samt Artikeln, Bestellungen, Widerrufen, Bildern und shopConfig löschen, Funktionsdokument bleibt disabled mit Vermerk), seitenweise mit Deckel je Lauf, Schritte und Dokumente einzeln abgesichert; stuendlicherJob räumt nie beanspruchte Bilder auf - shop/loeschen.js: gemeinsamer Baustein zum Löschen aller Daten eines Shops (Tagesjob und Löschfristen) - shop/kundenLoeschung.js + customerAdmin.js: Kundenlöschung schaltet den Webshop ab (kunde_geloescht) und schließt die Shops - retention.js: fällige Kunden verlieren auch ihre Shopdaten (vor den Abo-Dokumenten, bei Fehler held) - server/index.js: Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) registriert - Tests: shopJobs.test.js, retention.test.js erweitert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73820fb1e6
commit
b8055b336c
8 files changed
+941
-8
No files matched your search
@@ -18,12 +18,17 @@ const aw = await startAppwriteMock({
|
||||
newsPosts: schemaKeys('newsPosts'),
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
shopConfig: schemaKeys('shopConfig'),
|
||||
shopProducts: schemaKeys('shopProducts'),
|
||||
shopOrders: schemaKeys('shopOrders'),
|
||||
shopWiderrufe: schemaKeys('shopWiderrufe'),
|
||||
},
|
||||
})
|
||||
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')
|
||||
@@ -580,3 +585,89 @@ test('Nachweis-Liste an der Grenze: findDueDeletions bricht ab, bevor etwas gepl
|
||||
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' })
|
||||
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, 4)
|
||||
for (const [col, ids] of [['shopOrders', ['sh1_ord1', 'sh1_ord2']], ['shopProducts', ['sh1_prod']], ['shopWiderrufe', ['sh1_wid']], ['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 \(4 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'))
|
||||
})
|
||||
@@ -0,0 +1,452 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||||
|
||||
// Webshop J3: Tagesjob (Anonymisieren, Unbezahltes loeschen, abgeschaltete Shops loeschen), Stundenjob, Kundenloeschung, Registrierung.
|
||||
// Alles gegen den Appwrite-Nachbau und einen temporaeren Upload-Ordner; die Uhr ist immer ein fester Zeitpunkt.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
shopConfig: schemaKeys('shopConfig'),
|
||||
shopProducts: schemaKeys('shopProducts'),
|
||||
shopOrders: schemaKeys('shopOrders'),
|
||||
shopWiderrufe: schemaKeys('shopWiderrufe'),
|
||||
},
|
||||
datetimes: SHOP_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const uploadDir = mkdtempSync(path.join(os.tmpdir(), 'shop-jobs-'))
|
||||
process.env.SHOP_UPLOAD_DIR = uploadDir
|
||||
after(() => {
|
||||
rmSync(uploadDir, { recursive: true, force: true })
|
||||
return aw.close()
|
||||
})
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument, getDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { taeglicherJob, stuendlicherJob, minutenJob, anonymisierGrenze } = await import('../server/services/shop/jobs.js')
|
||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
|
||||
const KEY = 'abcdefghijklmnop'
|
||||
const KEY2 = 'bcdefghijklmnopq'
|
||||
const TAG = 24 * 3600 * 1000
|
||||
const JETZT = new Date('2026-10-05T10:00:00.000Z')
|
||||
const vor = (tage, von = JETZT) => new Date(von.getTime() - tage * TAG).toISOString()
|
||||
|
||||
let n = 0
|
||||
const id = (prefix) => `${prefix}${++n}`
|
||||
const shop = (shopId, over = {}) => aw.seed('shopConfig', { $id: shopId, customerId: 'cs1', projectId: 'p1', siteKey: KEY, geoeffnet: true, ...over })
|
||||
const feature = (shopId, over = {}) =>
|
||||
aw.seed('portalFeatures', { $id: shopId, customerId: 'cs1', projectId: 'p1', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true, ...over })
|
||||
const PERSON = {
|
||||
kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' },
|
||||
lieferadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
|
||||
rechnungsadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
|
||||
}
|
||||
const bestellung = (shopId, over = {}, daten = {}) => {
|
||||
const doc = {
|
||||
$id: id('o'),
|
||||
shopId,
|
||||
customerId: 'cs1',
|
||||
bestellnummer: '26-ABC123',
|
||||
status: 'versendet',
|
||||
summeCent: 2500,
|
||||
erstelltAt: vor(10),
|
||||
bezahltAt: vor(10),
|
||||
kaeuferEmail: 'max@example.com',
|
||||
bestellung: JSON.stringify({ positionen: [{ produktId: 'a', name: 'Tasse', menge: 2, einzelCent: 1000 }], summen: { gesamtCent: 2500 }, ...PERSON, ...daten }),
|
||||
...over,
|
||||
}
|
||||
aw.seed('shopOrders', doc)
|
||||
return doc.$id
|
||||
}
|
||||
const widerruf = (shopId, over = {}) => {
|
||||
const doc = { $id: id('w'), shopId, bestellnummer: '26-ABC123', name: 'Max Mustermann', email: 'max@example.com', nachricht: 'Ich widerrufe.', eingangAt: vor(10), ...over }
|
||||
aw.seed('shopWiderrufe', doc)
|
||||
return doc.$id
|
||||
}
|
||||
const produkt = (shopId) => {
|
||||
const doc = { $id: id('pr'), shopId, customerId: 'cs1', aktiv: true, sort: 1, name: 'Tasse', produkt: '{}' }
|
||||
aw.seed('shopProducts', doc)
|
||||
return doc.$id
|
||||
}
|
||||
const bild = (siteKey, bildId = id('img')) => {
|
||||
const ordner = path.join(uploadDir, siteKey)
|
||||
mkdirSync(ordner, { recursive: true })
|
||||
writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x')
|
||||
writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x')
|
||||
writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed: true, createdAt: vor(400) }))
|
||||
return bildId
|
||||
}
|
||||
const order = (orderId) => aw.docs('shopOrders').get(orderId)
|
||||
const daten = (orderId) => JSON.parse(order(orderId).bestellung)
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const name of ['shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'portalFeatures']) {
|
||||
for (const docId of aw.docs(name).keys()) await deleteDocument(config.collections[name], docId)
|
||||
}
|
||||
rmSync(uploadDir, { recursive: true, force: true })
|
||||
mkdirSync(uploadDir, { recursive: true })
|
||||
config.shop.mode = 'on'
|
||||
config.shop.loeschDeckel = undefined
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Anonymisieren
|
||||
|
||||
test('anonymisierGrenze: 1. Januar Berlin, drei Jahre nach Ende des Bestelljahres', () => {
|
||||
// Bestelljahr 2026 -> Ende 31.12.2026 -> +3 Jahre = 01.01.2030 (Berlin)
|
||||
assert.equal(anonymisierGrenze(new Date('2029-12-31T22:59:59.999Z')).toISOString(), '2025-12-31T23:00:00.000Z', 'noch 2029 in Berlin: Grenze = 01.01.2026 (Bestelljahr 2026 ist noch nicht dran)')
|
||||
assert.equal(anonymisierGrenze(new Date('2029-12-31T23:00:00.000Z')).toISOString(), '2026-12-31T23:00:00.000Z', 'ab 01.01.2030 00:00 Berlin: Grenze = 01.01.2027 (Bestelljahr 2026 ist dran)')
|
||||
})
|
||||
|
||||
test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, nicht eine Sekunde vorher', async () => {
|
||||
shop('s_a')
|
||||
const o = bestellung('s_a', { erstelltAt: '2026-06-01T10:00:00.000Z' })
|
||||
const vorher = await taeglicherJob(new Date('2029-12-31T22:59:59.000Z')) // 23:59:59 Berlin
|
||||
assert.equal(vorher.bestellungenAnonymisiert, 0)
|
||||
assert.equal(order(o).anonymisiertAt, undefined)
|
||||
assert.equal(order(o).kaeuferEmail, 'max@example.com')
|
||||
|
||||
const nachher = await taeglicherJob(new Date('2029-12-31T23:00:00.000Z')) // 00:00 Berlin, 1.1.2030
|
||||
assert.equal(nachher.bestellungenAnonymisiert, 1)
|
||||
assert.equal(nachher.fehler, 0)
|
||||
const doc = order(o)
|
||||
assert.equal(doc.kaeuferEmail, '')
|
||||
assert.ok(doc.anonymisiertAt)
|
||||
const d = daten(o)
|
||||
assert.deepEqual(d.kaeufer, { name: '', email: '', telefon: '' })
|
||||
assert.equal('lieferadresse' in d, false)
|
||||
assert.equal('rechnungsadresse' in d, false)
|
||||
assert.equal(d.positionen[0].name, 'Tasse', 'Positionen bleiben')
|
||||
assert.equal(d.summen.gesamtCent, 2500, 'Betraege bleiben')
|
||||
assert.equal(doc.bestellnummer, '26-ABC123')
|
||||
assert.equal(doc.status, 'versendet')
|
||||
assert.equal(doc.summeCent, 2500)
|
||||
assert.ok(!JSON.stringify(doc).includes('Mustermann'))
|
||||
})
|
||||
|
||||
test('Silvester-Nacht: eine Bestellung vom 31.12.2026 23:30 UTC gehoert in Berlin zu 2027 und folgt erst 2031', async () => {
|
||||
shop('s_b')
|
||||
const o = bestellung('s_b', { erstelltAt: '2026-12-31T23:30:00.000Z' })
|
||||
await taeglicherJob(new Date('2030-06-01T10:00:00.000Z'))
|
||||
assert.equal(order(o).anonymisiertAt, undefined, '2027 ist 2030 noch nicht dran')
|
||||
await taeglicherJob(new Date('2031-01-01T10:00:00.000Z'))
|
||||
assert.ok(order(o).anonymisiertAt)
|
||||
})
|
||||
|
||||
test('Anonymisierung ist idempotent und fasst andere Shops und junge Bestellungen nicht an', async () => {
|
||||
shop('s_c')
|
||||
shop('s_d', { siteKey: KEY2 })
|
||||
const alt = bestellung('s_c', { erstelltAt: '2020-03-01T10:00:00.000Z' })
|
||||
const jung = bestellung('s_c', { erstelltAt: '2026-03-01T10:00:00.000Z' })
|
||||
const erste = await taeglicherJob(JETZT)
|
||||
assert.equal(erste.bestellungenAnonymisiert, 1)
|
||||
const stand = order(alt).anonymisiertAt
|
||||
const zweite = await taeglicherJob(new Date(JETZT.getTime() + TAG))
|
||||
assert.equal(zweite.bestellungenAnonymisiert, 0)
|
||||
assert.equal(order(alt).anonymisiertAt, stand)
|
||||
assert.equal(order(jung).kaeuferEmail, 'max@example.com')
|
||||
assert.equal(daten(jung).kaeufer.name, 'Max Mustermann')
|
||||
})
|
||||
|
||||
test('Widerrufe: Name, E-Mail und Nachricht werden nach derselben Frist (eingangAt) geleert, die Bestellnummer bleibt', async () => {
|
||||
shop('s_e')
|
||||
const alt = widerruf('s_e', { eingangAt: '2026-02-01T10:00:00.000Z' })
|
||||
const jung = widerruf('s_e', { eingangAt: '2027-02-01T10:00:00.000Z' })
|
||||
assert.equal((await taeglicherJob(new Date('2029-12-31T22:59:59.000Z'))).widerrufeAnonymisiert, 0)
|
||||
const erg = await taeglicherJob(new Date('2030-01-01T10:00:00.000Z'))
|
||||
assert.equal(erg.widerrufeAnonymisiert, 1)
|
||||
const a = aw.docs('shopWiderrufe').get(alt)
|
||||
assert.deepEqual([a.name, a.email, a.nachricht, a.bestellnummer], ['', '', '', '26-ABC123'])
|
||||
assert.equal(aw.docs('shopWiderrufe').get(jung).email, 'max@example.com')
|
||||
assert.equal((await taeglicherJob(new Date('2030-01-02T10:00:00.000Z'))).widerrufeAnonymisiert, 0, 'idempotent')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Unbezahltes
|
||||
|
||||
test('unbezahlte Bestellungen: storniert und alte offene nach 30 Tagen weg; bezahlte, junge und solche mit bezahltAt bleiben', async () => {
|
||||
shop('s_f')
|
||||
const storniertAlt = bestellung('s_f', { status: 'storniert', erstelltAt: vor(31), bezahltAt: null })
|
||||
const storniertJung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(29), bezahltAt: null })
|
||||
const offenAlt = bestellung('s_f', { status: 'offen', erstelltAt: vor(40), bezahltAt: null })
|
||||
const offenJung = bestellung('s_f', { status: 'offen', erstelltAt: vor(2), bezahltAt: null })
|
||||
const bezahltAlt = bestellung('s_f', { status: 'versendet', erstelltAt: vor(100) })
|
||||
const stornoMitZahlung = bestellung('s_f', { status: 'storniert', erstelltAt: vor(100), bezahltAt: vor(100) })
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.unbezahltGeloescht, 2)
|
||||
assert.equal(erg.fehler, 0)
|
||||
for (const weg of [storniertAlt, offenAlt]) assert.equal(aw.docs('shopOrders').has(weg), false)
|
||||
for (const da of [storniertJung, offenJung, bezahltAlt, stornoMitZahlung]) assert.equal(aw.docs('shopOrders').has(da), true)
|
||||
assert.equal((await taeglicherJob(JETZT)).unbezahltGeloescht, 0)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Abgeschaltete Shops
|
||||
|
||||
test('Shop seit 91 Tagen disabled: Artikel, Bestellungen, Widerrufe, Bilder und shopConfig weg; Funktionsdokument bleibt disabled mit Vermerk', async () => {
|
||||
shop('s_g')
|
||||
feature('s_g', { state: 'disabled', enabled: false, disabledAt: vor(91), disabledReason: 'team', adminNote: 'war aus' })
|
||||
const p = produkt('s_g')
|
||||
const o = bestellung('s_g')
|
||||
const w = widerruf('s_g')
|
||||
const b = bild(KEY)
|
||||
// anderer Shop: aktiv, und einer erst 30 Tage disabled: bleiben unberuehrt
|
||||
shop('s_h', { siteKey: KEY2 })
|
||||
feature('s_h', { siteKey: KEY2, projectId: 'p2' })
|
||||
const o2 = bestellung('s_h')
|
||||
const bild2 = bild(KEY2)
|
||||
shop('s_i', { siteKey: 'cdefghijklmnopqr' })
|
||||
feature('s_i', { siteKey: 'cdefghijklmnopqr', projectId: 'p3', state: 'disabled', disabledAt: vor(30) })
|
||||
const o3 = bestellung('s_i')
|
||||
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.shopsGeloescht, 1)
|
||||
assert.equal(erg.shopDokumenteGeloescht, 3)
|
||||
assert.equal(erg.fehler, 0)
|
||||
assert.equal(aw.docs('shopProducts').has(p), false)
|
||||
assert.equal(aw.docs('shopOrders').has(o), false)
|
||||
assert.equal(aw.docs('shopWiderrufe').has(w), false)
|
||||
assert.equal(aw.docs('shopConfig').has('s_g'), false)
|
||||
assert.equal(existsSync(path.join(uploadDir, KEY)), false)
|
||||
assert.equal(existsSync(path.join(uploadDir, KEY, `${b}-l.webp`)), false)
|
||||
const f = aw.docs('portalFeatures').get('s_g')
|
||||
assert.equal(f.state, 'disabled')
|
||||
assert.equal(f.adminNote, 'Shopdaten gelöscht am 2026-10-05')
|
||||
assert.ok(f.adminNote.length <= 300)
|
||||
assert.equal(aw.docs('shopOrders').has(o2), true)
|
||||
assert.equal(aw.docs('shopConfig').has('s_h'), true)
|
||||
assert.equal(existsSync(path.join(uploadDir, KEY2, `${bild2}-l.webp`)), true)
|
||||
assert.equal(aw.docs('shopOrders').has(o3), true, '30 Tage reichen nicht')
|
||||
assert.equal(aw.docs('shopConfig').has('s_i'), true)
|
||||
|
||||
// zweiter Lauf: nichts mehr zu tun, der Vermerk haelt ihn kurz
|
||||
aw.resetListCalls()
|
||||
const nochmal = await taeglicherJob(new Date(JETZT.getTime() + TAG))
|
||||
assert.equal(nochmal.shopsGeloescht, 0)
|
||||
assert.equal(aw.listCalls('shopProducts').length, 0)
|
||||
})
|
||||
|
||||
test('Shop ohne siteKey-Bilder und ohne Dokumente: shopConfig wird trotzdem entfernt; wieder eingeschaltete Shops bleiben', async () => {
|
||||
shop('s_j')
|
||||
feature('s_j', { state: 'disabled', disabledAt: vor(100) })
|
||||
shop('s_k', { siteKey: KEY2 })
|
||||
feature('s_k', { siteKey: KEY2, state: 'active', disabledAt: null })
|
||||
const o = bestellung('s_k')
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.shopsGeloescht, 1)
|
||||
assert.equal(aw.docs('shopConfig').has('s_j'), false)
|
||||
assert.equal(aw.docs('shopOrders').has(o), true)
|
||||
})
|
||||
|
||||
test('Deckel: je Lauf hoechstens config.shop.loeschDeckel Loeschungen, der Rest folgt an den naechsten Tagen', async () => {
|
||||
config.shop.loeschDeckel = 3
|
||||
shop('s_l')
|
||||
feature('s_l', { state: 'disabled', disabledAt: vor(120) })
|
||||
const ids = { o: [], w: [], p: [] }
|
||||
for (let i = 0; i < 4; i++) ids.o.push(bestellung('s_l'))
|
||||
for (let i = 0; i < 2; i++) ids.w.push(widerruf('s_l'))
|
||||
ids.p.push(produkt('s_l'))
|
||||
bild(KEY)
|
||||
const gesamt = () => aw.docs('shopOrders').size + aw.docs('shopWiderrufe').size + aw.docs('shopProducts').size
|
||||
assert.equal(gesamt(), 7)
|
||||
|
||||
const tag1 = await taeglicherJob(JETZT)
|
||||
assert.equal(tag1.gedeckelt, true)
|
||||
assert.equal(tag1.shopsGeloescht, 0)
|
||||
assert.equal(gesamt(), 4)
|
||||
assert.equal(aw.docs('shopConfig').has('s_l'), true, 'shopConfig bleibt, bis alles weg ist')
|
||||
assert.equal(existsSync(path.join(uploadDir, KEY)), true, 'Bilder auch')
|
||||
const tag2 = await taeglicherJob(new Date(JETZT.getTime() + TAG))
|
||||
assert.equal(gesamt(), 1)
|
||||
assert.equal(tag2.shopsGeloescht, 0)
|
||||
const tag3 = await taeglicherJob(new Date(JETZT.getTime() + 2 * TAG))
|
||||
assert.equal(gesamt(), 0)
|
||||
assert.equal(tag3.shopsGeloescht, 1)
|
||||
assert.equal(aw.docs('shopConfig').has('s_l'), false)
|
||||
assert.equal(existsSync(path.join(uploadDir, KEY)), false)
|
||||
assert.equal(tag3.gedeckelt, false)
|
||||
})
|
||||
|
||||
test('Deckel gilt auch fuer unbezahlte und anonymisierte Bestellungen', async () => {
|
||||
config.shop.loeschDeckel = 2
|
||||
shop('s_m')
|
||||
for (let i = 0; i < 5; i++) bestellung('s_m', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null })
|
||||
for (let i = 0; i < 3; i++) bestellung('s_m', { erstelltAt: '2020-01-01T10:00:00.000Z' })
|
||||
const tag1 = await taeglicherJob(JETZT)
|
||||
assert.equal(tag1.unbezahltGeloescht, 2)
|
||||
assert.equal(tag1.bestellungenAnonymisiert, 2)
|
||||
assert.equal(tag1.gedeckelt, true)
|
||||
let t = 1
|
||||
let letzte = tag1
|
||||
while (letzte.gedeckelt && t < 10) letzte = await taeglicherJob(new Date(JETZT.getTime() + t++ * TAG))
|
||||
assert.equal([...aw.docs('shopOrders').values()].filter((d) => d.status === 'storniert').length, 0)
|
||||
assert.equal([...aw.docs('shopOrders').values()].filter((d) => !d.anonymisiertAt).length, 0)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Schalter, Fehler
|
||||
|
||||
test('SHOP_ENABLED=off ohne Shop-Dokumente: der Tagesjob kehrt nach einer Abfrage zurueck; mit Shop-Dokumenten laeuft er (Fristen gelten weiter)', async () => {
|
||||
config.shop.mode = 'off'
|
||||
aw.resetListCalls()
|
||||
assert.deepEqual(await taeglicherJob(JETZT), { aus: true })
|
||||
assert.equal(aw.listCalls().length, 1, 'genau eine Abfrage')
|
||||
assert.equal(aw.listCalls()[0].collection, 'shopConfig')
|
||||
assert.deepEqual(await stuendlicherJob(JETZT), { aus: true })
|
||||
assert.deepEqual(await minutenJob(JETZT), { aus: true })
|
||||
|
||||
shop('s_n')
|
||||
const o = bestellung('s_n', { status: 'storniert', erstelltAt: vor(60), bezahltAt: null })
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.unbezahltGeloescht, 1)
|
||||
assert.equal(aw.docs('shopOrders').has(o), false)
|
||||
})
|
||||
|
||||
test('Fehler sind je Schritt und je Dokument abgesichert: ein kaputter Widerruf stoppt weder Bestellungen noch andere Shops', async (t) => {
|
||||
const warn = t.mock.method(console, 'warn', () => {})
|
||||
shop('s_o')
|
||||
shop('s_p', { siteKey: KEY2 })
|
||||
const w1 = widerruf('s_o', { eingangAt: '2020-01-01T10:00:00.000Z' })
|
||||
const w2 = widerruf('s_o', { eingangAt: '2020-01-02T10:00:00.000Z' })
|
||||
const o = bestellung('s_o', { erstelltAt: '2020-01-01T10:00:00.000Z' })
|
||||
const w3 = widerruf('s_p', { eingangAt: '2020-01-01T10:00:00.000Z' })
|
||||
aw.failNext({ collection: 'shopWiderrufe', method: 'PATCH', status: 500, message: 'Fehler fuer max@example.com' })
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.fehler, 1)
|
||||
assert.equal(erg.widerrufeAnonymisiert, 2)
|
||||
assert.ok(order(o).anonymisiertAt)
|
||||
const geleert = [w1, w2, w3].filter((x) => !aw.docs('shopWiderrufe').get(x).email)
|
||||
assert.equal(geleert.length, 2)
|
||||
const logs = warn.mock.calls.map((c) => String(c.arguments[0])).join('\n')
|
||||
assert.match(logs, /taeglicherJob: shopWiderrufe .* fehlgeschlagen/)
|
||||
assert.ok(!logs.includes('Mustermann'), 'keine Kaeuferdaten im Log')
|
||||
// der naechste Lauf holt das kaputte Dokument nach
|
||||
assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).widerrufeAnonymisiert, 1)
|
||||
})
|
||||
|
||||
test('Fehler beim Loeschen eines Shops stoppt die anderen nicht und laesst den Vermerk weg', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
shop('s_q')
|
||||
feature('s_q', { state: 'disabled', disabledAt: vor(100) })
|
||||
bestellung('s_q')
|
||||
shop('s_r', { siteKey: KEY2 })
|
||||
feature('s_r', { siteKey: KEY2, projectId: 'p2', state: 'disabled', disabledAt: vor(100) })
|
||||
bestellung('s_r')
|
||||
aw.failNext({ collection: 'shopOrders', method: 'DELETE', status: 500, message: 'kaputt' })
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.fehler, 1)
|
||||
assert.equal(erg.shopsGeloescht, 1)
|
||||
const offen = ['s_q', 's_r'].filter((x) => aw.docs('shopConfig').has(x))
|
||||
assert.equal(offen.length, 1)
|
||||
assert.equal(aw.docs('portalFeatures').get(offen[0]).adminNote, undefined, 'kein Vermerk ohne vollstaendige Loeschung')
|
||||
assert.equal((await taeglicherJob(new Date(JETZT.getTime() + TAG))).shopsGeloescht, 1)
|
||||
assert.equal(aw.docs('shopConfig').size, 0)
|
||||
})
|
||||
|
||||
test('ein Lesefehler beim Auflisten der Shops stoppt die Loeschung abgeschalteter Shops nicht', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
shop('s_s')
|
||||
feature('s_s', { state: 'disabled', disabledAt: vor(100) })
|
||||
aw.failNext({ collection: 'shopConfig', method: 'GET', status: 500, message: 'kaputt' })
|
||||
const erg = await taeglicherJob(JETZT)
|
||||
assert.equal(erg.fehler, 1)
|
||||
assert.equal(erg.shopsGeloescht, 1)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Stundenjob
|
||||
|
||||
test('Stundenjob raeumt nie beanspruchte Bilder nach 24 h auf', async () => {
|
||||
const ordner = path.join(uploadDir, KEY)
|
||||
mkdirSync(ordner, { recursive: true })
|
||||
for (const [bildId, claimed] of [['alt1', false], ['alt2', true]]) {
|
||||
writeFileSync(path.join(ordner, `${bildId}-l.webp`), 'x')
|
||||
writeFileSync(path.join(ordner, `${bildId}-s.webp`), 'x')
|
||||
writeFileSync(path.join(ordner, `${bildId}.json`), JSON.stringify({ claimed, createdAt: vor(3) }))
|
||||
}
|
||||
const erg = await stuendlicherJob(JETZT)
|
||||
assert.equal(erg.bilderEntfernt, 1)
|
||||
assert.equal(existsSync(path.join(ordner, 'alt1.json')), false)
|
||||
assert.equal(existsSync(path.join(ordner, 'alt2.json')), true)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Kundenloeschung
|
||||
|
||||
test('Kunde loeschen: Webshop wird abgeschaltet (Grund kunde_geloescht), geoeffnet=false, Daten bleiben fuer die 90 Tage', async () => {
|
||||
aw.seed('customers', { $id: 'cs9', name: 'Weg', email: 'weg@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'p9', customerId: 'cs9', projectName: 'Laden' })
|
||||
aw.seed('shopConfig', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', siteKey: KEY, geoeffnet: true })
|
||||
aw.seed('portalFeatures', { $id: 'f_p9_shop', customerId: 'cs9', projectId: 'p9', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true })
|
||||
const o = bestellung('f_p9_shop')
|
||||
aw.seed('shopConfig', { $id: 'f_fremd', customerId: 'cs8', projectId: 'p8', siteKey: KEY2, geoeffnet: true })
|
||||
|
||||
await deleteCustomerWithPortalAccess('cs9')
|
||||
|
||||
assert.equal(aw.docs('customers').has('cs9'), false)
|
||||
const f = aw.docs('portalFeatures').get('f_p9_shop')
|
||||
assert.deepEqual([f.state, f.enabled, f.adminNote, f.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'])
|
||||
assert.ok(Math.abs(Date.parse(f.disabledAt) - Date.now()) < 5000)
|
||||
assert.equal(aw.docs('shopConfig').get('f_p9_shop').geoeffnet, false)
|
||||
assert.equal(aw.docs('shopConfig').get('f_fremd').geoeffnet, true, 'Shop eines anderen Kunden unberuehrt')
|
||||
assert.equal(aw.docs('shopOrders').has(o), true, 'Daten bleiben bis zur 90-Tage-Frist')
|
||||
|
||||
// 89 Tage spaeter noch da, nach 91 Tagen vom Tagesjob geloescht
|
||||
const frueh = new Date(Date.parse(f.disabledAt) + 89 * TAG)
|
||||
assert.equal((await taeglicherJob(frueh)).shopsGeloescht, 0)
|
||||
assert.equal(aw.docs('shopOrders').has(o), true)
|
||||
const spaet = new Date(Date.parse(f.disabledAt) + 91 * TAG)
|
||||
assert.equal((await taeglicherJob(spaet)).shopsGeloescht, 1)
|
||||
assert.equal(aw.docs('shopOrders').has(o), false)
|
||||
assert.equal(aw.docs('shopConfig').has('f_p9_shop'), false)
|
||||
assert.equal(aw.docs('portalFeatures').get('f_p9_shop').state, 'disabled')
|
||||
})
|
||||
|
||||
const kundeMitShop = (kunde, projekt) => {
|
||||
aw.seed('customers', { $id: kunde, name: 'Weg', email: `${kunde}@example.invalid` })
|
||||
aw.seed('websiteProjects', { $id: projekt, customerId: kunde, projectName: 'Laden' })
|
||||
aw.seed('shopConfig', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, siteKey: KEY, geoeffnet: true })
|
||||
aw.seed('portalFeatures', { $id: `f_${projekt}_shop`, customerId: kunde, projectId: projekt, featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true })
|
||||
}
|
||||
|
||||
test('Kunde loeschen gelingt auch, wenn das Abschalten des Webshops scheitert (Fehler im Log); der Shop wird trotzdem geschlossen', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
kundeMitShop('cs10', 'p10')
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
|
||||
await deleteCustomerWithPortalAccess('cs10')
|
||||
assert.equal(aw.docs('customers').has('cs10'), false)
|
||||
assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs10 nicht abgeschaltet/.test(String(c.arguments[0]))))
|
||||
assert.equal((await sf.getFeatureDoc('cs10', 'p10', 'shop')).state, 'active', 'das Dokument bleibt wie es war')
|
||||
assert.equal(aw.docs('shopConfig').get('f_p10_shop').geoeffnet, false)
|
||||
})
|
||||
|
||||
test('Kunde loeschen gelingt auch, wenn das Schliessen des Webshops scheitert (Fehler im Log); die Funktion ist abgeschaltet', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
kundeMitShop('cs11', 'p11')
|
||||
aw.failNext({ collection: 'shopConfig', method: 'PATCH', status: 500, message: 'kaputt' })
|
||||
await deleteCustomerWithPortalAccess('cs11')
|
||||
assert.equal(aw.docs('customers').has('cs11'), false)
|
||||
assert.ok(log.mock.calls.some((c) => /Webshop von Kunde cs11 nicht geschlossen/.test(String(c.arguments[0]))))
|
||||
assert.equal((await sf.getFeatureDoc('cs11', 'p11', 'shop')).state, 'disabled')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Registrierung
|
||||
|
||||
test('server/index.js registriert Minuten- (75 s), Stunden- (130 s) und Tagesjob (200 s) mit unref', () => {
|
||||
const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
|
||||
assert.match(quelle, /from '\.\/services\/shop\/jobs\.js'/)
|
||||
for (const [name, sekunden, takt] of [['shopMinuten', 75, '60 \\* 1000'], ['shopStunden', 130, '60 \\* 60 \\* 1000'], ['shopTages', 200, '24 \\* 60 \\* 60 \\* 1000']]) {
|
||||
assert.match(quelle, new RegExp(`setTimeout\\(\\(\\) => \\{\\s*${name}\\(\\)\\s*setInterval\\(${name}, ${takt}\\)\\.unref\\(\\)\\s*\\}, ${sekunden} \\* 1000\\)\\.unref\\(\\)`), name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Dokument bleibt lesbar: getDocument liefert das Funktionsdokument nach der Loeschung', async () => {
|
||||
shop('s_z')
|
||||
feature('s_z', { state: 'disabled', disabledAt: vor(100) })
|
||||
await taeglicherJob(JETZT)
|
||||
assert.equal((await getDocument(config.collections.portalFeatures, 's_z')).state, 'disabled')
|
||||
})
|
||||
Reference in new issue
Block a user