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:
1 parent
1f6ae0f7e0
commit
7fcb268644
5 files changed
+534
-43
No files matched your search
+71
-23
@@ -6,14 +6,15 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
|||||||
import { withLocks } from '../services/keyedLock.js'
|
import { withLocks } from '../services/keyedLock.js'
|
||||||
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
|
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
|
||||||
import {
|
import {
|
||||||
DISABLED_MESSAGE,
|
|
||||||
assertOwnProject,
|
assertOwnProject,
|
||||||
backfillInvoiceLinks,
|
backfillInvoiceLinks,
|
||||||
getFeatureDoc,
|
getFeatureDoc,
|
||||||
|
hakenTexte,
|
||||||
listCustomerFeatures,
|
listCustomerFeatures,
|
||||||
markOrdered,
|
markOrdered,
|
||||||
runActivation,
|
runActivation,
|
||||||
} from '../services/siteFeatures.js'
|
} from '../services/siteFeatures.js'
|
||||||
|
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
|
||||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||||
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
|
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
|
||||||
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
||||||
@@ -120,9 +121,10 @@ router.get('/', requireSession, async (req, res) => {
|
|||||||
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
|
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
|
||||||
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
|
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
|
||||||
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
|
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
|
||||||
|
* Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage).
|
||||||
*/
|
*/
|
||||||
async function openOrderSession(doc) {
|
async function openOrderSession(doc, feature) {
|
||||||
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
||||||
if (!doc.stripeSessionId) return null
|
if (!doc.stripeSessionId) return null
|
||||||
let session
|
let session
|
||||||
try {
|
try {
|
||||||
@@ -137,11 +139,45 @@ async function openOrderSession(doc) {
|
|||||||
throw conflict()
|
throw conflict()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
||||||
|
|
||||||
|
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
|
||||||
|
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem
|
||||||
|
* Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route
|
||||||
|
* antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log.
|
||||||
|
*/
|
||||||
|
async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) {
|
||||||
|
const belehrung = (await haken.belehrung?.()) ?? null
|
||||||
|
const texte = nachweisTexte(feature, { belehrung })
|
||||||
|
try {
|
||||||
|
await speichereZustimmung({
|
||||||
|
customerId,
|
||||||
|
projectId,
|
||||||
|
featureKey: feature.key,
|
||||||
|
stripeSessionId: sessionId,
|
||||||
|
fassungen: zustimmungsfassungen(feature),
|
||||||
|
texte,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
konto: kontoLabel(actingPerson(req.session)),
|
||||||
|
at: klickAt,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
|
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
|
||||||
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
|
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
|
||||||
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
||||||
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
|
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
|
||||||
router.post('/:key/checkout', requireSession, async (req, res) => {
|
router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||||
|
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
|
||||||
|
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
|
||||||
|
const klickAt = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
// 1 Kundenansicht
|
// 1 Kundenansicht
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
@@ -163,7 +199,7 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
|||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
await assertOwnProject(customerId, projectId)
|
await assertOwnProject(customerId, projectId)
|
||||||
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
|
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
|
||||||
throw bestellFehler(400, 'Bitte stimme der Erklärung zu, um zu bestellen.', 'zustimmung_fehlt')
|
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||||
}
|
}
|
||||||
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
|
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
|
||||||
const sperre = featureSperre(key, req.session.email)
|
const sperre = featureSperre(key, req.session.email)
|
||||||
@@ -188,37 +224,49 @@ router.post('/:key/checkout', requireSession, async (req, res) => {
|
|||||||
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
||||||
await pruefeAbo(feature, customerId)
|
await pruefeAbo(feature, customerId)
|
||||||
|
|
||||||
// Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register.
|
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
|
||||||
// Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch Dokument.
|
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||||
if (key !== 'news') {
|
|
||||||
throw bestellFehler(503, 'Die Bestellung ist für diese Funktion noch nicht freigeschaltet.', 'bestellung_nicht_freigeschaltet')
|
|
||||||
}
|
|
||||||
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
|
|
||||||
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
|
||||||
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||||
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung
|
// a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register)
|
||||||
// richtet sich nach ihrer gespeicherten Stripe-Sitzung
|
const existing = await getFeatureDoc(customerId, projectId, key)
|
||||||
const existing = await getFeatureDoc(customerId, projectId, 'news')
|
|
||||||
if (existing?.state === 'setup' || existing?.state === 'active') {
|
if (existing?.state === 'setup' || existing?.state === 'active') {
|
||||||
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
|
||||||
}
|
}
|
||||||
if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 })
|
if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
|
||||||
if (existing?.state === 'ordered') {
|
|
||||||
const open = await openOrderSession(existing)
|
const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt })
|
||||||
if (open) return open
|
|
||||||
|
// b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite
|
||||||
|
const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null
|
||||||
|
if (open) {
|
||||||
|
// d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung)
|
||||||
|
// gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu
|
||||||
|
if (!(await zustimmungFuerSitzung(open.id))) {
|
||||||
|
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||||
|
await nachweis(open.id)
|
||||||
|
}
|
||||||
|
return open
|
||||||
}
|
}
|
||||||
|
// keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu
|
||||||
|
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
|
||||||
|
|
||||||
|
// c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt
|
||||||
const customer = await getDocument(config.collections.customers, customerId)
|
const customer = await getDocument(config.collections.customers, customerId)
|
||||||
const now = new Date().toISOString()
|
const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })
|
||||||
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
|
// d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft
|
||||||
|
// nach 24 h ab, ein Storno ist nicht noetig)
|
||||||
|
await nachweis(created.id)
|
||||||
|
// e erst nach dem Nachweis wird die Bestellung eingetragen
|
||||||
await markOrdered({
|
await markOrdered({
|
||||||
customerId,
|
customerId,
|
||||||
projectId,
|
projectId,
|
||||||
key: 'news',
|
key,
|
||||||
sessionId: created.id,
|
sessionId: created.id,
|
||||||
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
|
consent: { text: hakenTexte(feature), at: klickAt, ip: req.ip, userAgent: req.get('user-agent') },
|
||||||
})
|
})
|
||||||
return created
|
return created
|
||||||
})
|
})
|
||||||
|
// f die URL geht erst jetzt an den Browser
|
||||||
res.json({ url: session.url })
|
res.json({ url: session.url })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
|
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ async function termineBestellpruefung({ customerId, projectId, feature }) {
|
|||||||
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
|
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der
|
||||||
|
* Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion().
|
||||||
|
*/
|
||||||
|
export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
* Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit
|
||||||
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung und B11 freischaltbar. News hat keine
|
* (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht
|
||||||
* Standard-Haken. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
* und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag
|
||||||
|
* im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||||
*/
|
*/
|
||||||
const STANDARD_HAKEN = {
|
const STANDARD_HAKEN = {
|
||||||
news: {},
|
news: {},
|
||||||
@@ -36,6 +43,7 @@ const STANDARD_HAKEN = {
|
|||||||
verfuegbar: (email) => termineFuerKunde(email),
|
verfuegbar: (email) => termineFuerKunde(email),
|
||||||
bestellpruefung: termineBestellpruefung,
|
bestellpruefung: termineBestellpruefung,
|
||||||
unterlagenBereit: async () => true,
|
unterlagenBereit: async () => true,
|
||||||
|
belehrung: async () => BELEHRUNG_PLATZHALTER,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,13 +229,24 @@ export async function paidFeatureOrders(customerId) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wortlaut der Zustimmung am Dokument (consentText): die Texte der Haken-Items der Funktion, durch eine Leerzeile getrennt.
|
||||||
|
* Eine bequeme Kopie fuer das Team; massgeblich ist der vollstaendige Nachweis in featureConsents.
|
||||||
|
*/
|
||||||
|
export function hakenTexte(feature) {
|
||||||
|
return feature.consent.items
|
||||||
|
.filter((item) => item.haken)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bestellung eintragen (Zustand ordered); die Meldungen bei setup/active/disabled stehen im Register der Funktion. */
|
||||||
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
||||||
assertKey(key)
|
assertKey(key)
|
||||||
|
const feature = featureConfig(key)
|
||||||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||||||
if (doc.state === 'setup' || doc.state === 'active') {
|
if (doc.state === 'setup' || doc.state === 'active') throw httpError(409, feature.orderedConflictMessage)
|
||||||
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet')
|
if (doc.state === 'disabled') throw httpError(409, feature.disabledMessage)
|
||||||
}
|
|
||||||
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
|
|
||||||
return updateDocument(config.collections.portalFeatures, doc.$id, {
|
return updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||||
featureKey: key,
|
featureKey: key,
|
||||||
...stateFields('ordered'),
|
...stateFields('ordered'),
|
||||||
|
|||||||
+401
-13
@@ -1,14 +1,17 @@
|
|||||||
import { test, after, beforeEach, mock } from 'node:test'
|
import { test, after, beforeEach, mock } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
import { startFakeStripe } from './support/fakeStripe.js'
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
import { useTestEnv } from './support/testEnv.js'
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
import { schemaKeys } from './support/schema.js'
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
|
||||||
// Checkout je Funktion, Teil 1 (Task B6): POST /api/funktionen/:key/checkout mit den Vorpruefungen in der
|
// Checkout je Funktion (Tasks B6 und B7): POST /api/funktionen/:key/checkout.
|
||||||
// Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, 5b Unterlagen,
|
// Teil 1 (B6): Vorpruefungen in der Reihenfolge der Spezifikation (1 Kundenansicht, 2 Schluessel, 3 Zustimmung, 4 Projekt,
|
||||||
// 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine Stripe-Sitzung, kein
|
// 5 Schalter, 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo). Jede Ablehnung laesst keine Spur zurueck: keine
|
||||||
// featureConsents- und kein portalFeatures-Dokument. Sperre, Zustandspruefung und Nachweis folgen in Task B7.
|
// 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({
|
const aw = await startAppwriteMock({
|
||||||
schema: {
|
schema: {
|
||||||
portalFeatures: schemaKeys('portalFeatures'),
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
@@ -26,6 +29,8 @@ const { default: funktionenRoutes } = await import('../server/routes/funktionen.
|
|||||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
const reg = await import('../server/services/featureRegistry.js')
|
const reg = await import('../server/services/featureRegistry.js')
|
||||||
const order = await import('../server/services/featureOrder.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 })
|
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
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.
|
// ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) ---
|
||||||
// Eine Termine-Bestellung, die alle Vorpruefungen besteht, darf bis dahin weder Sitzung noch Dokument noch Nachweis anlegen;
|
// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen, Block C fuellt sie).
|
||||||
// B7 ersetzt diesen Test durch den Erfolgsfall.
|
const bereit = () => {
|
||||||
test('Termine-Bestellung nach bestandenen Vorpruefungen legt bis Task B7 noch nichts an (503)', async () => {
|
|
||||||
T.bestellpruefung = async () => []
|
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 res = await checkout('termine', bodyT())
|
||||||
assert.equal(res.status, 503)
|
const nach = new Date().toISOString()
|
||||||
assert.equal(res.body.code, 'bestellung_nicht_freigeschaltet')
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||||
keineSpuren()
|
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 ---
|
// ------------------------------------------------------------------------------------------ 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 () => {
|
test('fortsetzen: ohne Dokument oder ausser ordered ist 400 zustimmung_fehlt, es entsteht nichts', async () => {
|
||||||
const fall = { projectId: 'p1', fortsetzen: true }
|
const fall = { projectId: 'p1', fortsetzen: true }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
|
|||||||
|
|
||||||
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
|
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
|
||||||
const aw = await startAppwriteMock({
|
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()
|
const st = await startFakeStripe()
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
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.doesNotMatch(res.body.error, /Invalid document structure|stripeSessionId/)
|
||||||
assert.match(res.body.error, /Bestellung konnte gerade nicht gestartet werden/)
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user