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'), portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'], }, }) 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 { analyseDeps } = await import('../server/services/analyse.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/) }) // Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19) async function withActiveAnalyse(fn) { const stops = [] const real = analyseDeps.client analyseDeps.client = { stopSite: async (k) => { stops.push(k) } } aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' }) try { return await fn(stops) } finally { analyseDeps.client = real } } const wechselIn = async (days) => { const consentAt = daysAgo(days) 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', }) } test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => { await resetSubH({ units: 0 }) await wechselIn(3) await withActiveAnalyse(async (stops) => { const res = await withdraw({ order: 'wechsel' }) assert.equal(res.status, 200) assert.deepEqual(stops, ['wfsitekey0123456789']) const d = aw.docs('portalFeatures').get('wf_feat') assert.equal(d.state, 'off') assert.ok(d.offSince) assert.ok(bySubject('Deine Website-Analyse wurde beendet')) }) // wirft der Messdienst, bleibt der Widerruf 200 await resetSubH({ units: 0 }) await wechselIn(3) await withActiveAnalyse(async () => { analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } } const res = await withdraw({ order: 'wechsel' }) assert.equal(res.status, 200) assert.equal((await events('wechsel'))[0].status, 'widerrufen') }) }) test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => { await resetSubH({ units: 0 }) await wechselIn(30) await withActiveAnalyse(async (stops) => { assert.equal((await withdraw({ order: 'wechsel' })).status, 200) assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200) assert.deepEqual(stops, []) assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active') }) }) 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 Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Funktion, Projekt und Fristende', async () => { await resetSubH({ units: 0 }) const orderedAt = daysAgo(3) aw.seed('websiteProjects', { $id: 'pf1', customerId: 'c1', projectName: 'Bäckerei Funktion' }) aw.seed('portalFeatures', { $id: 'f_pf1_news', customerId: 'c1', projectId: 'pf1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt }) const before = st.calls.length const res = await withdraw({ order: 'funktion' }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') assert.deepEqual(await listContractEvents('c1'), [], 'kein Vertragsereignis, kein Abo betroffen') assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined) assert.equal(aw.docs('portalFeatures').get('f_pf1_news').state, 'setup') assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/) assert.match(k04().text, /Wir prüfen Ihren Widerruf/) assert.doesNotMatch(k04().text, /Es erfolgen keine weiteren Abbuchungen/) const team = t09().text assert.match(team, /Funktion \(z\. B\. News-Pinnwand\)/) assert.match(team, /NICHTS automatisch geändert/) assert.match(team, /Bäckerei Funktion: in Einrichtung/) assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt))) assert.match(team, /RECHTZEITIG/) assert.doesNotMatch(team, /KEIN aktives Abo/) aw.docs('portalFeatures').delete('f_pf1_news') }) 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, /]*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.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/) 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/) })