Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f66a713531
commit
7ffe95f24a
10 files changed
+992
-87
No files matched your search
@@ -0,0 +1,494 @@
|
||||
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/)
|
||||
})
|
||||
Reference in new issue
Block a user