Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register

Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order:<Kunde>:<Projekt>:
Zustand aus dem Register (setup/active, disabled, ordered mit offener Sitzung, "Zahlung fortsetzen"),
Stripe-Sitzung mit der Zeit des Klicks, Zustimmungsnachweis (Serverfassungen und -texte) vor markOrdered
und vor der URL. Standard-Haken belehrung mit BELEHRUNG_PLATZHALTER fuer den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 12:38:50 +00:00
1 parent 1f6ae0f7e0
commit 7fcb268644
5 files changed
+534 -43

No files matched your search

+401 -13
View File
@@ -1,14 +1,17 @@
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, Teil 1 (Task B6): POST /api/funktionen/:key/checkout mit den 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. Sperre, Zustandspruefung und Nachweis folgen in Task B7.
// 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'),
@@ -26,6 +29,8 @@ const { default: funktionenRoutes } = await import('../server/routes/funktionen.
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()]))
@@ -514,20 +519,403 @@ test('Abo-Memo: je Anfrage ein aboMemo(), News fragt das Abo nie', async () => {
}
})
// Zwischenstand bis Task B7: der Rest der Route (Sperre, Zustand, Stripe-Sitzung, Nachweis, markOrdered) gilt noch nur fuer News.
// Eine Termine-Bestellung, die alle Vorpruefungen besteht, darf bis dahin weder Sitzung noch Dokument noch Nachweis anlegen;
// B7 ersetzt diesen Test durch den Erfolgsfall.
test('Termine-Bestellung nach bestandenen Vorpruefungen legt bis Task B7 noch nichts an (503)', async () => {
// ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) ---
// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen, Block C fuellt sie).
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())
assert.equal(res.status, 503)
assert.equal(res.body.code, 'bestellung_nicht_freigeschaltet')
keineSpuren()
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 ---
const featureDoc = (state, extra = {}) =>
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', ...extra })
test('fortsetzen: ohne Dokument oder ausser ordered ist 400 zustimmung_fehlt, es entsteht nichts', async () => {
const fall = { projectId: 'p1', fortsetzen: true }
+37 -1
View File
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
@@ -410,3 +410,39 @@ test('checkout_datenbankfehler_ohne_rohe_meldung', async () => {
assert.doesNotMatch(res.body.error, /Invalid document structure|stripeSessionId/)
assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/)
})
// Task B7: auch die News-Bestellung schreibt vor der Weiterleitung einen Zustimmungsnachweis (Texte und Fassungen aus dem
// Register config.features.news statt Literale: Block F2 hebt die Fassung ohne Testbruch an)
test('news_schreibt_auch_einen_nachweis', async () => {
aw.seed('websiteProjects', { $id: 'pn1', customerId: 'c1', projectName: 'Nachweis-Laden' })
const news = config.features.news
const vor = new Date().toISOString()
const res = await checkout('pn1')
const nach = new Date().toISOString()
assert.equal(res.status, 200)
const session = sessions().at(-1)
const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'pn1')
assert.equal(nachweise.length, 1)
const [n] = nachweise
assert.equal(n.stripeSessionId, session.id)
assert.equal(n.featureKey, 'news')
assert.deepEqual(JSON.parse(n.texte), [
...news.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
{ id: 'knopf', text: news.consent.knopf, haken: false },
])
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version })
assert.ok(n.ip && n.userAgent)
assert.equal(n.konto, 'Kunde Eins <k@example.invalid>')
// ein Zeitpunkt: Nachweis, Stripe-Metadaten und Dokument; das Dokument behaelt den Wortlaut wie bisher
const doc = await sf.getFeatureDoc('c1', 'pn1', 'news')
assert.equal(doc.consentText, config.funktionen.consentText)
assert.equal(n.acceptedAt, doc.consentAt)
assert.equal(n.acceptedAt, session.metadata.consentAt)
assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach)
// Widerrufs-Anker: nach der Zahlung zaehlt die Zeit des Klicks aus dem Nachweis, nicht die Zeit des Dokuments
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: '2026-01-01T00:00:00.000Z', consentAt: '2026-01-02T00:00:00.000Z' })
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pn1')))).status, 200)
const order = (await sf.paidFeatureOrders('c1')).find((o) => o.projekt === 'Nachweis-Laden')
assert.equal(order.featureKey, 'news')
assert.equal(order.vertragAt, n.acceptedAt)
})