Files
Webklar-Kundenbereich/test/funktionenCheckout.test.js
T
ClaudeandClaude Sonnet 5.5 31aa80affd Terminplaner TP2: Fragebogen im Kontext und Speicher
kontext.js: ctx.fragebogen (normalisiert; fehlt er oder ist er kaputt, der leere,
kaputt mit einer Logzeile und ohne Wurf), ladeEinstellung liefert zusaetzlich
fragebogen, neuer kontextFuerPortal(feature) fuer das Portal in jedem Zustand.
speichereFragebogen schreibt unter termine-cfg:<id> nur fragebogen, updatedAt und
updatedBy (ohne version; fehlt das Dokument: 409 keine_einstellung). Neue Option
fragebogen bei speichereEinstellung schreibt Einstellung und Fragebogen in
derselben Sperre. kontextPerSiteKey bleibt unveraendert (Entwurf nie oeffentlich),
Tests dazu fuer off, ordered, setup, disabled und nach activate. Der Test
ladeEntwurf in funktionenCheckout.test.js schaltet die neue Logzeile stumm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:14:22 +00:00

1184 lines
59 KiB
JavaScript

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Checkout je Funktion (Tasks B6 und B7): POST /api/funktionen/:key/checkout.
// Teil 1 (B6): Vorpruefungen in der Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt,
// 5 Schalter, 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine
// Stripe-Sitzung, kein featureConsents- und kein portalFeatures-Dokument.
// Teil 2 (B7, ab "Sperre, Zustand, Nachweis"): Zustandspruefung, Stripe-Sitzung, Zustimmungsnachweis vor der Weiterleitung,
// markOrdered; Wiederverwendung der offenen Sitzung, "Zahlung fortsetzen", parallele Klicks.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
termineConfig: schemaKeys('termineConfig'),
contractEvents: schemaKeys('contractEvents'),
},
})
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 { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const reg = await import('../server/services/featureRegistry.js')
const order = await import('../server/services/featureOrder.js')
const consents = await import('../server/services/featureConsents.js')
const sf = await import('../server/services/siteFeatures.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
const T = config.features.termine
const STANDARD_TERMINE_MODUS = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
const DAY_MS = 86400000
// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
// Jeder Test beginnt sauber: Standard-Abos (c1 Wartung), Pilot fuer alle, keine Haken-Ueberschreibungen, keine Dokumente
beforeEach(async () => {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig', 'contractEvents']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c3', projectName: 'Kiosk' })
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' })
wartung()
st.checkoutSessions.clear()
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit']) delete T[name]
})
after(() => {
config.termine.mode = STANDARD_TERMINE_MODUS.mode
config.termine.pilotEmails = STANDARD_TERMINE_MODUS.pilotEmails
})
const c1 = customerCookie('c1')
const checkout = (key, body, cookie = c1) => post(`/api/funktionen/${key}/checkout`, body, { cookie })
const pflichtHaken = (feature = T) => feature.consent.items.filter((item) => item.haken).map((item) => item.id)
const zustimmung = (patch = {}) => ({ version: T.consent.version, haken: pflichtHaken(), ...patch })
const bodyT = (extra = {}) => ({ projectId: 'p1', zustimmung: zustimmung(), ...extra })
const spuren = () => ({ sessions: st.checkoutSessions.size, consents: aw.docs('featureConsents').size, features: aw.docs('portalFeatures').size })
const keineSpuren = () => assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: 0 }, 'Ablehnung ohne Spur')
const stumm = () => mock.method(console, 'error', () => {})
/** Zeichnet die Reihenfolge der Stufen auf: Haken unterlagenBereit/bestellpruefung und orderDeps.aboMemo (Zaehler). */
function ablaufAufzeichnen({ unterlagen = true, offen = [] } = {}) {
const ablauf = []
const args = []
T.unterlagenBereit = async () => {
ablauf.push('unterlagen')
return unterlagen
}
T.bestellpruefung = async (arg) => {
ablauf.push('pruefung')
args.push(arg)
return offen
}
const echt = order.orderDeps.aboMemo
order.orderDeps.aboMemo = () => {
ablauf.push('abo')
return echt()
}
return { ablauf, args, ende: () => (order.orderDeps.aboMemo = echt) }
}
// ------------------------------------------------------------------------------------------ 1 Kundenansicht ---
test('Kundenansicht: 403, auch mit gueltigem Body (Testanwendung ohne Waechter)', async () => {
const res = await checkout('termine', bodyT(), kundenansichtCookie())
assert.equal(res.status, 403)
assert.equal(res.body.error, 'In der Kundenansicht kann nicht bestellt werden.')
keineSpuren()
// News ebenso, und die Sperre kommt vor dem Schluessel: ein unbekannter Schluessel ist in der Kundenansicht 403, nicht 404
assert.equal((await checkout('news', { projectId: 'p1', consent: true }, kundenansichtCookie())).status, 403)
assert.equal((await checkout('nope', {}, kundenansichtCookie())).status, 403)
keineSpuren()
})
// ------------------------------------------------------------------------------------------ 2 Schluessel ---
test('unbekannter Schluessel: 404 (analyse, __proto__, nope, Gross-/Kleinschreibung)', async () => {
for (const key of ['analyse', '__proto__', 'nope', 'News', 'constructor']) {
const res = await checkout(key, bodyT())
assert.equal(res.status, 404, key)
assert.equal(res.body.error, 'Unbekannte Funktion', key)
}
keineSpuren()
})
// ------------------------------------------------------------------------------------------ 3 Zustimmung ---
test('Zustimmung fehlt oder ist leer: 400 zustimmung_fehlt', async () => {
const faelle = [
{ projectId: 'p1' },
{ projectId: 'p1', zustimmung: null },
{ projectId: 'p1', zustimmung: {} },
{ projectId: 'p1', zustimmung: zustimmung({ haken: [] }) },
{ projectId: 'p1', zustimmung: zustimmung({ haken: undefined }) },
{ projectId: 'p1', zustimmung: zustimmung({ version: undefined }) },
{ projectId: 'p1', zustimmung: 'alles' },
{ projectId: 'p1', zustimmung: [] },
{ projectId: 'p1', zustimmung: zustimmung({ haken: 'unterlagen' }) },
{ projectId: 'p1', zustimmung: zustimmung({ haken: [...pflichtHaken(), 42] }) },
{ projectId: 'p1', zustimmung: zustimmung({ haken: [...pflichtHaken(), null] }) },
]
for (const body of faelle) {
const res = await checkout('termine', body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.equal(res.body.code, 'zustimmung_fehlt', JSON.stringify(body))
assert.match(res.body.error, /zu/)
}
keineSpuren()
})
test('Zustimmung: ein Pflicht-Haken fehlt oder eine Haken-ID ist unbekannt: 400', async () => {
const ids = pflichtHaken()
assert.equal(ids.length, 4, 'Terminplaner hat vier Haken')
for (const fehlt of ids) {
const res = await checkout('termine', bodyT({ zustimmung: zustimmung({ haken: ids.filter((id) => id !== fehlt) }) }))
assert.equal(res.status, 400, `ohne ${fehlt}`)
assert.equal(res.body.code, 'zustimmung_fehlt', `ohne ${fehlt}`)
}
// unbekannte ID, auch neben vollstaendigen Haken; ebenso die ID eines Items ohne Haken
for (const fremd of ['gibt-es-nicht', '__proto__', '']) {
const res = await checkout('termine', bodyT({ zustimmung: zustimmung({ haken: [...ids, fremd] }) }))
assert.equal(res.status, 400, `unbekannt ${fremd}`)
assert.equal(res.body.code, 'zustimmung_fehlt', `unbekannt ${fremd}`)
}
keineSpuren()
})
test('Zustimmung: falsche Fassung ist 409 fassung_veraltet mit der Serverfassung und allen Item-Texten', async () => {
const res = await checkout('termine', bodyT({ zustimmung: zustimmung({ version: '1999-01-01' }) }))
assert.equal(res.status, 409)
assert.equal(res.body.code, 'fassung_veraltet')
assert.equal(res.body.consent.version, T.consent.version)
assert.deepEqual(res.body.consent, reg.oeffentlicheAnsicht('termine').consent)
assert.deepEqual(
res.body.consent.items.map((item) => item.text),
T.consent.items.map((item) => item.text),
'alle Item-Texte'
)
assert.equal(typeof res.body.error, 'string')
// die Fassung entscheidet auch dann, wenn die Haken nicht passen (der Browser zeigt erst die neue Fassung)
const alt = await checkout('termine', bodyT({ zustimmung: zustimmung({ version: 'alt', haken: ['frueher-anderes-item'] }) }))
assert.equal(alt.status, 409)
assert.equal(alt.body.code, 'fassung_veraltet')
keineSpuren()
// zur Laufzeit geaenderte Serverfassung gilt sofort (config bei jedem Aufruf gelesen)
const vorher = T.consent.version
const alterBody = bodyT()
try {
T.consent.version = 'neuere-fassung'
const veraltet = await checkout('termine', alterBody)
assert.equal(veraltet.status, 409)
assert.equal(veraltet.body.consent.version, 'neuere-fassung')
} finally {
T.consent.version = vorher
}
})
test('consent: true gilt nur fuer Funktionen mit genau einem Haken: Termine 400, News weiter', async () => {
const termine = await checkout('termine', { projectId: 'p1', consent: true })
assert.equal(termine.status, 400)
assert.equal(termine.body.code, 'zustimmung_fehlt')
keineSpuren()
// News: consent true kommt durch die Zustimmung (fremdes Projekt => erst danach 403), false/fehlend nicht
assert.equal((await checkout('news', { projectId: 'p5', consent: true })).status, 403)
for (const body of [{ projectId: 'p1', consent: false }, { projectId: 'p1' }, { projectId: 'p1', consent: 'true' }, { projectId: 'p1', consent: 1 }]) {
const res = await checkout('news', body)
assert.equal(res.status, 400, JSON.stringify(body))
assert.equal(res.body.code, 'zustimmung_fehlt', JSON.stringify(body))
}
keineSpuren()
})
test('News mit zustimmung-Objekt: richtige Fassung kommt durch, alte ist 409 mit der News-Fassung', async () => {
const news = config.features.news
const ok = { version: news.consent.version, haken: ['beginn'] }
assert.equal((await checkout('news', { projectId: 'p5', zustimmung: ok })).status, 403, 'Zustimmung ok, fremdes Projekt')
const alt = await checkout('news', { projectId: 'p1', zustimmung: { ...ok, version: 'alt' } })
assert.equal(alt.status, 409)
assert.deepEqual(alt.body.consent, reg.oeffentlicheAnsicht('news').consent)
// die Haken des Terminplaners gelten nicht fuer News und umgekehrt
assert.equal((await checkout('news', { projectId: 'p1', zustimmung: { ...ok, haken: ['unterlagen'] } })).status, 400)
assert.equal((await checkout('termine', { projectId: 'p1', zustimmung: zustimmung({ haken: ['beginn'] }) })).status, 400)
keineSpuren()
})
// ------------------------------------------------------------------------------------------ 4 Projekt ---
test('Projekt: ohne projectId 400, fremdes Projekt 403', async () => {
for (const projectId of [undefined, '', 12, null, {}]) {
const res = await checkout('termine', { projectId, zustimmung: zustimmung() })
assert.equal(res.status, 400, String(projectId))
assert.equal(res.body.error, 'Bitte wähle ein Projekt.')
}
const fremd = await checkout('termine', bodyT({ projectId: 'p5' }))
assert.equal(fremd.status, 403)
assert.equal((await checkout('termine', bodyT({ projectId: 'gibt-es-nicht' }))).status, 403)
keineSpuren()
})
// ------------------------------------------------------------------------------------------ 5 Schalter ---
test('Schalter: mode off und Pilot mit fremder Adresse sind 409 nicht_verfuegbar, News unabhaengig vom Modus', async () => {
config.termine.mode = 'off'
const aus = await checkout('termine', bodyT())
assert.equal(aus.status, 409)
assert.equal(aus.body.error, 'Der Terminplaner ist noch nicht verfügbar.')
assert.equal(aus.body.code, 'nicht_verfuegbar')
keineSpuren()
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['jemand-anderes@example.invalid']
const fremd = await checkout('termine', bodyT())
assert.equal(fremd.status, 409)
assert.equal(fremd.body.code, 'nicht_verfuegbar')
keineSpuren()
// eigene Adresse in der Pilotliste und Modus on lassen durch (die Stufen danach lehnen hier nichts ab)
for (const [mode, pilotEmails] of [['pilot', ['k@example.invalid']], ['on', []]]) {
config.termine.mode = mode
config.termine.pilotEmails = pilotEmails
const res = await checkout('termine', bodyT())
assert.notEqual(res.body?.code, 'nicht_verfuegbar', `${mode} ${pilotEmails}`)
}
// News kennt den Schalter nicht
config.termine.mode = 'off'
const news = await checkout('news', { projectId: 'p1', consent: true })
assert.equal(news.status, 200)
assert.match(news.body.url, /^https?:\/\//)
})
test('featureSperre: Text bei nicht verfuegbar, sonst null; Haken und Meldung ueberschreibbar', () => {
config.termine.mode = 'off'
assert.equal(reg.featureSperre('termine', 'k@example.invalid'), 'Der Terminplaner ist noch nicht verfügbar.')
assert.equal(reg.featureSperre('news', 'k@example.invalid'), null, 'News ohne Haken verfuegbar')
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['k@example.invalid']
assert.equal(reg.featureSperre('termine', 'k@example.invalid'), null)
assert.equal(reg.featureSperre('termine', 'x@example.invalid'), 'Der Terminplaner ist noch nicht verfügbar.')
assert.equal(reg.featureSperre('termine', undefined), 'Der Terminplaner ist noch nicht verfügbar.')
config.termine.mode = 'on'
assert.equal(reg.featureSperre('termine', 'x@example.invalid'), null)
// ueberschriebener Haken und Meldung des Registers
const meldung = T.unavailableMessage
try {
T.verfuegbar = (email) => email === 'ok@example.invalid'
T.unavailableMessage = 'Gerade nicht.'
assert.equal(reg.featureSperre('termine', 'ok@example.invalid'), null)
assert.equal(reg.featureSperre('termine', 'k@example.invalid'), 'Gerade nicht.')
delete T.unavailableMessage
assert.equal(reg.featureSperre('termine', 'k@example.invalid'), 'Diese Funktion ist noch nicht verfügbar.', 'ohne Meldung im Register')
} finally {
T.unavailableMessage = meldung
delete T.verfuegbar
}
// unbekannter Schluessel: nicht verfuegbar (fail-closed)
assert.equal(typeof reg.featureSperre('analyse', 'k@example.invalid'), 'string')
})
test('Standard-Haken des Terminplaners: verfuegbar, bestellpruefung und unterlagenBereit sind gefuellt, News hat keine', async () => {
const haken = reg.featureHaken('termine')
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit']) assert.equal(typeof haken[name], 'function', name)
assert.equal(await haken.unterlagenBereit(), true, 'Standard: immer bereit')
assert.deepEqual(reg.featureHaken('news'), {})
// dieselben Funktionen bei jedem Aufruf (der Haken-Test in featureRegistry.test.js vergleicht per ===)
assert.equal(reg.featureHaken('termine').bestellpruefung, haken.bestellpruefung)
})
// ------------------------------------------------------------------------------------------ 5b Unterlagen ---
test('Unterlagen nicht abrufbar: 503 unterlagen_fehlen, ohne Spur', async () => {
const lauf = ablaufAufzeichnen({ unterlagen: false })
try {
const res = await checkout('termine', bodyT())
assert.equal(res.status, 503)
assert.equal(res.body.code, 'unterlagen_fehlen')
assert.equal(res.body.error, 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.')
assert.deepEqual(lauf.ablauf, ['unterlagen'], 'weder Bestellpruefung noch Abo gelesen')
keineSpuren()
} finally {
lauf.ende()
}
})
// ------------------------------------------------------------------------------------------ 6 Bestellpruefung ---
test('Bestellpruefung nicht leer: 409 bestellung_offen mit den offenen Punkten, Haken ohne abo', async () => {
const offen = [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }, { schritt: 'website', text: 'Impressum eintragen' }]
const lauf = ablaufAufzeichnen({ offen })
try {
const res = await checkout('termine', bodyT())
assert.equal(res.status, 409)
assert.equal(res.body.code, 'bestellung_offen')
assert.deepEqual(res.body.offen, offen)
assert.equal(typeof res.body.error, 'string')
assert.equal(lauf.args.length, 1)
const [arg] = lauf.args
assert.equal('abo' in arg, false, 'der Checkout uebergibt kein abo')
assert.equal(arg.customerId, 'c1')
assert.equal(arg.projectId, 'p1')
assert.equal(arg.feature, T, 'der Register-Eintrag')
assert.deepEqual(lauf.ablauf, ['unterlagen', 'pruefung'], 'die Abo-Stufe wird nicht gelesen')
keineSpuren()
} finally {
lauf.ende()
}
})
test('Bestellpruefung: kaputter Haken (keine Liste) lehnt ab, 500 ohne Rohtext (fail-closed)', async () => {
T.bestellpruefung = async () => 'sieht gut aus'
const log = stumm()
try {
const res = await checkout('termine', bodyT())
assert.equal(res.status, 500)
assert.doesNotMatch(JSON.stringify(res.body), /sieht gut aus|Liste/)
keineSpuren()
} finally {
log.mock.restore()
}
})
test('Standard-Haken liest den Entwurf nur, wenn es ein Dokument gibt, und ruft die Bestellpruefung ohne Abo', async () => {
const haken = reg.featureHaken('termine')
const arg = { customerId: 'c1', projectId: 'p1', feature: T }
// ohne Dokument: keine Abfrage von termineConfig, das echte Gate meldet nur die Branche offen (kein Abo-Punkt)
aw.failNext({ collection: 'termineConfig', status: 500 })
const ohneDoc = await haken.bestellpruefung(arg)
assert.deepEqual(ohneDoc.map((p) => p.schritt), ['branche'])
// mit Dokument: der Entwurf wird geladen (der eingeplante Lesefehler trifft)
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
const log = stumm()
try {
await assert.rejects(haken.bestellpruefung(arg), (err) => err.status === 500)
} finally {
log.mock.restore()
}
// Dokument ohne gespeicherten Entwurf: wie ohne Dokument
assert.deepEqual((await haken.bestellpruefung(arg)).map((p) => p.schritt), ['branche'], 'danach wieder normal')
// bestellbarer Entwurf: leer; offener Fragebogen: Punkte der Schritte, nie der Abo-Punkt (mitAbo: false)
aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1' }))
assert.deepEqual(await haken.bestellpruefung(arg), [])
const offen = bestellbarerStand()
offen.fragebogen.bestaetigt = []
aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1', stand: offen }))
const punkte = await haken.bestellpruefung(arg)
assert.equal(punkte.length, 8)
assert.ok(punkte.every((p) => p.schritt !== 'bestellen'))
})
// ------------------------------------------------------------------------------------------ 7 Abo ---
const SCHRITTE_OHNE_BRANCHE = ['wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']
const ABO_TEXT = {
kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.',
gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
nicht_aktiv:
'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
}
function aboAbgelehnt(res, grund) {
assert.equal(res.status, 409, grund)
assert.equal(res.body.code, grund)
assert.equal(res.body.grund, grund, 'Grund ohne Praefix')
assert.equal(res.body.error, ABO_TEXT[grund])
keineSpuren()
}
test('Abo-Gruende: kein Abo (c3, c2 mit Hosting), Kuendigung, offene Zahlung', async () => {
T.bestellpruefung = async () => []
// c3 hat gar kein Abo, c2 nur Hosting
aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, customerCookie('c3')), 'kein_abo')
aboAbgelehnt(await checkout('termine', { projectId: 'p5', zustimmung: zustimmung() }, customerCookie('c2')), 'kein_abo')
// c1 mit Wartungs-Abo, aber Hosting-Abo allein ist auch kein Wartungs-Abo
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' }))
aboAbgelehnt(await checkout('termine', bodyT()), 'kein_abo')
wartung({ cancelAtPeriodEnd: true })
aboAbgelehnt(await checkout('termine', bodyT()), 'gekuendigt')
wartung({ cancelAt: new Date(Date.now() + 30 * DAY_MS).toISOString() })
aboAbgelehnt(await checkout('termine', bodyT()), 'gekuendigt')
wartung({ status: 'past_due' })
aboAbgelehnt(await checkout('termine', bodyT()), 'nicht_aktiv')
wartung({ status: 'past_due', cancelAtPeriodEnd: true })
aboAbgelehnt(await checkout('termine', bodyT()), 'gekuendigt')
// laufendes, ungekuendigtes Abo: die Abo-Stufe lehnt nicht ab
wartung({ status: 'active' })
const ok = await checkout('termine', bodyT())
assert.notEqual(ok.body?.code, 'kein_abo')
assert.notEqual(ok.body?.grund, 'nicht_aktiv')
})
test('Wechsel-Regel (E1): Spiegel Wartung active, aber juengstes wechsel-Ereignis widerrufen => 409 kein_abo ohne Spur; ein neueres erledigtes hebt es auf', async () => {
T.bestellpruefung = async () => []
const wechsel = (nr, status) =>
aw.seed('contractEvents', {
$id: `ev_w${nr}`,
$createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, nr)).toISOString(),
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: '{}',
status,
})
wechsel(1, 'widerrufen')
aboAbgelehnt(await checkout('termine', bodyT()), 'kein_abo')
assert.equal(aw.docs('portalFeatures').size, 0, 'kein Dokument entstanden')
wechsel(2, 'erledigt')
const ok = await checkout('termine', bodyT())
assert.notEqual(ok.body?.code, 'kein_abo')
assert.notEqual(ok.body?.grund, 'nicht_aktiv')
})
test('Abo-Lesefehler: 500 ohne Rohtext, fail-closed ohne Spur', async () => {
T.bestellpruefung = async () => []
aw.failNext({ collection: 'subscriptions', status: 500, message: 'GEHEIMER-ROHTEXT interner Fehler' })
const log = stumm()
try {
const res = await checkout('termine', bodyT())
assert.equal(res.status, 500)
assert.doesNotMatch(JSON.stringify(res.body), /GEHEIMER-ROHTEXT/)
assert.equal(typeof res.body.error, 'string')
keineSpuren()
} finally {
log.mock.restore()
}
})
test('Abo-Stufe mit dem echten Gate: ohne Abo und bestellbarem Entwurf 409 kein_abo, mit offenem Fragebogen 409 bestellung_offen (ohne bestellen)', async () => {
const c3 = customerCookie('c3')
const entwurf = (stand) => {
aw.seed('portalFeatures', { $id: 'f_p3_termine', customerId: 'c3', projectId: 'p3', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
aw.seed('termineConfig', entwurfDoc('f_p3_termine', { customerId: 'c3', projectId: 'p3', stand }))
}
// Standard-Haken, Kunde ohne Abo, bestellbarer Entwurf: das Gate ist leer, die Abo-Stufe lehnt ab
entwurf(bestellbarerStand())
const featuresVorher = aw.docs('portalFeatures').size
const abgelehnt = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
assert.equal(abgelehnt.status, 409)
assert.equal(abgelehnt.body.code, 'kein_abo')
assert.notEqual(abgelehnt.body.code, 'bestellung_offen')
assert.equal(abgelehnt.body.error, ABO_TEXT.kein_abo)
assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: featuresVorher }, 'keine Sitzung, kein Nachweis, kein neues Dokument')
// Kunde ohne Abo, Fragebogen offen: bestellung_offen, und die Liste enthaelt keinen Abo-Punkt (mitAbo: false)
const offen = bestellbarerStand()
offen.fragebogen.bestaetigt = ['branche']
entwurf(offen)
const res = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
assert.equal(res.status, 409)
assert.equal(res.body.code, 'bestellung_offen')
assert.equal(res.body.offen.length, 7)
assert.ok(res.body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Eintrag bestellen')
assert.deepEqual(res.body.offen.map((p) => p.schritt), SCHRITTE_OHNE_BRANCHE)
assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: featuresVorher })
// ohne Entwurf (gar kein Dokument): ebenfalls bestellung_offen mit der Branche, nie ein Abo-Punkt
for (const col of ['portalFeatures', 'termineConfig']) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
const leer = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
assert.equal(leer.status, 409)
assert.equal(leer.body.code, 'bestellung_offen')
assert.deepEqual(leer.body.offen.map((p) => p.schritt), ['branche'])
// ueberschriebener Haken mit leerer Liste: weiter die Abo-Stufe
T.bestellpruefung = async () => []
aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3), 'kein_abo')
})
test('Echtes Gate bis zur Stripe-Sitzung: Kunde mit Wartungs-Abo und bestellbarem Entwurf kommt durch, mit offenem Punkt nicht', async () => {
const entwurf = (stand) => {
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1', stand }))
}
// ein offener Punkt (Impressum fehlt): 409 bestellung_offen, keine Sitzung, kein Nachweis
const luecke = bestellbarerStand()
luecke.einstellung.betrieb.impressumUrl = null
entwurf(luecke)
const offen = await checkout('termine', bodyT())
assert.equal(offen.status, 409)
assert.equal(offen.body.code, 'bestellung_offen')
assert.deepEqual(offen.body.offen.map((p) => p.schritt), ['website'])
assert.deepEqual({ sessions: sitzungen().length, consents: nachweise().length }, { sessions: 0, consents: 0 })
// bestellbarer Entwurf: Gate leer, Abo laeuft, die Stripe-Sitzung entsteht
entwurf(bestellbarerStand())
const ok = await checkout('termine', bodyT())
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.match(ok.body.url, /^https?:\/\//)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
assert.equal(termineDoc().state, 'ordered')
})
test('aboMeldung: Wortlaute aus dem Register, Akkusativ aus mails.formen.akk, unbekannter Grund wie unbekannt', () => {
for (const grund of ['kein_abo', 'gekuendigt', 'nicht_aktiv']) assert.equal(order.aboMeldung(T, grund), ABO_TEXT[grund], grund)
assert.equal(order.aboMeldung(T, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.')
assert.equal(order.aboMeldung(T, 'irgendwas'), 'Das Abo konnte gerade nicht geprüft werden.')
assert.equal(order.aboMeldung(T, undefined), 'Das Abo konnte gerade nicht geprüft werden.')
// die Funktion steht im Satz: Akkusativ der jeweiligen Funktion
assert.equal(order.aboMeldung(config.features.news, 'kein_abo'), 'Für die News-Pinnwand brauchst du ein laufendes Wartungs-Abo.')
assert.match(order.aboMeldung(config.features.news, 'nicht_aktiv'), /kannst du die News-Pinnwand bestellen\.$/)
assert.match(order.aboMeldung(T, 'nicht_aktiv'), /bestellen\.$/)
})
// ------------------------------------------------------------------------------------------ Reihenfolge ---
test('Reihenfolge der Stufen: jede Stufe steht vor der naechsten und laesst nichts zurueck', async () => {
const lauf = ablaufAufzeichnen({ offen: [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }] })
try {
const nichtsGelesen = () => {
assert.deepEqual(lauf.ablauf, [], 'kein Haken, kein Abo gelesen')
keineSpuren()
}
config.termine.mode = 'off'
// 1 vor 2: Kundenansicht schlaegt unbekannten Schluessel und alles andere
assert.equal((await checkout('nope', {}, kundenansichtCookie())).status, 403)
// 2 vor 3: unbekannter Schluessel schlaegt fehlende Zustimmung
assert.equal((await checkout('nope', { projectId: 'p5' })).status, 404)
// 3 vor 4: fehlende Zustimmung schlaegt fremdes Projekt und fehlendes Projekt
assert.equal((await checkout('termine', { projectId: 'p5' })).status, 400)
assert.equal((await checkout('termine', {})).status, 400)
// 4 vor 5: Projekt schlaegt den Schalter (mode off)
assert.equal((await checkout('termine', { zustimmung: zustimmung() })).status, 400)
assert.equal((await checkout('termine', bodyT({ projectId: 'p5' }))).status, 403)
nichtsGelesen()
// 5: Schalter vor den Unterlagen, der Bestellpruefung und dem Abo
const aus = await checkout('termine', bodyT())
assert.equal(aus.body.code, 'nicht_verfuegbar')
nichtsGelesen()
config.termine.mode = 'pilot'
// 5b vor 6 und 7
lauf.ablauf.length = 0
const alt = T.unterlagenBereit
T.unterlagenBereit = async () => {
lauf.ablauf.push('unterlagen')
return false
}
const unterlagen = await checkout('termine', bodyT())
assert.equal(unterlagen.body.code, 'unterlagen_fehlen')
assert.deepEqual(lauf.ablauf, ['unterlagen'])
T.unterlagenBereit = alt
// 6 vor 7: offene Punkte, die Abo-Stufe (Zaehler) wird nicht angefasst - auch ohne Abo (c3)
lauf.ablauf.length = 0
const offen = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, customerCookie('c3'))
assert.equal(offen.status, 409)
assert.equal(offen.body.code, 'bestellung_offen')
assert.equal(offen.body.offen[0].schritt, 'zeiten')
assert.deepEqual(lauf.ablauf, ['unterlagen', 'pruefung'])
assert.equal(lauf.ablauf.filter((x) => x === 'abo').length, 0, 'Zaehler 0')
keineSpuren()
// danach leere Pruefung: die Abo-Stufe wird genau einmal gelesen
lauf.ablauf.length = 0
T.bestellpruefung = async (arg) => {
lauf.ablauf.push('pruefung')
assert.equal('abo' in arg, false)
return []
}
const weiter = await checkout('termine', bodyT())
assert.deepEqual(lauf.ablauf, ['unterlagen', 'pruefung', 'abo'], 'Zaehler 1')
assert.notEqual(weiter.body?.code, 'bestellung_offen')
assert.notEqual(weiter.body?.code, 'kein_abo')
} finally {
lauf.ende()
}
})
test('Abo-Memo: je Anfrage ein aboMemo(), News fragt das Abo nie', async () => {
const lauf = ablaufAufzeichnen()
try {
T.bestellpruefung = async () => []
await checkout('termine', bodyT())
assert.equal(lauf.ablauf.filter((x) => x === 'abo').length, 1)
lauf.ablauf.length = 0
// News (requiresWartungsAbo false) liest weder Abo noch Haken, c3 bestellt weiter
const news = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3'))
assert.equal(news.status, 200)
assert.deepEqual(lauf.ablauf, [])
} finally {
lauf.ende()
}
})
// ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) ---
// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen; das echte Gate pruefen die Tests der Abschnitte 6 und 7).
const bereit = () => {
T.bestellpruefung = async () => []
}
const featureDoc = (state, extra = {}) =>
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', ...extra })
const sitzungen = () => [...st.checkoutSessions.values()]
const nachweise = () => [...aw.docs('featureConsents').values()]
const termineDoc = () => aw.docs('portalFeatures').get('f_p1_termine')
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
const belehrungText = async (key) => (await reg.featureHaken(key).belehrung?.()) ?? null
const erwarteteTexte = async (key) => consents.nachweisTexte(reg.featureConfig(key), { belehrung: await belehrungText(key) })
const hakenTexte = (feature) => feature.consent.items.filter((item) => item.haken).map((item) => item.text).join('\n\n')
test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihenfolge, Hash, ein Zeitpunkt', async () => {
bereit()
const vor = new Date().toISOString()
const res = await checkout('termine', bodyT())
const nach = new Date().toISOString()
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['url'])
assert.equal(sitzungen().length, 1)
const [session] = sitzungen()
assert.equal(res.body.url, session.url)
assert.equal(nachweise().length, 1, 'genau ein Nachweis')
const [n] = nachweise()
assert.equal(n.stripeSessionId, session.id)
assert.deepEqual([n.customerId, n.projectId, n.featureKey], ['c1', 'p1', 'termine'])
assert.deepEqual(JSON.parse(n.texte), await erwarteteTexte('termine'), 'Texte in Anzeigereihenfolge')
assert.deepEqual(JSON.parse(n.fassungen), consents.zustimmungsfassungen(T))
assert.equal(n.textHash, sha256(n.texte), 'SHA-256 ueber die gespeicherten Texte')
assert.equal(n.textHash, consents.texteHash(JSON.parse(n.texte)))
assert.ok(n.ip, 'ip')
assert.ok(n.userAgent, 'userAgent')
assert.equal(n.konto, 'Kunde Eins <k@example.invalid>')
// ein Zeitpunkt: Nachweis, Stripe-Metadaten und Dokument tragen dieselbe Zeit des Klicks (Eingang der Anfrage)
const doc = termineDoc()
assert.equal(doc.state, 'ordered')
assert.equal(doc.stripeSessionId, session.id)
assert.equal(n.acceptedAt, session.metadata.consentAt)
assert.equal(n.acceptedAt, doc.consentAt)
assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`)
assert.match(n.acceptedAt, /Z$/, 'UTC-Zeit mit Z')
assert.equal(doc.consentText, hakenTexte(T).slice(0, 1000), 'consentText aus den Haken-Texten')
assert.equal(doc.consentIp, n.ip)
assert.equal(doc.consentUserAgent, n.userAgent)
})
test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => {
bereit()
assert.equal(reg.BELEHRUNG_PLATZHALTER, 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.')
assert.equal(await reg.featureHaken('termine').belehrung(), reg.BELEHRUNG_PLATZHALTER)
assert.equal(reg.featureHaken('news').belehrung, undefined)
await checkout('termine', bodyT())
const texte = JSON.parse(nachweise()[0].texte)
assert.deepEqual(texte.slice(-2), [
{ id: 'belehrung', text: reg.BELEHRUNG_PLATZHALTER, haken: false },
{ id: 'knopf', text: T.consent.knopf, haken: false },
])
assert.deepEqual(texte.map((x) => x.id), [...T.consent.items.map((x) => x.id), 'belehrung', 'knopf'])
const news = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3'))
assert.equal(news.status, 200)
const newsNachweis = nachweise().find((d) => d.featureKey === 'news')
assert.deepEqual(JSON.parse(newsNachweis.texte).map((x) => x.id), [...config.features.news.consent.items.map((x) => x.id), 'knopf'])
})
test('Belehrungs-Haken ueberschreibbar: der gelieferte Text steht im Nachweis, null laesst den Eintrag weg', async () => {
bereit()
T.belehrung = async () => 'Eigene Belehrung.'
await checkout('termine', bodyT())
assert.deepEqual(JSON.parse(nachweise()[0].texte).at(-2), { id: 'belehrung', text: 'Eigene Belehrung.', haken: false })
await deleteDocument('featureConsents', nachweise()[0].$id)
await deleteDocument('portalFeatures', 'f_p1_termine')
st.checkoutSessions.clear()
T.belehrung = async () => null
try {
assert.equal((await checkout('termine', bodyT())).status, 200)
assert.equal(JSON.parse(nachweise()[0].texte).some((x) => x.id === 'belehrung'), false)
} finally {
delete T.belehrung
}
})
test('Client-Texte zaehlen nicht: gespeichert werden die Registertexte und -fassungen', async () => {
bereit()
const res = await checkout('termine', bodyT({ texte: ['manipuliert'], fassungen: { zustimmung: 'x' }, konto: 'Fremd <f@x.de>', acceptedAt: '2000-01-01T00:00:00Z' }))
assert.equal(res.status, 200)
const [n] = nachweise()
assert.deepEqual(JSON.parse(n.texte), await erwarteteTexte('termine'))
assert.deepEqual(JSON.parse(n.fassungen), consents.zustimmungsfassungen(T))
assert.equal(n.konto, 'Kunde Eins <k@example.invalid>')
assert.ok(n.acceptedAt > '2020')
assert.doesNotMatch(JSON.stringify(nachweise()), /manipuliert/)
})
test('Fassungswechsel: alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis', async () => {
bereit()
const alt = T.consent.version
try {
T.consent.version = 'ENTWURF-2099-01-01'
const veraltet = await checkout('termine', bodyT({ zustimmung: { version: alt, haken: pflichtHaken() } }))
assert.equal(veraltet.status, 409)
assert.equal(veraltet.body.code, 'fassung_veraltet')
keineSpuren()
const neu = await checkout('termine', bodyT({ zustimmung: { version: 'ENTWURF-2099-01-01', haken: pflichtHaken() } }))
assert.equal(neu.status, 200)
assert.equal(JSON.parse(nachweise()[0].fassungen).zustimmung, 'ENTWURF-2099-01-01')
} finally {
T.consent.version = alt
}
})
test('ohne Nachweis keine URL: scheitert das Speichern, gibt es kein ordered, keine Weiterleitung', async () => {
bereit()
const log = stumm()
try {
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'GEHEIMER-ROHTEXT interner Fehler' })
const res = await checkout('termine', bodyT())
assert.equal(res.status, 500)
assert.equal(res.body.url, undefined)
assert.doesNotMatch(JSON.stringify(res.body), /GEHEIMER-ROHTEXT|https?:/)
assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/)
assert.equal(nachweise().length, 0)
const doc = termineDoc()
assert.ok(!doc || doc.state === 'off', 'nicht ordered')
assert.ok(!doc?.orderedAt, 'orderedAt leer')
// die Stripe-Sitzung ist angelegt, aber nie an den Browser gegangen; sie laeuft nach 24 h ab
assert.equal(sitzungen().length, 1)
// ein erneuter Klick geht normal durch: neue Sitzung, ein Nachweis zu ihr
const noch = await checkout('termine', bodyT())
assert.equal(noch.status, 200)
assert.equal(sitzungen().length, 2)
assert.equal(nachweise().length, 1)
assert.equal(nachweise()[0].stripeSessionId, sitzungen()[1].id)
} finally {
log.mock.restore()
}
})
test('Nachweis nicht speicherbar (zu lange Texte): 500 mit allgemeinem Text, kein Rohtext, kein ordered', async () => {
bereit()
const alt = T.consent.items[0].text
const log = stumm()
try {
T.consent.items[0].text = 'x'.repeat(30000)
const res = await checkout('termine', bodyT())
assert.equal(res.status, 500)
assert.doesNotMatch(JSON.stringify(res.body), /Zustimmungsnachweis|zu lang/)
assert.equal(res.body.url, undefined)
assert.ok(!termineDoc() || termineDoc().state === 'off')
} finally {
T.consent.items[0].text = alt
log.mock.restore()
}
})
test('Datenbankfehler beim markOrdered: 500 ohne Rohtext, der Nachweis steht schon, das Dokument nicht ordered', async () => {
bereit()
const log = stumm()
try {
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 400, message: 'Invalid document structure: Attribute "stripeSessionId" has invalid type.' })
const res = await checkout('termine', bodyT())
assert.equal(res.status, 500, JSON.stringify(res.body))
assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/)
assert.doesNotMatch(JSON.stringify(res.body), /Invalid document structure|stripeSessionId|https?:/)
assert.equal(nachweise().length, 1, 'Nachweis kommt vor markOrdered')
assert.equal(termineDoc().state, 'off')
} finally {
log.mock.restore()
}
})
test('Wiederverwendung: zweiter Aufruf bei offener Sitzung gibt dieselbe URL, ohne zweite Sitzung und zweiten Nachweis', async () => {
bereit()
const erste = await checkout('termine', bodyT())
const zweite = await checkout('termine', bodyT())
assert.equal(zweite.status, 200)
assert.equal(zweite.body.url, erste.body.url)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
// die Wiederverwendung fasst das Dokument nicht an (orderedAt bleibt der Zeitpunkt der Bestellung)
aw.seed('portalFeatures', { ...termineDoc(), orderedAt: '2026-01-01T00:00:00.000Z' })
await checkout('termine', bodyT())
assert.equal(termineDoc().orderedAt, '2026-01-01T00:00:00.000Z')
assert.equal(termineDoc().state, 'ordered')
})
test('Wiederverwendung ohne Nachweis: der Nachweis wird nachgetragen, bevor die URL zurueckkommt', async () => {
bereit()
const erste = await checkout('termine', bodyT())
await deleteDocument('featureConsents', nachweise()[0].$id)
assert.equal(nachweise().length, 0)
const vor = new Date().toISOString()
const zweite = await checkout('termine', bodyT())
assert.equal(zweite.status, 200)
assert.equal(zweite.body.url, erste.body.url)
assert.equal(sitzungen().length, 1, 'keine zweite Stripe-Sitzung')
assert.equal(nachweise().length, 1)
assert.equal(nachweise()[0].stripeSessionId, sitzungen()[0].id)
assert.ok(nachweise()[0].acceptedAt >= vor, 'Zeit des neuen Klicks')
assert.deepEqual(JSON.parse(nachweise()[0].texte), await erwarteteTexte('termine'))
// scheitert das Nachtragen, kommt keine URL zurueck
await deleteDocument('featureConsents', nachweise()[0].$id)
const log = stumm()
try {
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })
const kaputt = await checkout('termine', bodyT())
assert.equal(kaputt.status, 500)
assert.equal(kaputt.body.url, undefined)
assert.equal(nachweise().length, 0)
} finally {
log.mock.restore()
}
})
test('abgelaufene Sitzung: neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs)', async () => {
bereit()
const erste = await checkout('termine', bodyT())
sitzungen()[0].status = 'expired'
const zweite = await checkout('termine', bodyT())
assert.equal(zweite.status, 200)
assert.notEqual(zweite.body.url, erste.body.url)
assert.equal(sitzungen().length, 2)
assert.equal(nachweise().length, 2)
assert.equal(new Set(nachweise().map((n) => n.stripeSessionId)).size, 2)
assert.deepEqual(nachweise().map((n) => n.stripeSessionId).sort(), sitzungen().map((s) => s.id).sort())
assert.equal(termineDoc().stripeSessionId, sitzungen()[1].id)
assert.equal(termineDoc().consentAt, sitzungen()[1].metadata.consentAt)
})
test('parallele Klicks: eine Sitzung, ein Nachweis, beide 200 mit gleicher URL', async () => {
bereit()
const [a, b] = await Promise.all([checkout('termine', bodyT()), checkout('termine', bodyT())])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(a.body.url, b.body.url)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
})
// ------------------------------------------------------------------------------------------ Zustaende ---
test('Zustaende: setup und active sind 409 orderedConflictMessage, disabled 409 disabledMessage, nirgends eine neue Sitzung', async () => {
bereit()
for (const [state, text] of [
['setup', T.orderedConflictMessage],
['active', T.orderedConflictMessage],
['disabled', T.disabledMessage],
]) {
featureDoc(state)
const vorher = spuren()
const res = await checkout('termine', bodyT())
assert.equal(res.status, 409, state)
assert.equal(res.body.error, text, state)
assert.equal(res.body.url, undefined, state)
assert.deepEqual(spuren(), vorher, `${state}: nichts angelegt`)
assert.equal(termineDoc().state, state, `${state}: unveraendert`)
}
// News: Meldungen aus dem Register der News
const news = config.features.news
aw.seed('portalFeatures', { $id: 'f_p3_news', customerId: 'c3', projectId: 'p3', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const nCookie = customerCookie('c3')
assert.equal((await checkout('news', { projectId: 'p3', consent: true }, nCookie)).body.error, news.orderedConflictMessage)
aw.seed('portalFeatures', { $id: 'f_p3_news', customerId: 'c3', projectId: 'p3', featureKey: 'news', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop' })
assert.equal((await checkout('news', { projectId: 'p3', consent: true }, nCookie)).body.error, news.disabledMessage)
assert.equal(sitzungen().length, 0)
})
test('Zustand complete: 409, keine neue Sitzung, kein neuer Nachweis', async () => {
bereit()
await checkout('termine', bodyT())
sitzungen()[0].status = 'complete'
const res = await checkout('termine', bodyT())
assert.equal(res.status, 409)
assert.equal(res.body.error, T.orderedConflictMessage)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
// auch "Zahlung fortsetzen" bekommt keine URL
const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true })
assert.equal(fort.status, 409)
assert.equal(fort.body.url, undefined)
})
test('Stripe nicht erreichbar: frische Bestellung (< 24 h) ist 409, aeltere bekommt eine neue Sitzung samt Nachweis', async () => {
bereit()
const log = mock.method(console, 'warn', () => {})
try {
featureDoc('ordered', { stripeSessionId: 'cs_weg', orderedAt: new Date().toISOString() })
const frisch = await checkout('termine', bodyT())
assert.equal(frisch.status, 409)
assert.equal(frisch.body.error, T.orderedConflictMessage)
assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: 1 }, 'nichts angelegt')
featureDoc('ordered', { stripeSessionId: 'cs_weg', orderedAt: new Date(Date.now() - 2 * DAY_MS).toISOString() })
const alt = await checkout('termine', bodyT())
assert.equal(alt.status, 200)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
assert.equal(termineDoc().stripeSessionId, sitzungen()[0].id)
// ordered ohne gespeicherte Sitzungs-ID: ebenfalls eine neue Sitzung
featureDoc('ordered', { stripeSessionId: '' })
assert.equal((await checkout('termine', bodyT())).status, 200)
assert.equal(sitzungen().length, 2)
assert.equal(nachweise().length, 2)
} finally {
log.mock.restore()
}
})
// ------------------------------------------------------------------------------------------ Zahlung fortsetzen ---
test('Zahlung fortsetzen: Dokument ordered mit Nachweis gibt dieselbe URL, ohne zweite Sitzung und zweiten Nachweis', async () => {
bereit()
const erste = await checkout('termine', bodyT())
const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true })
assert.equal(fort.status, 200, JSON.stringify(fort.body))
assert.equal(fort.body.url, erste.body.url)
assert.equal(sitzungen().length, 1)
assert.equal(nachweise().length, 1)
})
test('Zahlung fortsetzen ohne Nachweis: 409 zustimmung_fehlt ohne URL, danach mit zustimmung 200 und der Nachweis ist wieder da', async () => {
bereit()
const erste = await checkout('termine', bodyT())
await deleteDocument('featureConsents', nachweise()[0].$id)
const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true })
assert.equal(fort.status, 409)
assert.equal(fort.body.code, 'zustimmung_fehlt')
assert.equal(fort.body.url, undefined)
assert.doesNotMatch(JSON.stringify(fort.body), /https?:/)
assert.equal(nachweise().length, 0, 'ohne Zustimmung entsteht kein Nachweis')
assert.equal(sitzungen().length, 1)
// der Kunde stimmt im Formular erneut zu
const neu = await checkout('termine', bodyT())
assert.equal(neu.status, 200)
assert.equal(neu.body.url, erste.body.url)
assert.equal(nachweise().length, 1)
assert.equal(nachweise()[0].stripeSessionId, sitzungen()[0].id)
assert.equal(sitzungen().length, 1)
})
test('Zahlung fortsetzen bei abgelaufener Sitzung: 409 zustimmung_fehlt, mit zustimmung eine neue Sitzung', async () => {
bereit()
await checkout('termine', bodyT())
sitzungen()[0].status = 'expired'
const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true })
assert.equal(fort.status, 409)
assert.equal(fort.body.code, 'zustimmung_fehlt')
assert.equal(fort.body.url, undefined)
assert.equal(sitzungen().length, 1, 'fortsetzen legt nie eine Sitzung an')
assert.equal(nachweise().length, 1)
const neu = await checkout('termine', bodyT())
assert.equal(neu.status, 200)
assert.equal(sitzungen().length, 2)
assert.equal(nachweise().length, 2)
})
test('Zahlung fortsetzen: ein Lesefehler beim Nachweis ist 500 ohne URL (im Zweifel keine Weiterleitung)', async () => {
bereit()
await checkout('termine', bodyT())
const log = stumm()
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' })
const fort = await checkout('termine', { projectId: 'p1', fortsetzen: true })
assert.equal(fort.status, 500)
assert.equal(fort.body.url, undefined)
assert.doesNotMatch(JSON.stringify(fort.body), /GEHEIMER-ROHTEXT/)
} finally {
log.mock.restore()
}
})
test('Zahlung fortsetzen bei News: gleiche Regeln (Nachweis da, dieselbe URL)', async () => {
const erste = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3'))
const fort = await checkout('news', { projectId: 'p3', fortsetzen: true }, customerCookie('c3'))
assert.equal(fort.status, 200)
assert.equal(fort.body.url, erste.body.url)
assert.equal(nachweise().length, 1)
assert.equal(sitzungen().length, 1)
})
// ------------------------------------------------------------------------------------------ Meldungen aus dem Register ---
test('markOrdered: Meldungen kommen aus dem Register der Funktion', async () => {
for (const [key, feature] of [['news', config.features.news], ['termine', T]]) {
for (const [state, text] of [['setup', feature.orderedConflictMessage], ['active', feature.orderedConflictMessage], ['disabled', feature.disabledMessage]]) {
aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, state, enabled: state === 'active', siteKey: 'abcdefghijklmnop' })
await assert.rejects(
() => sf.markOrdered({ customerId: 'c1', projectId: 'p1', key, sessionId: 'cs_x', consent: {} }),
(err) => err.status === 409 && err.message === text,
`${key} ${state}`
)
}
}
})
// ------------------------------------------------------------------------------------------ fortsetzen ---
test('fortsetzen: ohne Dokument oder ausser ordered ist 400 zustimmung_fehlt, es entsteht nichts', async () => {
const fall = { projectId: 'p1', fortsetzen: true }
const res = await checkout('termine', fall)
assert.equal(res.status, 400)
assert.equal(res.body.code, 'zustimmung_fehlt')
keineSpuren()
for (const state of ['off', 'setup', 'active', 'disabled']) {
featureDoc(state) // seed ersetzt das Dokument gleicher ID
const vorher = spuren()
const antwort = await checkout('termine', fall)
assert.equal(antwort.status, 400, state)
assert.equal(antwort.body.code, 'zustimmung_fehlt', state)
assert.deepEqual(spuren(), vorher, `${state}: nichts angelegt`)
assert.equal(vorher.features, 1)
assert.equal(aw.docs('portalFeatures').get('f_p1_termine').state, state, `${state}: Dokument unveraendert`)
}
// ein Dokument eines anderen Kunden zaehlt nicht als eigenes ordered
await deleteDocument('portalFeatures', 'f_p1_termine')
featureDoc('ordered', { customerId: 'c2' })
assert.equal((await checkout('termine', fall)).status, 400)
})
test('fortsetzen: fremdes Projekt ist 403, die Projektpruefung kommt vor dem Dokument', async () => {
featureDoc('ordered')
const res = await checkout('termine', { projectId: 'p5', fortsetzen: true })
assert.equal(res.status, 403)
assert.equal(aw.docs('featureConsents').size, 0)
assert.equal(st.checkoutSessions.size, 0)
// ohne projectId bleibt es bei 400 Projekt
const ohne = await checkout('termine', { fortsetzen: true })
assert.equal(ohne.status, 400)
assert.equal(ohne.body.error, 'Bitte wähle ein Projekt.')
})
test('fortsetzen bei ordered ueberspringt nur die Zustimmung: Schalter, Unterlagen, Pruefung und Abo gelten weiter', async () => {
featureDoc('ordered')
const fall = { projectId: 'p1', fortsetzen: true }
// Schalter
config.termine.mode = 'off'
assert.equal((await checkout('termine', fall)).body.code, 'nicht_verfuegbar')
config.termine.mode = 'pilot'
// Unterlagen, Bestellpruefung
const lauf = ablaufAufzeichnen({ unterlagen: false })
try {
assert.equal((await checkout('termine', fall)).body.code, 'unterlagen_fehlen')
T.unterlagenBereit = async () => true
T.bestellpruefung = async () => [{ schritt: 'branche', text: 'Branche wählen' }]
assert.equal((await checkout('termine', fall)).body.code, 'bestellung_offen')
T.bestellpruefung = async () => []
// Abo
wartung({ cancelAtPeriodEnd: true })
assert.equal((await checkout('termine', fall)).body.code, 'gekuendigt')
} finally {
lauf.ende()
}
assert.equal(st.checkoutSessions.size, 0)
assert.equal(aw.docs('featureConsents').size, 0)
})
test('fortsetzen und zustimmung zusammen: die Zustimmung gilt und wird geprueft', async () => {
featureDoc('ordered')
// unvollstaendige Zustimmung neben fortsetzen: 400 trotz ordered
const unvollstaendig = await checkout('termine', { projectId: 'p1', fortsetzen: true, zustimmung: zustimmung({ haken: [] }) })
assert.equal(unvollstaendig.status, 400)
assert.equal(unvollstaendig.body.code, 'zustimmung_fehlt')
// alte Fassung neben fortsetzen: 409
const alt = await checkout('termine', { projectId: 'p1', fortsetzen: true, zustimmung: zustimmung({ version: 'alt' }) })
assert.equal(alt.status, 409)
assert.equal(alt.body.code, 'fassung_veraltet')
// gueltige Zustimmung neben fortsetzen und ohne Dokument im Zustand ordered: die Zustimmung genuegt, der Zustand zaehlt nicht
await deleteDocument('portalFeatures', 'f_p1_termine')
config.termine.mode = 'off'
const gueltig = await checkout('termine', { projectId: 'p1', fortsetzen: true, zustimmung: zustimmung() })
assert.equal(gueltig.body.code, 'nicht_verfuegbar', 'Zustimmung geprueft und bestanden, weiter bis zum Schalter')
// News-Altform: consent: true neben fortsetzen gilt als Zustimmung (fremdes Projekt => erst danach 403), consent: false nicht
assert.equal((await checkout('news', { projectId: 'p5', fortsetzen: true, consent: true })).status, 403)
const news = await checkout('news', { projectId: 'p1', fortsetzen: true, consent: false })
assert.equal(news.status, 400, 'fortsetzen ohne ordered')
assert.equal(news.body.code, 'zustimmung_fehlt')
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(st.checkoutSessions.size, 0)
})
test('fortsetzen ist nur true wirksam: andere Werte zaehlen nicht', async () => {
featureDoc('ordered')
for (const fortsetzen of ['true', 1, 'ja', {}, [], false, null]) {
const res = await checkout('termine', { projectId: 'p1', fortsetzen })
assert.equal(res.status, 400, JSON.stringify(fortsetzen))
assert.equal(res.body.code, 'zustimmung_fehlt', JSON.stringify(fortsetzen))
}
})
// ------------------------------------------------------------------------------------------ News ---
test('News braucht kein Abo und keinen Termine-Schalter: c3 bestellt weiter', async () => {
config.termine.mode = 'off'
const res = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3'))
assert.equal(res.status, 200)
assert.match(res.body.url, /^https?:\/\//)
assert.equal(st.checkoutSessions.size, 1)
assert.equal([...aw.docs('portalFeatures').values()].filter((d) => d.featureKey === 'news' && d.state === 'ordered').length, 1)
})
// ------------------------------------------------------------------------------------------ Bausteine ---
test('pruefeZustimmung: gibt die Haken-IDs in Reihenfolge des Registers zurueck, wirft 400/409 mit body', () => {
const ids = pflichtHaken()
assert.deepEqual(order.pruefeZustimmung(T, { zustimmung: zustimmung() }), { akzeptiert: ids })
assert.deepEqual(order.pruefeZustimmung(T, { zustimmung: zustimmung({ haken: [...ids].reverse() }) }), { akzeptiert: ids }, 'Registerreihenfolge')
assert.deepEqual(order.pruefeZustimmung(T, { zustimmung: zustimmung({ haken: [...ids, ids[0]] }) }), { akzeptiert: ids }, 'Doppelte zaehlen einmal')
assert.deepEqual(order.pruefeZustimmung(config.features.news, { consent: true }), { akzeptiert: ['beginn'] })
assert.throws(
() => order.pruefeZustimmung(T, { consent: true }),
(err) => err.status === 400 && err.code === 'zustimmung_fehlt' && err.body.code === 'zustimmung_fehlt'
)
assert.throws(
() => order.pruefeZustimmung(T, undefined),
(err) => err.status === 400
)
assert.throws(
() => order.pruefeZustimmung(T, { zustimmung: zustimmung({ version: 'alt' }) }),
(err) => err.status === 409 && err.code === 'fassung_veraltet' && err.body.code === 'fassung_veraltet' && err.body.consent.version === T.consent.version
)
// die Ansicht im Fehler ist eine Kopie: Aenderungen daran aendern das Register nicht
try {
order.pruefeZustimmung(T, { zustimmung: zustimmung({ version: 'alt' }) })
} catch (err) {
err.body.consent.items[0].text = 'manipuliert'
assert.notEqual(T.consent.items[0].text, 'manipuliert')
}
})
test('istFortsetzen: nur fortsetzen === true ohne Zustimmung und ohne consent: true', () => {
assert.equal(order.istFortsetzen({ fortsetzen: true }), true)
assert.equal(order.istFortsetzen({ fortsetzen: true, zustimmung: null }), true)
assert.equal(order.istFortsetzen({ fortsetzen: true, zustimmung: undefined, consent: false }), true)
assert.equal(order.istFortsetzen({ fortsetzen: true, zustimmung: {} }), false)
assert.equal(order.istFortsetzen({ fortsetzen: true, consent: true }), false)
assert.equal(order.istFortsetzen({ fortsetzen: 'true' }), false)
assert.equal(order.istFortsetzen({}), false)
assert.equal(order.istFortsetzen(undefined), false)
assert.equal(order.istFortsetzen(null), false)
})
test('ladeEntwurf: Einstellung und Fragebogen aus termineConfig, fehlend oder kaputt ist null', async () => {
const einstellung = { betrieb: { name: 'Salon' } }
const fragebogen = { schritt: 'branche', branche: 'friseur' }
const doc = (extra) => aw.seed('termineConfig', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', einstellung: JSON.stringify(einstellung), version: 3, updatedAt: new Date().toISOString(), updatedBy: 'x', ...extra })
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung: null, fragebogen: null }, 'kein Dokument')
// ladeEinstellung (Kontext-Modul) meldet einen Fragebogen, den die Normalisierung ablehnt, mit einer Logzeile: hier stumm
const leise = stumm()
try {
doc({ fragebogen: JSON.stringify(fragebogen) })
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen })
doc({})
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, 'Attribut fehlt')
for (const kaputt of ['{kaputt', '', '[1,2]', '42', '"text"', 'null']) {
doc({ fragebogen: kaputt })
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, `fragebogen ${JSON.stringify(kaputt)}`)
}
// Einstellung unlesbar: null (der Fehler wird im Kontext-Modul geloggt), der Fragebogen bleibt
doc({ einstellung: '{kaputt', fragebogen: JSON.stringify(fragebogen) })
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung: null, fragebogen })
// Lesefehler werden nicht verschluckt
doc({ fragebogen: JSON.stringify(fragebogen) })
aw.failNext({ collection: 'termineConfig', status: 500 })
await assert.rejects(order.ladeEntwurf('f_p1_termine'))
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 })
await assert.rejects(order.ladeEntwurf('f_p1_termine'))
await assert.doesNotReject(order.ladeEntwurf('f_p1_termine'))
await assert.rejects(order.ladeEntwurf(''), TypeError)
} finally {
leise.mock.restore()
}
})