server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung, zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash, zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register). paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung verschiebt den Anker nicht. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
872 lines
46 KiB
JavaScript
872 lines
46 KiB
JavaScript
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'],
|
||
featureConsents: schemaKeys('featureConsents'),
|
||
},
|
||
})
|
||
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/)
|
||
// Der Nachbau liefert aus docs() eine Kopie: geloescht wird ueber die API, sonst bliebe die Bestellung fuer die naechsten Tests
|
||
await deleteDocument('portalFeatures', 'f_pf1_news')
|
||
})
|
||
|
||
/** Zustimmungsnachweis zu einer Sitzung (featureConsents) fuer die Fristanker-Tests. */
|
||
function seedZustimmung(id, { projectId = 'pa1', stripeSessionId, acceptedAt, featureKey = 'termine' }) {
|
||
aw.seed('featureConsents', {
|
||
$id: id,
|
||
customerId: 'c1',
|
||
projectId,
|
||
featureKey,
|
||
stripeSessionId,
|
||
acceptedAt,
|
||
fassungen: '{}',
|
||
texte: '[]',
|
||
textHash: '',
|
||
})
|
||
}
|
||
|
||
/** Bezahlte Funktion mit Zustimmung zur bezahlten Sitzung (vertragAt) und neuerer, unbezahlter Zustimmung derselben Funktion. */
|
||
function seedBezahlteBestellung({ klickAt, orderedAt = daysAgo(1) }) {
|
||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
|
||
aw.seed('portalFeatures', {
|
||
$id: 'f_pa1_termine',
|
||
customerId: 'c1',
|
||
projectId: 'pa1',
|
||
featureKey: 'termine',
|
||
state: 'setup',
|
||
enabled: false,
|
||
unlockedByPurchase: true,
|
||
// durch eine Neubestellung ueberschrieben: spaeter als der Klick der bezahlten Sitzung
|
||
orderedAt,
|
||
stripeSessionId: 'cs_bezahlt',
|
||
})
|
||
seedZustimmung('fc_bezahlt', { stripeSessionId: 'cs_bezahlt', acceptedAt: klickAt })
|
||
// spaetere, unbezahlte Zustimmung desselben Kunden und Projekts (andere Sitzung): verschiebt den Anker nicht
|
||
seedZustimmung('fc_offen', { stripeSessionId: 'cs_offen', acceptedAt: daysAgo(0.5) })
|
||
}
|
||
|
||
async function raeumeAnkerTest() {
|
||
await deleteDocument('portalFeatures', 'f_pa1_termine')
|
||
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
|
||
}
|
||
|
||
test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const klickAt = daysAgo(13)
|
||
seedBezahlteBestellung({ klickAt })
|
||
try {
|
||
const res = await withdraw({ order: 'funktion' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.doesNotMatch(team, /NACH Fristende/)
|
||
assert.match(team, /Studio Anker: in Einrichtung/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const klickAt = daysAgo(20)
|
||
seedBezahlteBestellung({ klickAt })
|
||
try {
|
||
const res = await withdraw({ order: 'funktion' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||
assert.match(team, /NACH Fristende/)
|
||
assert.doesNotMatch(team, /RECHTZEITIG/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
|
||
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
|
||
try {
|
||
// consentAt (Altbestand, News) vor orderedAt
|
||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
|
||
// weder Nachweis noch consentAt: orderedAt
|
||
mails.sent.length = 0
|
||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
|
||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
} finally {
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const orderedAt = daysAgo(2)
|
||
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
|
||
const warn = console.warn
|
||
const warnungen = []
|
||
console.warn = (...args) => warnungen.push(args.join(' '))
|
||
try {
|
||
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
|
||
const res = await withdraw({ order: 'funktion' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const team = t09().text
|
||
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
|
||
assert.match(team, /RECHTZEITIG/)
|
||
assert.ok(warnungen.some((w) => w.includes('Zustimmungsnachweis')), 'Rueckfall wird geloggt')
|
||
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
|
||
} finally {
|
||
console.warn = warn
|
||
await raeumeAnkerTest()
|
||
}
|
||
})
|
||
|
||
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
|
||
const startedAt = daysAgo(2)
|
||
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
|
||
const before = st.calls.length
|
||
const res = await withdraw({ order: 'abschluss', customerNumber: 'KD-1' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const upd = subscriptionUpdates(before)
|
||
assert.equal(upd.length, 1)
|
||
assert.equal(upd[0].body.cancel_at_period_end, 'true')
|
||
assert.equal(upd[0].body['metadata[cancelSource]'], 'widerruf')
|
||
assert.ok(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt)
|
||
const [widerruf] = await events('widerruf')
|
||
assert.deepEqual(widerruf.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: true })
|
||
assert.match(k04().text, /Abschluss Ihres Abos/)
|
||
assert.match(k04().text, /keine weiteren Abbuchungen/)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
assert.match(t09().text, /auf Kündigung gesetzt/)
|
||
assert.match(t09().text, /Abo in Stripe sofort beenden/)
|
||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(startedAt).endsAt)))
|
||
|
||
// Standard ohne order: der Abschluss; mit Ereignis abschluss zaehlt allein das Ereignis (hier 40 Tage alt: Frist vorbei)
|
||
await resetSubH({ units: 0 })
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
|
||
details: { contractAt: hostingDoc.startedAt }, consent: { ...consent, consentAt: hostingDoc.startedAt },
|
||
})
|
||
const late = st.calls.length
|
||
assert.equal((await withdraw({})).status, 200)
|
||
assert.deepEqual(subscriptionUpdates(late), [])
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, false)
|
||
const [w2] = await events('widerruf')
|
||
assert.deepEqual(w2.details, { order: 'abschluss', eventId: null, eventIds: [], inTime: false })
|
||
assert.match(k04().text, /prüfen/)
|
||
assert.match(t09().text, /keine offene Frist gefunden/)
|
||
})
|
||
|
||
test('Widerruf mit unbekannter E-Mail oder ungültiger Auswahl', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const before = st.calls.length
|
||
const res = await post('/api/abo/public/withdraw', { name: 'X', email: 'niemand@example.invalid', order: 'zusatzspeicher' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(st.calls.length, before)
|
||
assert.match(t09().text, /KEIN aktives Abo/)
|
||
assert.deepEqual(await listContractEvents('c1'), [])
|
||
// Unbekannte Auswahl faellt auf den Standard zurueck; ohne Name oder E-Mail 400
|
||
assert.equal((await withdraw({ order: 'irgendwas' })).status, 200)
|
||
assert.equal((await events('widerruf'))[0].details.order, 'abschluss')
|
||
assert.equal((await post('/api/abo/public/withdraw', { name: '', email: 'k@example.invalid' })).status, 400)
|
||
assert.equal((await post('/api/abo/public/cancel', { name: 'K', email: 'kein-mail' })).status, 400)
|
||
})
|
||
|
||
// F30: Ratenbegrenzung je IP (10 je Stunde), fuer Tests zuruecksetzbar; alte Eintraege verschwinden nach einer
|
||
// Stunde, damit IP-Adressen nicht laenger als noetig im Speicher bleiben (Datenschutz Abschnitt 7)
|
||
test('Ratenbegrenzung: 11. Anfrage 429; Reset; Einträge älter als eine Stunde werden entfernt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
for (let i = 0; i < 10; i += 1) assert.equal((await cancel({})).status, 200, `Anfrage ${i + 1}`)
|
||
const limited = await cancel({})
|
||
assert.equal(limited.status, 429)
|
||
assert.equal((await withdraw({})).status, 429, 'gemeinsames Limit beider Seiten')
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
resetPublicRateLimit()
|
||
assert.equal(publicRateLimitState().ips, 0)
|
||
assert.equal((await cancel({})).status, 200)
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
// Eintraege aelter als eine Stunde werden aus der Map entfernt (Zeit im Test vorgegeben), juengere bleiben
|
||
purgeRateLimit(Date.now() + 3600 * 1000 - 5000)
|
||
assert.equal(publicRateLimitState().ips, 1)
|
||
purgeRateLimit(Date.now() + 3600 * 1000 + 1)
|
||
assert.equal(publicRateLimitState().ips, 0)
|
||
})
|
||
|
||
// Fix 1 (6): eine ausserordentliche Kuendigung des Speichers wird trotzdem zum Periodenende verringert; die Pruefung
|
||
// der Art steht in K03 und T08
|
||
test('Zusatzspeicher außerordentlich gekündigt: trotzdem zum Periodenende verringert, Prüfung angekündigt', async () => {
|
||
await resetSubH({ units: 3 })
|
||
const res = await cancel({ contract: 'zusatzspeicher', packages: '2', kind: 'ausserordentlich', reason: 'Postfach dauernd voll' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(storageQuantity(), 1)
|
||
const [ev] = await events('kuendigung')
|
||
assert.equal(ev.details.toUnits, 1)
|
||
assert.match(k03().text, /um 2 Paket\(e\) gekündigt/)
|
||
assert.match(k03().text, /außerordentliche Kündigung wird geprüft/)
|
||
assert.match(t08().text, /Außerordentliche Kündigung des Zusatzspeichers/)
|
||
assert.match(t08().text, /Grund: Postfach dauernd voll/)
|
||
// ordentlich: kein Pruefungshinweis
|
||
await resetSubH({ units: 3 })
|
||
assert.equal((await cancel({ contract: 'zusatzspeicher', packages: '2' })).status, 200)
|
||
assert.doesNotMatch(k03().text, /wird geprüft/)
|
||
assert.doesNotMatch(t08().text, /des Zusatzspeichers: Grund prüfen/)
|
||
})
|
||
|
||
// Fix 1 (4): Ereignisse nicht lesbar - Abschluss faellt auf startedAt zurueck (F14), sonst Hinweis "von Hand pruefen"
|
||
test('Widerruf bei nicht lesbaren Ereignissen: Hinweis fürs Team, Abschluss zählt weiter ab startedAt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
|
||
let before = st.calls.length
|
||
let res = await withdraw({ order: 'wechsel' })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
assert.match(k04().text, /prüfen/)
|
||
assert.match(t09().text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
|
||
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art/)
|
||
assert.equal((await events('widerruf')).length, 1, 'Ereignis widerruf trotzdem gespeichert')
|
||
|
||
// Abschluss in der Frist: der Rueckfall auf startedAt gilt auch, wenn die Ereignisse nicht lesbar sind
|
||
const startedAt = daysAgo(2)
|
||
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'GET', status: 500 })
|
||
before = st.calls.length
|
||
res = await withdraw({ order: 'abschluss' })
|
||
assert.equal(res.status, 200)
|
||
assert.equal(subscriptionUpdates(before).length, 1)
|
||
assert.equal(st.subscriptions.get('sub_h').cancel_at_period_end, true)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
assert.match(t09().text, /Ereignisse nicht lesbar/)
|
||
|
||
// Abo-Beginn fehlt (kein Ereignis abschluss, kein startedAt): eigener Hinweis, nichts geaendert
|
||
await resetSubH({ units: 0, patch: { startedAt: null, termStartAt: null } })
|
||
before = st.calls.length
|
||
res = await withdraw({ order: 'abschluss' })
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
assert.match(t09().text, /Abo-Beginn fehlt/)
|
||
assert.doesNotMatch(t09().text, /keine Bestellung dieser Art|NACH Fristende/)
|
||
})
|
||
|
||
// Fix 1 (5): "NACH Fristende" nur nach dem Fristende; ein zweiter Widerruf derselben Bestellung heisst "bereits widerrufen"
|
||
test('Wiederholter Widerruf: „bereits widerrufen“ statt „NACH Fristende“', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(3)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.match(t09().text, /RECHTZEITIG/)
|
||
mails.sent.length = 0
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.match(t09().text, /bereits widerrufen/)
|
||
assert.doesNotMatch(t09().text, /NACH Fristende|RECHTZEITIG/)
|
||
assert.match(k04().text, /prüfen/)
|
||
const widerrufe = await events('widerruf')
|
||
assert.equal(widerrufe.length, 2)
|
||
assert.equal(widerrufe[0].details.eventId, null)
|
||
})
|
||
|
||
// Fix 1 (7): Widerruf eines noch nicht wirksamen Kartenwechsels bzw. eines verspaeteten Abschluss-Widerrufs
|
||
test('T09: offene Wechselrechnung stornieren; verspäteter Abschluss-Widerruf ggf. als Kündigung', async () => {
|
||
await resetSubH({ units: 0 })
|
||
const consentAt = daysAgo(1)
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: null, paymentMethod: 'card', paymentUrl: 'https://pay.example/inv' },
|
||
consent: { ...consent, consentAt }, status: 'offen',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||
assert.match(t09().text, /Wechselrechnung noch offen – stornieren, damit der Wechsel nicht nachträglich wirksam wird/)
|
||
|
||
// Wirksamer (bezahlter) Wechsel: kein Storno-Hinweis
|
||
await resetSubH({ units: 0 })
|
||
await recordContractEvent({
|
||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||
})
|
||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||
assert.doesNotMatch(t09().text, /Wechselrechnung noch offen/)
|
||
|
||
// Abschluss nach der Frist (Fixture: Abo seit 40 Tagen): Hinweis auf die ordentliche Kuendigung
|
||
await resetSubH({ units: 0 })
|
||
assert.equal((await withdraw({ order: 'abschluss' })).status, 200)
|
||
assert.match(t09().text, /NACH Fristende/)
|
||
assert.match(t09().text, /ggf\. als ordentliche Kündigung behandeln/)
|
||
// Zusatzspeicher ohne jede Bestellung: keine Frist gefunden statt "–, 24 Uhr"
|
||
mails.sent.length = 0
|
||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||
assert.match(t09().text, /Widerrufsfrist bis: keine Frist gefunden/)
|
||
assert.doesNotMatch(t09().text, /–, 24 Uhr|als ordentliche Kündigung/)
|
||
assert.match(t09().text, /keine Bestellung dieser Art/)
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Formulare: Auswahl vorhanden und wortgleich (Spec 4.5); die Bedienung prueft Task 10 Schritt 4 mit Playwright
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const html = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8')
|
||
|
||
test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext', () => {
|
||
const src = html('kuendigen.html')
|
||
assert.match(src, /name="contract" value="abo"[^>]*> Mein Abo \(Hosting- oder Wartungs-Abo, mit Zusatzspeicher\)/)
|
||
assert.match(src, /name="contract" value="zusatzspeicher"[^>]*> Nur den E-Mail-Zusatzspeicher/)
|
||
assert.match(src, /<input id="packages" name="packages" type="number"[^>]*min="1"[^>]*max="10"/)
|
||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||
assert.match(src, /Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir Ihnen per E-Mail\./)
|
||
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
|
||
})
|
||
|
||
test('widerrufen.html: Radioknöpfe order', () => {
|
||
const src = html('widerrufen.html')
|
||
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
|
||
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
|
||
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
|
||
assert.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/)
|
||
})
|