Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation
POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen (503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo (409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter. featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung, Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d568a3798d
commit
ded6bb2a77
5 files changed
+960
-11
No files matched your search
@@ -15,6 +15,8 @@ import {
|
|||||||
runActivation,
|
runActivation,
|
||||||
} from '../services/siteFeatures.js'
|
} from '../services/siteFeatures.js'
|
||||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||||
|
import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
|
||||||
|
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ELIGIBILITY_MESSAGES,
|
ELIGIBILITY_MESSAGES,
|
||||||
@@ -32,9 +34,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
|
|||||||
|
|
||||||
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
|
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
|
||||||
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
|
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
|
||||||
|
// err.body (Checkout-Vorpruefungen: code, consent, offen, grund) geht mit in die Antwort, error bleibt der Text.
|
||||||
function fail(res, err, fallback) {
|
function fail(res, err, fallback) {
|
||||||
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
|
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
|
||||||
if (own) return res.status(err.status).json({ error: err.message })
|
if (own) return res.status(err.status).json({ ...err.body, error: err.message })
|
||||||
console.error('[funktionen]', err.status || '', err.type || '', err.message)
|
console.error('[funktionen]', err.status || '', err.type || '', err.message)
|
||||||
return res.status(500).json({ error: fallback })
|
return res.status(500).json({ error: fallback })
|
||||||
}
|
}
|
||||||
@@ -134,20 +137,62 @@ async function openOrderSession(doc) {
|
|||||||
throw conflict()
|
throw conflict()
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/news/checkout', requireSession, async (req, res) => {
|
// 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,
|
||||||
|
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
|
||||||
|
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
|
||||||
|
router.post('/:key/checkout', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// 1 Kundenansicht
|
||||||
if (req.session.viewAs) {
|
if (req.session.viewAs) {
|
||||||
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
|
||||||
}
|
}
|
||||||
const { projectId, consent } = req.body || {}
|
// 2 Funktionsschluessel (analyse hat einen eigenen Weg und steht nicht im Register)
|
||||||
if (consent !== true) {
|
const key = req.params.key
|
||||||
return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
|
const feature = featureConfig(key)
|
||||||
}
|
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
|
||||||
|
const body = req.body || {}
|
||||||
|
// 3 Zustimmung: alle Pflicht-Haken und die aktuelle Fassung; bei "Zahlung fortsetzen" entfaellt sie (Stufe 4 prueft dafuer ordered)
|
||||||
|
const fortsetzen = istFortsetzen(body)
|
||||||
|
if (!fortsetzen) pruefeZustimmung(feature, body)
|
||||||
|
// 4 Projekt
|
||||||
|
const { projectId } = body
|
||||||
if (typeof projectId !== 'string' || !projectId) {
|
if (typeof projectId !== 'string' || !projectId) {
|
||||||
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
|
||||||
}
|
}
|
||||||
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') {
|
||||||
|
throw bestellFehler(400, 'Bitte stimme der Erklärung zu, um zu bestellen.', 'zustimmung_fehlt')
|
||||||
|
}
|
||||||
|
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
|
||||||
|
const sperre = featureSperre(key, req.session.email)
|
||||||
|
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
|
||||||
|
// 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung)
|
||||||
|
const haken = featureHaken(key)
|
||||||
|
if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) {
|
||||||
|
throw bestellFehler(
|
||||||
|
503,
|
||||||
|
'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||||
|
'unterlagen_fehlen'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7
|
||||||
|
if (haken.bestellpruefung) {
|
||||||
|
const offen = await haken.bestellpruefung({ customerId, projectId, feature })
|
||||||
|
if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern')
|
||||||
|
if (offen.length > 0) {
|
||||||
|
throw bestellFehler(409, 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', 'bestellung_offen', { offen })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
|
||||||
|
await pruefeAbo(feature, customerId)
|
||||||
|
|
||||||
|
// Task B7 ersetzt den Rest: Sperre, Zustand, Stripe-Sitzung, Nachweis und markOrdered je Funktion aus dem Register.
|
||||||
|
// Bis dahin laeuft nur News; fuer andere Funktionen entsteht weder Sitzung noch 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
|
// 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
|
// 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 () => {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { config } from '../config.js'
|
||||||
|
import { getDocument } from './appwriteAdmin.js'
|
||||||
|
import { aboMemo } from './aboStatus.js'
|
||||||
|
import { oeffentlicheAnsicht } from './featureRegistry.js'
|
||||||
|
import { ladeEinstellung } from './termine/kontext.js'
|
||||||
|
|
||||||
|
// Bausteine der Bestell-Vorpruefungen (POST /api/funktionen/:key/checkout, Task B6): Zustimmung, Abo-Hinweistexte,
|
||||||
|
// Entwurf des Terminplaners. Die Route in routes/funktionen.js legt die Reihenfolge fest; jede Stufe hier ist
|
||||||
|
// ohne Seiteneffekt (liest nur, schreibt nie, legt keine Stripe-Sitzung an).
|
||||||
|
|
||||||
|
/** Einhaengepunkt fuer Tests: der Abo-Speicher je Anfrage (Tests zaehlen die Aufrufe, um die Reihenfolge zu pruefen). */
|
||||||
|
export const orderDeps = { aboMemo }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler mit Status, Kundentext und Code. code steht am Fehler und im Antwortkoerper (err.body.code); weitere
|
||||||
|
* Felder fuer den Browser (consent, offen, grund) kommen in body. routes/funktionen.js fail() gibt body weiter.
|
||||||
|
*/
|
||||||
|
export function bestellFehler(status, message, code, extra = {}) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
if (code) {
|
||||||
|
err.code = code
|
||||||
|
err.body = { code, ...extra }
|
||||||
|
} else if (Object.keys(extra).length > 0) {
|
||||||
|
err.body = { ...extra }
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.'
|
||||||
|
const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.'
|
||||||
|
const MAX_HAKEN = 50
|
||||||
|
|
||||||
|
const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt')
|
||||||
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||||
|
const hatWert = (wert) => wert !== undefined && wert !== null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Zahlung fortsetzen" aus der Statuskarte: { projectId, fortsetzen: true } ohne Zustimmung. Enthaelt der Koerper auch
|
||||||
|
* zustimmung (oder die News-Altform consent: true), gilt die Zustimmung und sie wird geprueft.
|
||||||
|
*/
|
||||||
|
export function istFortsetzen(eingabe) {
|
||||||
|
return eingabe?.fortsetzen === true && !hatWert(eingabe.zustimmung) && eingabe.consent !== true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefstufe 3: Sind alle Pflicht-Haken der Funktion gesetzt und passt die Fassung zur Serverfassung?
|
||||||
|
* eingabe ist der Anfragekoerper: { zustimmung: { version, haken: [id, …] } } oder, nur bei Funktionen mit genau einem
|
||||||
|
* Haken-Item (News), die Altform { consent: true }. Gibt die angenommenen Haken-IDs in Registerreihenfolge zurueck.
|
||||||
|
* Wirft 400 zustimmung_fehlt (fehlt, unvollstaendig, unbekannte ID) und 409 fassung_veraltet (err.body.consent ist die
|
||||||
|
* aktuelle Fassung mit allen Texten, wie die Bestellseite sie zeigt). Die Fassung geht vor den Haken: wer einen alten
|
||||||
|
* Wortlaut gesehen hat, soll erst den neuen lesen.
|
||||||
|
*/
|
||||||
|
export function pruefeZustimmung(feature, eingabe) {
|
||||||
|
const ids = feature.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||||||
|
|
||||||
|
if (!hatWert(eingabe?.zustimmung)) {
|
||||||
|
// Altform: ein einziger Haken (News) laesst sich mit consent: true bestaetigen
|
||||||
|
if (eingabe?.consent === true && ids.length === 1) return { akzeptiert: [...ids] }
|
||||||
|
throw fehlt()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!istObjekt(eingabe.zustimmung)) throw fehlt()
|
||||||
|
const { version, haken } = eingabe.zustimmung
|
||||||
|
if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt()
|
||||||
|
if (version !== feature.consent.version) {
|
||||||
|
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent })
|
||||||
|
}
|
||||||
|
if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt()
|
||||||
|
const gesetzt = new Set(haken)
|
||||||
|
if (!ids.every((id) => gesetzt.has(id))) throw fehlt()
|
||||||
|
return { akzeptiert: [...ids] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abo-Hinweistexte (Du-Form, Akkusativ der Funktion aus mails.formen.akk, z. B. "den Terminplaner"). Einzige Quelle:
|
||||||
|
// Bestellseite, Vorschau (Gate-Punkt (h)), Entwurfsrechte und Abo-Abgleich importieren sie von hier.
|
||||||
|
const ABO_TEXTE = {
|
||||||
|
kein_abo: (akk) => `Für ${akk} brauchst du ein laufendes Wartungs-Abo.`,
|
||||||
|
gekuendigt: (akk) => `Dein Wartungs-Abo ist gekündigt. Für ${akk} brauchst du ein laufendes Abo.`,
|
||||||
|
nicht_aktiv: (akk) =>
|
||||||
|
`Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du ${akk} bestellen.`,
|
||||||
|
unbekannt: () => 'Das Abo konnte gerade nicht geprüft werden.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweistext zu einem Abo-Grund.
|
||||||
|
* @param {object} feature Registereintrag (config.features.<key>)
|
||||||
|
* @param {'kein_abo'|'gekuendigt'|'nicht_aktiv'|'unbekannt'} grund jeder andere Wert gilt wie 'unbekannt'
|
||||||
|
*/
|
||||||
|
export function aboMeldung(feature, grund) {
|
||||||
|
const text = Object.hasOwn(ABO_TEXTE, grund) ? ABO_TEXTE[grund] : ABO_TEXTE.unbekannt
|
||||||
|
return text(feature?.mails?.formen?.akk || 'diese Funktion')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
|
||||||
|
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
|
||||||
|
|
||||||
|
async function holeOderNull(collectionId, id) {
|
||||||
|
try {
|
||||||
|
return await getDocument(collectionId, id)
|
||||||
|
} catch (err) {
|
||||||
|
if (dokumentFehlt(err)) return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragebogen aus termineConfig.fragebogen (JSON-Text): nur ein Objekt zaehlt, fehlend oder kaputt ist null
|
||||||
|
function liesFragebogen(text) {
|
||||||
|
if (typeof text !== 'string' || text === '') return null
|
||||||
|
try {
|
||||||
|
const wert = JSON.parse(text)
|
||||||
|
return istObjekt(wert) ? wert : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entwurf des Terminplaners (Einstellung und Fragebogen aus termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
|
||||||
|
* Fehlende oder unlesbare Teile sind null; Lesefehler der Datenbank werden weitergereicht.
|
||||||
|
* @param {string} featureId
|
||||||
|
* @returns {Promise<{ einstellung: object|null, fragebogen: object|null }>}
|
||||||
|
*/
|
||||||
|
export async function ladeEntwurf(featureId) {
|
||||||
|
const [gelesen, doc] = await Promise.all([ladeEinstellung(featureId), holeOderNull(config.collections.termineConfig, featureId)])
|
||||||
|
return { einstellung: gelesen?.einstellung ?? null, fragebogen: liesFragebogen(doc?.fragebogen) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefstufe 7: Wartungs-Abo, wenn die Funktion es verlangt (requiresWartungsAbo). Wirft 409 mit code und body.grund
|
||||||
|
* (kein_abo | gekuendigt | nicht_aktiv, ohne Praefix) und dem Text aus aboMeldung. Ein Lesefehler wirft den Fehler der
|
||||||
|
* Datenbank weiter (die Route antwortet 500 mit allgemeinem Text): im Zweifel nicht bestellbar.
|
||||||
|
*/
|
||||||
|
export async function pruefeAbo(feature, customerId) {
|
||||||
|
if (feature.requiresWartungsAbo !== true) return
|
||||||
|
let ergebnis
|
||||||
|
try {
|
||||||
|
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
|
||||||
|
} catch (err) {
|
||||||
|
// egal mit welchem Status die Datenbank antwortet: 500, die Route zeigt nur ihren allgemeinen Text
|
||||||
|
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
|
||||||
|
}
|
||||||
|
if (ergebnis?.ok === true) return
|
||||||
|
const grund = ergebnis?.grund
|
||||||
|
if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
|
||||||
|
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
|
||||||
|
}
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
|
import { termineFuerKunde } from './termine/schalter.js'
|
||||||
|
|
||||||
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
|
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
|
||||||
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch: Standard-Haken
|
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
|
||||||
// laden ihre Dienste per dynamischem import() innerhalb der Funktion (sonst entstuenden Importzyklen).
|
// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import()
|
||||||
|
// innerhalb der Funktion (sonst entstuenden Importzyklen).
|
||||||
|
|
||||||
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
|
||||||
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
|
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard-Haken je Funktion (Funktionen, nicht Daten). Leer in Task B1; es fuellen sie B6 (verfuegbar,
|
* Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus
|
||||||
* bestellpruefung, unterlagenBereit), B7 (belehrung) und B11 (freischaltbar). News hat keine Standard-Haken.
|
* termineConfig; ohne Dokument beides null) nach termine/fragebogen.js. Ohne abo und mit mitAbo: false: den Abo-Punkt (h)
|
||||||
|
* wendet der Checkout nicht an, die Abo-Stufe (7) der Route liefert kein_abo | gekuendigt | nicht_aktiv.
|
||||||
|
*/
|
||||||
|
async function termineBestellpruefung({ customerId, projectId, feature }) {
|
||||||
|
const [{ getFeatureDoc }, { ladeEntwurf }, { bestellpruefung }] = await Promise.all([
|
||||||
|
import('./siteFeatures.js'),
|
||||||
|
import('./featureOrder.js'),
|
||||||
|
import('./termine/fragebogen.js'),
|
||||||
|
])
|
||||||
|
const doc = await getFeatureDoc(customerId, projectId, 'termine')
|
||||||
|
const { einstellung, fragebogen } = doc ? await ladeEntwurf(doc.$id) : { einstellung: null, fragebogen: null }
|
||||||
|
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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-Haken. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert.
|
||||||
*/
|
*/
|
||||||
const STANDARD_HAKEN = {
|
const STANDARD_HAKEN = {
|
||||||
news: {},
|
news: {},
|
||||||
termine: {},
|
termine: {
|
||||||
|
verfuegbar: (email) => termineFuerKunde(email),
|
||||||
|
bestellpruefung: termineBestellpruefung,
|
||||||
|
unterlagenBereit: async () => true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
|
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
|
||||||
@@ -43,6 +66,21 @@ export function featureHaken(key) {
|
|||||||
return haken
|
return haken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NICHT_VERFUEGBAR = 'Diese Funktion ist noch nicht verfügbar.'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefstufe 5 des Checkouts: Text, warum die Funktion diesem Kunden (noch) nicht angeboten wird, sonst null.
|
||||||
|
* Gefragt wird der Haken verfuegbar(email) (Terminplaner: Schalter TERMINE_ENABLED und Pilotliste); Funktionen ohne
|
||||||
|
* Haken (News) sind immer verfuegbar. Ein unbekannter Schluessel ist nicht verfuegbar (fail-closed).
|
||||||
|
*/
|
||||||
|
export function featureSperre(key, email) {
|
||||||
|
const eintrag = featureConfig(key)
|
||||||
|
if (!eintrag) return NICHT_VERFUEGBAR
|
||||||
|
const { verfuegbar } = featureHaken(key)
|
||||||
|
if (typeof verfuegbar !== 'function' || verfuegbar(email)) return null
|
||||||
|
return eintrag.unavailableMessage || NICHT_VERFUEGBAR
|
||||||
|
}
|
||||||
|
|
||||||
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
|
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,705 @@
|
|||||||
|
import { test, after, beforeEach, mock } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
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.
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: {
|
||||||
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
|
subscriptions: schemaKeys('subscriptions'),
|
||||||
|
featureConsents: schemaKeys('featureConsents'),
|
||||||
|
termineConfig: schemaKeys('termineConfig'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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 { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const reg = await import('../server/services/featureRegistry.js')
|
||||||
|
const order = await import('../server/services/featureOrder.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']) {
|
||||||
|
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, der Platzhalter meldet nichts offen
|
||||||
|
aw.failNext({ collection: 'termineConfig', status: 500 })
|
||||||
|
assert.deepEqual(await haken.bestellpruefung(arg), [])
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
assert.deepEqual(await haken.bestellpruefung(arg), [], 'danach wieder normal')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------ 7 Abo ---
|
||||||
|
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('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 leerer Pruefung (Stub und Standard-Gate): ohne Abo 409 kein_abo, nie bestellung_offen', async () => {
|
||||||
|
const c3 = customerCookie('c3')
|
||||||
|
// Standard-Haken (Platzhalter von Block B meldet nichts offen)
|
||||||
|
aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3), 'kein_abo')
|
||||||
|
// ueberschriebener Haken mit leerer Liste
|
||||||
|
T.bestellpruefung = async () => []
|
||||||
|
aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3), 'kein_abo')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
|
T.bestellpruefung = async () => []
|
||||||
|
const res = await checkout('termine', bodyT())
|
||||||
|
assert.equal(res.status, 503)
|
||||||
|
assert.equal(res.body.code, 'bestellung_nicht_freigeschaltet')
|
||||||
|
keineSpuren()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------------ 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 }
|
||||||
|
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')
|
||||||
|
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
|
||||||
|
const log = stumm()
|
||||||
|
try {
|
||||||
|
doc({ einstellung: '{kaputt', fragebogen: JSON.stringify(fragebogen) })
|
||||||
|
assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung: null, fragebogen })
|
||||||
|
} finally {
|
||||||
|
log.mock.restore()
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
@@ -104,6 +104,20 @@ test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', asy
|
|||||||
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
|
assert.ok(checked >= 18, `nur ${checked} gesperrte Routen geprueft`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Checkout je Funktion (POST /api/funktionen/:key/checkout): die Schleife oben findet die Route ueber :key => x und sperrt sie,
|
||||||
|
// ohne dass sie auf der Erlaubnisliste steht (die Route prueft zusaetzlich selbst req.session.viewAs)
|
||||||
|
test('Checkout je Funktion ist in der Kundenansicht gesperrt (Schleife trifft /api/funktionen/x/checkout, Alias news auch)', async () => {
|
||||||
|
const routes = writeRoutes(MOUNTS)
|
||||||
|
assert.ok(routes.some(({ method, path: p }) => method === 'POST' && p === '/api/funktionen/x/checkout'), 'Route :key/checkout gefunden')
|
||||||
|
for (const key of ['x', 'news', 'termine']) {
|
||||||
|
const p = `/api/funktionen/${key}/checkout`
|
||||||
|
assert.ok(!allowed('POST', p), p)
|
||||||
|
const res = await call('POST', p, { ...kv(), body: { projectId: 'p1', fortsetzen: true } })
|
||||||
|
assert.equal(res.status, 403, p)
|
||||||
|
assert.equal(res.body.code, 'view_blocked', p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
|
test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und Login', () => {
|
||||||
const ok = [
|
const ok = [
|
||||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||||
|
|||||||
Reference in new issue
Block a user