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) })