Files
Webklar-Kundenbereich/test/publicForms.test.js
T

668 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
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'
// Oeffentliche Kuendigungs- und Widerrufsseite (Task 10): Auswahl des Vertrags (Abo oder nur der
// E-Mail-Zusatzspeicher, ganz oder mit Paketzahl) bzw. der Bestellung (Abschluss, Wechsel, Zusatzspeicher).
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes, resetPublicRateLimit, publicRateLimitState, purgeRateLimit } = await import('../server/routes/abo.js')
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, stripeSubscription() haengt
// dieselben IDs an die Abo-Posten)
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
const DAY_MS = 86400000
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd)
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
/** Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen (units Pakete); leert Ereignisse und Senke. */
async function resetSubH({ units = 0, patch = {}, stripe = {} } = {}) {
await clearEvents('c1')
mails.sent.length = 0
aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, ...patch })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe }))
}
// Jeder Test faengt ohne Ratenbegrenzung an (10 Anfragen je Stunde und IP, F30)
beforeEach(() => resetPublicRateLimit())
const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h')
const storageQuantity = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)?.quantity ?? 0
const bySubject = (start) => mails.sent.find((m) => m.subject.startsWith(start)) || null
const k03 = () => bySubject('Eingangsbestätigung Ihrer Kündigung')
const k04 = () => bySubject('Eingangsbestätigung Ihres Widerrufs')
const k13 = () => bySubject('Ihr E-Mail-Zusatzspeicher')
const t08 = () => bySubject('[Abo] Kündigung eingegangen')
const t09 = () => bySubject('[Abo] WIDERRUF eingegangen')
const t13 = () => bySubject('[Speicher]')
const events = async (type) => (await listContractEvents('c1')).filter((e) => e.type === type)
const cancel = (body) => post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', ...body })
const withdraw = (body) => post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', ...body })
// ---------------------------------------------------------------------------
// Kuendigung: nur der Zusatzspeicher (Rechtsrunde 28.09.2026, Entscheidung 5)
// ---------------------------------------------------------------------------
test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => {
await resetSubH({ units: 3 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0')
assert.equal(subscriptionUpdates(before)[0].body.proration_behavior, 'none')
assert.equal(storageQuantity(), 0)
const all = await listContractEvents('c1')
assert.equal(all[0].type, 'kuendigung')
// F31: das Ereignis traegt Vertrag, Wirksamkeit und die Pakete; ohne IP oder Browser (Datenschutz Abschnitt 7)
assert.deepEqual(all[0].details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 3, fromUnits: 3, toUnits: 0 })
assert.equal(all[0].status, 'erledigt')
assert.equal(all[0].subscriptionId, 'sub_h')
assert.equal(all[0].consentIp ?? null, null)
assert.equal(all[0].$id.length, 36)
// Die Verringerung selbst ist das Ereignis speicher ueber den Kuendigungsweg (Task 8)
const [speicher] = await events('speicher')
assert.equal(speicher.details.viaCancellation, true)
assert.equal(speicher.details.toUnits, 0)
// K03 nennt Vertrag, Pakete und den Zeitpunkt; K13 entfaellt (F31), T08 und T13 gehen ans Team
assert.ok(k03(), 'K03')
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
assert.ok(k03().text.includes('um 3 Paket(e) gekündigt; danach bleiben 0 Paket(e)'), k03().text)
assert.ok(k03().text.includes(PERIOD_END_LABEL), PERIOD_END_LABEL)
assert.doesNotMatch(k03().text, /Wartungs-Abo\n/, 'kein fester Vertragsname')
assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular')
assert.match(t08().text, /um 3 Paket\(e\) verringert/)
assert.match(t08().text, /E-Mail-Zusatzspeicher/)
assert.ok(t13(), 'T13 (Speicher-Aufgabe)')
// Die Abo-Kuendigung selbst bleibt aus: kein cancel_at, kein cancelRequestedAt
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
assert.equal(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, undefined)
})
// Task 12: Betreff und Text nennen das Abo des Kunden (Katalogname ohne „WEBklar “, F33); das Vertragsende steht
// in der Fassung 4 in § 11, nicht in § 9 (F08); das Stundenkontingent gibt es nur im Wartungs-Abo (kein Geldguthaben, Fix 1)
test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4', async () => {
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Hosting-Abo')
assert.ok(k03().text.includes('§ 11 der Vertragsbedingungen'), k03().text)
assert.ok(!k03().text.includes('§ 9 der Vertragsbedingungen') && !k03().text.includes('Stundenkontingent') && !k03().text.includes('Stundenguthaben'))
assert.ok(t08().text.includes('Abo: Hosting-Abo'), t08().text)
await resetSubH()
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo')
assert.ok(t09().text.includes('Abo: Hosting-Abo'), t09().text)
// Wartungs-Abo nach Fassung 3 (Altbestand): § 9, nicht genutzte Arbeitszeit aus dem Stundenkontingent
await resetSubH({ patch: { productKey: 'wartung', termsVersion: '2026-09-25-v3' }, stripe: { productKey: 'wartung', metadata: { termsVersion: '2026-09-25-v3' } } })
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200)
assert.equal(k03().subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo')
assert.ok(k03().text.includes('§ 9 der Vertragsbedingungen') && k03().text.includes('Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende.'), k03().text)
// Unbekannte E-Mail: kein Abo, neutrale Anrede „Abo“, Paragraph der aktuellen Fassung (F08)
await resetSubH()
assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200)
const k03u = mails.sent.find((m) => m.subject.startsWith('Eingangsbestätigung Ihrer Kündigung'))
assert.equal(k03u.subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Abo')
assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text)
})
test('2 von 3 Paketen ergibt Menge 1', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: hostingDoc.currentPeriodEnd, packages: 2, fromUnits: 3, toUnits: 1 })
assert.ok(k03().text.includes('um 2 Paket(e) gekündigt; danach bleiben 1 Paket(e)'), k03().text)
assert.doesNotMatch(k03().text, /um 3 Paket/)
})
test('Ohne packages ergibt Menge 0; mehr Pakete als gebucht heißt alle', async () => {
await resetSubH({ units: 3 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '' })).status, 200)
assert.equal(storageQuantity(), 0)
await resetSubH({ units: 2 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: 10 })).status, 200)
assert.equal(storageQuantity(), 0)
const [ev] = await events('kuendigung')
assert.equal(ev.details.packages, 2)
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
})
test('Gekündigtes Abo: die Menge wird trotzdem reduziert', async () => {
await resetSubH({
units: 2,
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
stripe: { cancel_at_period_end: true },
})
const res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.equal(ev.details.toUnits, 1)
assert.match(k03().text, /um 1 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
})
test('packages: 0 oder 11 ergibt 400, ohne Stripe-Aufruf und ohne Mail', async () => {
await resetSubH({ units: 3 })
const before = st.calls.length
for (const packages of [0, 11, '0', '11', '1,5', 'x']) {
const res = await cancel({ contract: 'zusatzspeicher', packages })
assert.equal(res.status, 400, String(packages))
assert.match(res.body.error, /1 bis 10/)
}
assert.equal(st.calls.length, before)
assert.deepEqual(mails.sent, [])
assert.deepEqual(await listContractEvents('c1'), [])
// Beim Abo zaehlt das Feld nicht (im Formular ausgeblendet)
assert.equal((await cancel({ contract: 'abo', packages: '0' })).status, 200)
})
test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200)
assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length)
// K03 sagt "wir pruefen", T08 sagt dem Team, dass kein Speicher gebucht ist; Ereignis als Aufgabe (offen)
assert.match(k03().text, /prüfen/)
assert.doesNotMatch(k03().text, /endet zum|verringert/)
assert.match(t08().text, /kein Zusatzspeicher gebucht/i)
// § 312k Abs. 4 BGB: die Bestaetigung nennt den Inhalt der Erklaerung, also auch die Paketzahl
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 0, toUnits: null })
assert.equal(t13(), null)
mails.sent.length = 0
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.match(k03().text, /Pakete: 2 Paket\(e\)/)
assert.match(t08().text, /Pakete: 2 Paket\(e\)/)
})
test('Unbekannte E-Mail: 200, keine Änderung', async () => {
await resetSubH({ units: 1 })
const before = st.calls.length
const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' })
assert.equal(res.status, 200)
assert.equal(st.calls.length, before)
assert.match(k03().text, /ordnen Ihre Kündigung/)
assert.match(t08().text, /KEIN aktives Abo/)
assert.deepEqual(await listContractEvents('c1'), [])
// Auch beim Zusatzspeicher
mails.sent.length = 0
assert.equal((await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'zusatzspeicher' })).status, 200)
assert.equal(st.calls.length, before)
assert.match(k03().text, /ordnen Ihre Kündigung/)
assert.match(k03().text, /E-Mail-Zusatzspeicher/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.match(t08().text, /Pakete: alle gebuchten Pakete/)
})
test('Kündigung des Abos: wie bisher bei Stripe, dazu das Ereignis kuendigung mit dem Abo-Namen', async () => {
await resetSubH({ units: 2 })
const before = st.calls.length
const res = await cancel({ contract: 'abo', customerNumber: 'KD-1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body['metadata[cancelSource]'], 'kuendigungsbutton')
assert.ok(upd[0].body.cancel_at_period_end === 'true' || upd[0].body.cancel_at, 'Kuendigung bei Stripe')
assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt)
// Der Speicher bleibt (er endet mit dem Abo)
assert.equal(storageQuantity(), 2)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'erledigt')
assert.equal(ev.details.contract, 'abo')
assert.equal(ev.details.kind, 'ordentlich')
assert.ok(ev.details.effectiveAt)
assert.equal(ev.productKey, 'hosting')
assert.deepEqual(await events('speicher'), [])
// Die Bestaetigung nennt das tatsaechliche Abo (Spec 4.5)
assert.match(k03().text, /Vertrag: WEBklar Hosting-Abo/)
assert.match(k03().text, /Ihr Abo endet zum/)
assert.match(t08().text, /Vertrag: WEBklar Hosting-Abo/)
assert.match(t08().text, /Automatisch gekündigt/)
// Standard ohne contract: das Abo
await resetSubH({ units: 0 })
assert.equal((await cancel({})).status, 200)
assert.equal((await events('kuendigung'))[0].details.contract, 'abo')
})
test('Außerordentliche Kündigung des Abos: keine Stripe-Änderung, Ereignis offen fürs Team', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await cancel({ kind: 'ausserordentlich', reason: 'Grund' })).status, 200)
assert.deepEqual(subscriptionUpdates(before), [])
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'abo', kind: 'ausserordentlich', effectiveAt: null })
assert.match(k03().text, /Wir prüfen Ihre Kündigung/)
})
// Controller: waehrend ein Kartenwechsel aussteht (offenes Ereignis wechsel, Stripe pending_update) darf die
// Verringerung das Stripe-Abo nicht anfassen - Stripe wuerde die Wechselrechnung stornieren
test('Wechsel ausstehend (Karte): Speicher nicht bei Stripe geändert, K03 „wir prüfen“, T08 mit Hinweis', async () => {
const switchedAt = new Date().toISOString()
const openSwitch = () =>
recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent, status: 'offen',
})
// Ausloeser 1: nur das offene Ereignis wechsel (Stripe traegt noch kein pending_update, z. B. Webhook-Rueckstand)
await resetSubH({ units: 2 })
await openSwitch()
let before = st.calls.length
let res = await cancel({ contract: 'zusatzspeicher', packages: '1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
assert.deepEqual(await events('speicher'), [])
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: 1, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(k03().text, /Pakete: 1 Paket\(e\)/)
assert.match(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.match(t08().text, /Pakete: 1 Paket\(e\)/)
assert.equal((await events('wechsel'))[0].status, 'offen')
// Ausloeser 2: nur pending_update am Stripe-Abo (Ereignis nicht gespeichert, nachweis_fehlt beim Wechsel)
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
before = st.calls.length
res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [], 'kein Stripe-Update')
assert.equal(storageQuantity(), 2)
assert.match(t08().text, /Wechsel ausstehend/)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
// Beide Ausloeser weg: Verringerung laeuft
await resetSubH({ units: 2 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '1' })).status, 200)
assert.equal(storageQuantity(), 1)
})
// F20: wirft changeStorage trotzdem (z. B. Stripe lehnt ab), bleibt die Seite bei 200 - K03 "wir pruefen", T08 Hinweis
test('changeStorage scheitert: 200, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
await resetSubH({ units: 2 })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau lehnt ab' })
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 2)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
assert.deepEqual(ev.details, { contract: 'zusatzspeicher', effectiveAt: null, packages: null, fromUnits: 2, toUnits: null })
assert.match(k03().text, /prüfen/)
assert.match(t08().text, /nicht automatisch verringert/i)
assert.match(t08().text, /gekündigt: alle gebuchten Pakete/)
assert.match(t08().text, /Stripe/)
assert.match(k03().text, /Pakete: alle gebuchten Pakete/)
assert.equal((await events('speicher'))[0].status, 'gescheitert')
})
// Sind die Ereignisse nicht lesbar (Wechsel-Pruefung unmoeglich), bleibt Stripe unangetastet: "wir pruefen" + T08
test('Ereignisse nicht lesbar: Speicher nicht angefasst, K03 „wir prüfen“, T08 nennt den Fehler', async () => {
await resetSubH({ units: 2 })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
const before = st.calls.length
const res = await cancel({ contract: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [])
assert.equal(storageQuantity(), 2)
assert.match(k03().text, /prüfen/)
assert.match(t08().text, /NICHT automatisch verringert/)
const [ev] = await events('kuendigung')
assert.equal(ev.status, 'offen')
})
// ---------------------------------------------------------------------------
// Widerruf: Bestellung waehlen (Abschluss, Wechsel, Zusatzspeicher)
// ---------------------------------------------------------------------------
test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(3)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
const before = st.calls.length
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const evs = await listContractEvents('c1')
const wechsel = evs.find((e) => e.type === 'wechsel')
assert.equal(wechsel.status, 'widerrufen')
assert.equal(st.calls.slice(before).filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0)
const widerruf = evs.find((e) => e.type === 'widerruf')
assert.equal(widerruf.details.order, 'wechsel')
assert.equal(widerruf.details.eventId, wechsel.$id)
assert.deepEqual(widerruf.details.eventIds, [wechsel.$id])
assert.equal(widerruf.status, 'offen')
assert.equal(widerruf.$id.length, 36)
assert.equal(widerruf.consentIp ?? null, null)
// Das Abo wird nicht gekuendigt; withdrawalRequestedAt bleibt der Abo-Kuendigung vorbehalten
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
// K04 nennt die Bestellung, T09 die Handgriffe und die Frist
assert.match(k04().text, /Wechsel ins Wartungs-Abo/)
assert.match(k04().text, /zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben/)
assert.doesNotMatch(k04().text, /keine weiteren Abbuchungen/)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /Preis .*zurück/)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(consentAt).endsAt)))
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
})
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(30)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
const before = st.calls.length
const res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200)
const evs = await listContractEvents('c1')
assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen')
assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null)
assert.deepEqual(evs.find((e) => e.type === 'widerruf').details.eventIds, [])
assert.equal(subscriptionUpdates(before).length, 0)
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /keine offene Frist gefunden/)
assert.match(t09().text, /NACH Fristende/)
})
// F03: mehrere Speicher-Bestellungen (Plus) in der Frist -> alle widerrufen; ein Minus hat keine Frist
test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerrufen, T09 listet sie', async () => {
await resetSubH({ units: 3 })
const plus1 = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'einrichten', fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt: daysAgo(5) }, status: 'erledigt',
})
const minus = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'periodenende', fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
})
const plus2 = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { kind: 'einrichten', fromUnits: 1, toUnits: 3 }, consent: { ...consent, consentAt: daysAgo(1) }, status: 'offen', dueAt: daysAgo(1),
})
const before = st.calls.length
const res = await withdraw({ order: 'zusatzspeicher' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const evs = await listContractEvents('c1')
const statusOf = (id) => evs.find((e) => e.$id === id).status
assert.equal(statusOf(plus1.$id), 'widerrufen')
assert.equal(statusOf(plus2.$id), 'widerrufen')
assert.equal(statusOf(minus.$id), 'offen')
const widerruf = evs.find((e) => e.type === 'widerruf')
assert.equal(widerruf.details.order, 'zusatzspeicher')
assert.deepEqual([...widerruf.details.eventIds].sort(), [plus1.$id, plus2.$id].sort())
assert.ok([plus1.$id, plus2.$id].includes(widerruf.details.eventId))
assert.equal(subscriptionUpdates(before).length, 0)
assert.match(k04().text, /E-Mail-Zusatzspeicher/)
assert.match(k04().text, /Menge/)
assert.match(t09().text, /0 → 2/)
assert.match(t09().text, /1 → 3/)
assert.match(t09().text, /Menge .*zurück/)
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
const before = st.calls.length
const res = await withdraw({ order: 'abschluss', customerNumber: 'KD-1' })
assert.equal(res.status, 200, JSON.stringify(res.body))
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body.cancel_at_period_end, 'true')
assert.equal(upd[0].body['metadata[cancelSource]'], 'widerruf')
assert.ok(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt)
const [widerruf] = await events('widerruf')
assert.deepEqual(widerruf.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: true })
assert.match(k04().text, /Abschluss Ihres Abos/)
assert.match(k04().text, /keine weiteren Abbuchungen/)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /auf Kündigung gesetzt/)
assert.match(t09().text, /Abo in Stripe sofort beenden/)
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(startedAt).endsAt)))
// Standard ohne order: der Abschluss; mit Ereignis abschluss zaehlt allein das Ereignis (hier 40 Tage alt: Frist vorbei)
await resetSubH({ units: 0 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
details: { contractAt: hostingDoc.startedAt }, consent: { ...consent, consentAt: hostingDoc.startedAt },
})
const late = st.calls.length
assert.equal((await withdraw({})).status, 200)
assert.deepEqual(subscriptionUpdates(late), [])
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
const [w2] = await events('widerruf')
assert.deepEqual(w2.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: false })
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /keine offene Frist gefunden/)
})
test('Widerruf mit unbekannter E-Mail oder ungültiger Auswahl', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
const res = await post('/api/abo/public/withdraw', { name: 'X', email: 'niemand@example.invalid', order: 'zusatzspeicher' })
assert.equal(res.status, 200)
assert.equal(st.calls.length, before)
assert.match(t09().text, /KEIN aktives Abo/)
assert.deepEqual(await listContractEvents('c1'), [])
// Unbekannte Auswahl faellt auf den Standard zurueck; ohne Name oder E-Mail 400
assert.equal((await withdraw({ order: 'irgendwas' })).status, 200)
assert.equal((await events('widerruf'))[0].details.order, 'abschluss')
assert.equal((await post('/api/abo/public/withdraw', { name: '', email: 'k@example.invalid' })).status, 400)
assert.equal((await post('/api/abo/public/cancel', { name: 'K', email: 'kein-mail' })).status, 400)
})
// F30: Ratenbegrenzung je IP (10 je Stunde), fuer Tests zuruecksetzbar; alte Eintraege verschwinden nach einer
// Stunde, damit IP-Adressen nicht laenger als noetig im Speicher bleiben (Datenschutz Abschnitt 7)
test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde werden entfernt', async () => {
await resetSubH({ units: 0 })
for (let i = 0; i < 10; i += 1) assert.equal((await cancel({})).status, 200, `Anfrage ${i + 1}`)
const limited = await cancel({})
assert.equal(limited.status, 429)
assert.equal((await withdraw({})).status, 429, 'gemeinsames Limit beider Seiten')
assert.equal(publicRateLimitState().ips, 1)
resetPublicRateLimit()
assert.equal(publicRateLimitState().ips, 0)
assert.equal((await cancel({})).status, 200)
assert.equal(publicRateLimitState().ips, 1)
// Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben
purgeRateLimit(Date.now() + 3600 * 1000 - 5000)
assert.equal(publicRateLimitState().ips, 1)
purgeRateLimit(Date.now() + 3600 * 1000 + 1)
assert.equal(publicRateLimitState().ips, 0)
})
// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung
// der Art steht in K03 und T08
test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => {
await resetSubH({ units: 3 })
const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(storageQuantity(), 1)
const [ev] = await events('kuendigung')
assert.equal(ev.details.toUnits, 1)
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
assert.match(k03().text, /außerordentliche Kündigung wird geprüft/)
assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/)
assert.match(t08().text, /Grund: Postfach dauernd voll/)
// ordentlich: kein Pruefungshinweis
await resetSubH({ units: 3 })
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
assert.doesNotMatch(k03().text, /wird geprüft/)
assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/)
})
// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen"
test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => {
await resetSubH({ units: 0 })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
let before = st.calls.length
let res = await withdraw({ order: 'wechsel' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(k04().text, /prüfen/)
assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/)
assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert')
// Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.equal(subscriptionUpdates(before).length, 1)
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true)
assert.match(t09().text, /RECHTZEITIG/)
assert.match(t09().text, /Ereignisse nicht lesbar/)
// Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert
await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } })
before = st.calls.length
res = await withdraw({ order: 'abschluss' })
assert.equal(res.status, 200)
assert.deepEqual(subscriptionUpdates(before), [])
assert.match(t09().text, /Abo-Beginn fehlt/)
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/)
})
// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen"
test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(3)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /RECHTZEITIG/)
mails.sent.length = 0
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.match(t09().text, /bereits widerrufen/)
assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/)
assert.match(k04().text, /prüfen/)
const widerrufe = await events('widerruf')
assert.equal(widerrufe.length, 2)
assert.equal(widerrufe[0].details.eventId, null)
})
// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs
test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => {
await resetSubH({ units: 0 })
const consentAt = daysAgo(1)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
consent: { ...consent, consentAt }, status: 'offen',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/)
// Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis
await resetSubH({ units: 0 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
consent: { ...consent, consentAt }, status: 'erledigt',
})
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/)
// Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung
await resetSubH({ units: 0 })
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
assert.match(t09().text, /NACH Fristende/)
assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/)
// Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr"
mails.sent.length = 0
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/)
assert.match(t09().text, /keine Bestellung dieser Art/)
})
// ---------------------------------------------------------------------------
// Formulare: Auswahl vorhanden und wortgleich (Spec 4.5); die Bedienung prueft Task 10 Schritt 4 mit Playwright
// ---------------------------------------------------------------------------
const html = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8')
test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', () => {
const src = html('kuendigen.html')
assert.match(src, /name="contract" value="abo"[^>]*> Mein Abo \(Hosting- oder Wartungs-Abo, mit Zusatzspeicher\)/)
assert.match(src, /name="contract" value="zusatzspeicher"[^>]*> Nur den E-Mail-Zusatzspeicher/)
assert.match(src, /<input id="packages" name="packages" type="number"[^>]*min="1"[^>]*max="10"/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail\./)
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
})
test('widerrufen.html: Radioknöpfe order', () => {
const src = html('widerrufen.html')
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
})
test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => {
const src = html('legal-form.js')
assert.doesNotMatch(src, /\['Vertrag', 'WEBklar Wartungs-Abo'\]/)
assert.match(src, /packages/)
})