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

495 lines
27 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 } = 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)
})
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)
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)
})
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/)
})
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 () => {
await resetSubH({ units: 2, stripe: { pending_update: { expires_at: Math.floor(Date.now() / 1000) + 3600 } } })
const switchedAt = new Date().toISOString()
await 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',
})
const before = st.calls.length
const 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(t08().text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.equal((await events('wechsel'))[0].status, 'offen')
})
// 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, /Stripe/)
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, /bisherigen Preis/)
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)
// Nach einer Stunde ist der Eintrag weg (Zeit im Test vorgegeben)
assert.equal(publicRateLimitState({ now: Date.now() + 3600 * 1000 + 1 }).ips, 0)
assert.equal(publicRateLimitState().ips, 1)
})
// ---------------------------------------------------------------------------
// 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/)
})