From 1cd74226cbe45f6f8b84842e4e8417baa37e940a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:48:57 +0000 Subject: [PATCH] Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich PATCH /api/termine/einstellungen schreibt den Entwurf nach eigenen Regeln (withTermine({ schreiben, entwurf }) in routes/termine.js): off und active ja, ordered und setup nur in der Kundenansicht (sonst 409 gesperrt), disabled 409 abgeschaltet wie bisher. In off zusaetzlich laeuft(customerId): ohne Wartungs-Abo 409 kein_abo (Text aus aboMeldung), bei Lesefehler 503 abo_pruefung; eine Kuendigung oder offene Zahlung sperrt nur die Bestellung, nicht das Speichern des Entwurfs. Das Funktionsdokument legt erst der Handler an (ensureFeatureDoc), nach der Eingabepruefung. PATCH normalisiert mit entwurf: state !== 'active' und antwortet mit fehlt. Tests: Zustandsmatrix, Mandanten, Pflichtangaben, Abo, und die Gegenprobe an der oeffentlichen Schnittstelle (Entwurf und Einbauwuensche nie oeffentlich). Co-Authored-By: Claude Sonnet 5.5 --- server/routes/termine.js | 102 +++++-- test/termineFragebogenRoutes.test.js | 412 ++++++++++++++++++++++++++- test/termineRoutes.test.js | 10 +- 3 files changed, 495 insertions(+), 29 deletions(-) diff --git a/server/routes/termine.js b/server/routes/termine.js index 92dd208..899ffa8 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -1,12 +1,13 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' -import { aboMemo } from '../services/aboStatus.js' +import { aboMemo, laeuft } from '../services/aboStatus.js' import { getDocument } from '../services/appwriteAdmin.js' import { analysePrivacyData } from '../services/analysePrivacy.js' import { featureConfig } from '../services/featureRegistry.js' +import { aboMeldung } from '../services/featureOrder.js' import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js' -import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' +import { assertOwnProject, ensureFeatureDoc, getFeatureDoc } from '../services/siteFeatures.js' import { createWriteLimiter } from '../services/writeLimiter.js' import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' @@ -28,7 +29,9 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine * Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder * `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409. * Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne - * Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). + * Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). PATCH /einstellungen + * schreibt den Entwurf nach eigenen Regeln (off und active ja; ordered und setup nur in der Kundenansicht; disabled nie; in off + * nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. * Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in * services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion, * termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail). @@ -65,6 +68,7 @@ const MELDUNG = { abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.', abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.', abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', + gesperrt: 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.', keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.', zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', vorlage: 'Bitte wähle eine Vorlage aus der Liste.', @@ -96,12 +100,16 @@ const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array. /** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */ const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) }) -/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 500 ohne Rohtext. */ +/** + * Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, ebenso das 503 der + * Abo-Prüfung des Entwurfs (`abo_pruefung`: "später noch einmal"); alles andere 500 ohne Rohtext. + */ function fail(res, err) { const status = Number.isInteger(err?.status) ? err.status : 500 const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {} const eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET' - if (!eigen || status < 400 || status >= 500) { + const durchgelassen = (status >= 400 && status < 500) || (status === 503 && err?.code === 'abo_pruefung') + if (!eigen || !durchgelassen) { console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) return res.status(500).json({ error: 'Interner Serverfehler' }) } @@ -157,15 +165,52 @@ const projectIdOf = (req) => { return typeof raw === 'string' ? raw : '' } +/** + * Das Abo konnte nicht gelesen werden: nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten). + * Der Aufrufer entscheidet, was dann gilt (Vorschau: "nicht geprüft", Entwurf schreiben: 503). + */ +const logAboFehler = (err) => console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + +/** + * Schreibregeln des Entwurfs (PATCH /einstellungen), nach dem Zustand des Funktionsdokuments (`feature`, null = kein Dokument = off): + * active ja (die Einstellung läuft; der Abo-Abgleich beendet sie, nicht das Speichern) + * off ja, aber nur mit laufendem Wartungs-Abo (laeuft, nicht bestellbar: eine Kündigung oder offene Zahlung sperrt nur die + * Bestellung, Gate (h), nicht das Speichern des Entwurfs). Ohne Abo 409 `kein_abo`, bei Lesefehler 503 `abo_pruefung`. + * ordered, setup nur in der Kundenansicht (Mitarbeiter); der Kunde selbst bekommt 409 `gesperrt`: Änderungen laufen über den Chat + * disabled 409 `abgeschaltet` (Text von E11), auch in der Kundenansicht + * Ein unbekannter Zustand gilt als nicht aktiv (409). Liest nichts außer dem Abo und schreibt nichts. + */ +async function pruefeEntwurfSchreiben(req, customerId, feature) { + const state = feature ? feature.state : 'off' + if (state === 'active') return + if (state === 'disabled') throw stop(409, abgeschaltetMeldung(feature), 'abgeschaltet') + if (state === 'ordered' || state === 'setup') { + if (isKundenansicht(req.session)) return + throw stop(409, MELDUNG.gesperrt, 'gesperrt') + } + if (state !== 'off') throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv') + + const register = featureConfig('termine') + let laufend + try { + laufend = await laeuft(customerId) + } catch (err) { + logAboFehler(err) + throw stop(503, aboMeldung(register, 'unbekannt'), 'abo_pruefung') + } + if (!laufend) throw stop(409, aboMeldung(register, 'kein_abo'), 'kein_abo') +} + /** * Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active. * Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409). * - * entwurf (TP2, nur GET /einstellungen): Lesen in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, - * disabled) über kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller - * `off` ohne Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und nie etwas angelegt. Projekt (403) - * und Schalter (409) gelten wie sonst. Die Schreibregeln des Entwurfs folgen mit C4b: bis dahin behält jede Route mit - * `schreiben` die Schranke active, auch wenn `entwurf` gesetzt ist. + * entwurf (TP2): Zugriff auf den Entwurf in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, disabled) über + * kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller `off` ohne + * Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und hier nie etwas angelegt (das macht der Handler + * mit ensureFeatureDoc, erst nach der Eingabeprüfung). Projekt (403) und Schalter (409) gelten wie sonst. Ohne `schreiben` + * (GET) liest jeder Zustand; mit `schreiben` gelten die Regeln von pruefeEntwurfSchreiben. Öffentlich wird der Entwurf nie: + * die öffentliche Schnittstelle liest nur Funktionen im Zustand active (kontextPerSiteKey). */ const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async (req, _res, next) => { const projectId = projectIdOf(req) @@ -180,7 +225,8 @@ const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async req.projectId = projectId if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar') const feature = await getFeatureDoc(customerId, projectId, 'termine') - if (entwurf && !schreiben) { + if (entwurf) { + if (schreiben) await pruefeEntwurfSchreiben(req, customerId, feature) req.ctx = feature ? await kontextFuerPortal(feature) : virtuellerKontext(customerId, projectId, req.project) return next() } @@ -249,8 +295,7 @@ async function aboFuerVorschau(customerId) { try { return await aboMemo().bestellbar(customerId) } catch (err) { - // nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten) - console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + logAboFehler(err) return null } } @@ -340,24 +385,39 @@ router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: t res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) }) })) -router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { +/** + * Einstellungen speichern (Version wie beim Lesen, optimistisch gesperrt). Der Entwurf (alles außer active) darf unvollständig + * gespeichert werden: es entfallen nur die Pflichtangaben für Gäste (Telefon, Anschrift), alles andere prüft er wie active; die + * Antwort nennt sie in `fehlt`. In active sind sie Pflicht (400). Ohne Funktionsdokument legt erst dieser Handler es an, nach der + * Eingabeprüfung (ein abgelehnter Entwurf hinterlässt nichts). + */ +router.patch('/einstellungen', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => { const { version, einstellung: eingabe } = req.body ?? {} if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version') const now = uhr() - const feature = req.ctx.feature - - // Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40) - const gespeichert = await speichereEinstellung(feature, async (bisher) => { - const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null - const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now }) + const entwurf = req.ctx.feature.state !== 'active' + const pruefe = (bisher, kuenftigeTermine) => { + const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, entwurf, now }) if (ergebnis.fehler) { throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler }) } return ergebnis.einstellung + } + + let feature = req.ctx.feature + if (!feature.$id) { + pruefe(null, null) + feature = await ensureFeatureDoc(getSessionCustomerId(req), req.projectId, 'termine') + } + + // Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40) + const gespeichert = await speichereEinstellung(feature, async (bisher) => { + const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null + return pruefe(bisher, kuenftigeTermine) }, { version, by: von(req), now }) await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert') - res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version }) + res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) }) })) router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index db88525..f43b779 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -9,8 +9,10 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Entwurfszugriff der Kunden-Schnittstelle /api/termine (TP2, Task C4a): GET /einstellungen liest in JEDEM Zustand des // Funktionsdokuments (off, ordered, setup, active, disabled) und auch ohne Dokument (virtueller Kontext off, nichts wird // angelegt); alle anderen Routen behalten die Schranke active|disabled. Dazu die neuen GET-Felder (branchen, fragebogen, -// kundenansicht, abo, offen, bestellung, betriebVorschlag, einbau/feeds nur in active/disabled). Teil 1: nur Lesen; die -// Schreibregeln des Entwurfs (PATCH, Vorlage, PUT fragebogen) folgen in Teil 1b (C4b). +// kundenansicht, abo, offen, bestellung, betriebVorschlag, einbau/feeds nur in active/disabled). Teil 1: nur Lesen. +// Teil 1b (Task C4b): Schreibregeln des Entwurfs fuer PATCH /einstellungen (off und active ja, ordered und setup nur in der +// Kundenansicht, disabled nie; in off nur mit laufendem Wartungs-Abo), PATCH mit `fehlt`, und die Gegenprobe an der +// oeffentlichen Schnittstelle (ein Entwurf ist nie oeffentlich, Einbauwuensche nie). Vorlage und PUT fragebogen folgen mit C5/C6. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), @@ -33,15 +35,18 @@ const { config } = await import('../server/config.js') const { startTestApp, send } = await import('./support/testApp.js') const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js') const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js') -const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js') +const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js') const { resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js') const { aboMeldung } = await import('../server/services/featureOrder.js') const app = await startTestApp({ '/': kundenansichtGuard, + '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -50,6 +55,7 @@ const T = config.features.termine const JETZT = new Date('2026-10-05T08:00:00.000Z') const MODUS_VORHER = config.termine.mode const MAILS_VORHER = [...config.termine.pilotEmails] +const MIN_ANTWORT_VORHER = config.termine.minAntwortMs const VIEW = 'a'.repeat(32) const SITE_KEY = 'abcdefghijklmnop' const FEATURE = 'f_p1_termine' @@ -99,9 +105,12 @@ beforeEach(async () => { } resetKontextForTests() resetTermineRoutesForTests() + resetPublicTermineForTests() + resetTermineLimitsForTests() setzeUhrFuerTests(() => JETZT) config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] + config.termine.minAntwortMs = 0 mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) @@ -115,6 +124,7 @@ afterEach(() => { mock.restoreAll() config.termine.mode = MODUS_VORHER config.termine.pilotEmails = [...MAILS_VORHER] + config.termine.minAntwortMs = MIN_ANTWORT_VORHER }) // ---------------------------------------------------------------- Zustandsmatrix: GET liest in jedem Zustand @@ -514,3 +524,399 @@ test('betriebVorschlag auch in active und disabled ohne Einstellung (das Konto l assert.equal(body.betriebVorschlag.name, 'Salon Müller', zustand) } }) + +// ================================================================ Teil 1b (C4b): Schreibregeln des Entwurfs + +const PATCH_URL = '/api/termine/einstellungen' +const GESPERRT = 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.' +const FEHLT_BEIDES = ['betrieb.telefon', 'betrieb.adresse'] +const PUBLIC = '/api/public/termine/v1' + +const patch = (body, auth = { cookie }) => send('PATCH', PATCH_URL, { ...auth, body: { projectId: 'p1', ...body } }) +/** Alle Dokumente, die ein Schreibversuch ändern könnte, als Text: gleicher Text = nichts angelegt, nichts geändert. */ +const dokumente = () => + JSON.stringify(['portalFeatures', 'termineConfig', 'customerViewLog', 'featureConsents'].map((c) => [c, [...aw.docs(c).entries()]])) +const logEintraege = () => [...aw.docs('customerViewLog').values()] +const termineConfigDoc = () => aw.docs('termineConfig').get(FEATURE) + +/** Ausgangsstand für einen PATCH: Zustand setzen (oder 'keins') und die Einstellung dazu zurückgeben. */ +const ausgangsstand = (zustand) => setzeZustand(zustand) ?? bestellbarerStand({ now: JETZT }) +const neueEinstellung = (basis, aendere = (e) => e) => { + const e = structuredClone(basis.einstellung) + e.betrieb.name = 'Salon Neu' + return aendere(e) +} +const ohneTelefonUndAnschrift = (e) => { + e.betrieb.telefon = '' + e.betrieb.adresse = '' + return e +} + +// ---------------------------------------------------------------- Zustandsmatrix PATCH + +const ERLAUBT = { keins: 1, off: 2, active: 2, ordered: 2, setup: 2 } // erwartete Version nach dem Speichern + +for (const zustand of ZUSTAENDE) { + for (const [zeile, auth] of ZEILEN) { + const kundenansicht = zeile === 'Kundenansicht' + const gesperrt = (zustand === 'ordered' || zustand === 'setup') && !kundenansicht + const abgeschaltet = zustand === 'disabled' + const erwartet = abgeschaltet ? '409 abgeschaltet' : gesperrt ? '409 gesperrt' : '200' + test(`PATCH einstellungen: Zustand ${zustand}, ${zeile} => ${erwartet}`, async () => { + const basis = ausgangsstand(zustand) + const vorher = dokumente() + const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth()) + + if (abgeschaltet) { + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, 'abgeschaltet') + assert.match(res.body.error, /Wartungs-Abo beendet/, 'der Text von E11') + assert.match(res.body.error, /Ab dem 03\.11\.2026 löschen wir die Daten\./) + assert.equal(dokumente(), vorher, 'nichts geändert, nichts protokolliert') + return + } + if (gesperrt) { + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, 'gesperrt') + assert.equal(res.body.error, GESPERRT) + assert.equal(dokumente(), vorher, 'das Dokument bleibt unverändert') + return + } + + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu') + assert.equal(res.body.version, ERLAUBT[zustand]) + assert.deepEqual(res.body.fehlt, []) + assert.equal(termineConfigDoc().version, ERLAUBT[zustand]) + assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.name, 'Salon Neu') + assert.equal(termineConfigDoc().updatedBy, kundenansicht ? 'team:u_staff2' : 'kunde:c1') + // der Zustand des Funktionsdokuments ändert sich durch das Speichern nie + const feature = aw.docs('portalFeatures').get(FEATURE) + assert.equal(feature.state, zustand === 'keins' ? 'off' : zustand) + assert.equal(feature.enabled, zustand === 'active') + // Protokoll nur in der Kundenansicht, ohne Inhalt der Einstellung + assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), kundenansicht ? [['termin_einstellungen', 'Einstellungen gespeichert']] : []) + }) + } +} + +test('PATCH einstellungen ohne Funktionsdokument: der erste Schreibzugriff legt Dokument (off, siteKey, ohne Kauf) und Einstellung an', async () => { + assert.equal(aw.docs('portalFeatures').size, 0) + const res = await patch({ version: 0, einstellung: neueEinstellung(bestellbarerStand({ now: JETZT })) }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(aw.docs('portalFeatures').size, 1) + const feature = aw.docs('portalFeatures').get(FEATURE) + assert.equal(feature.customerId, 'c1') + assert.equal(feature.projectId, 'p1') + assert.equal(feature.featureKey, 'termine') + assert.equal(feature.state, 'off') + assert.equal(feature.enabled, false) + assert.equal(feature.unlockedByPurchase, false) + assert.equal(feature.purchaseStatus, '') + assert.match(feature.siteKey, /^[a-z2-7]{16}$/) + assert.equal(termineConfigDoc().customerId, 'c1') + assert.equal(termineConfigDoc().projectId, 'p1') + // danach liest GET den Entwurf in off + const gelesen = (await send('GET', einstellungenUrl(), { cookie })).body + assert.equal(gelesen.state, 'off') + assert.equal(gelesen.version, 1) + assert.equal(gelesen.einstellung.betrieb.name, 'Salon Neu') + assert.equal(gelesen.siteKey, feature.siteKey) +}) + +test('PATCH einstellungen: die Antwort trägt Einstellung, Version und fehlt - ohne Feed-Token', async () => { + const basis = ausgangsstand('off') + const res = await patch({ version: 1, einstellung: neueEinstellung(basis) }) + assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'version']) + assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r))) +}) + +// ---------------------------------------------------------------- Mandanten (Review-Focus 3) + +test('Mandanten: Kunde c2 (und seine Kundenansicht) mit dem Projekt von c1 => 403 auf PATCH, in jedem Zustand; nichts angelegt, termineConfig unverändert', async () => { + for (const zustand of ZUSTAENDE) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + const basis = ausgangsstand(zustand) + const vorher = dokumente() + for (const auth of [{ cookie: customerCookie('c2') }, kv({ customerId: 'c2' })]) { + const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth) + assert.equal(res.status, 403, zustand) + assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.') + } + assert.equal(dokumente(), vorher, `${zustand}: nichts angelegt, nichts geändert`) + if (zustand !== 'keins') assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.name, 'Salon Muster', zustand) + } +}) + +test('Mandanten: ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403, Pilot-Schalter 409 - nichts wird angelegt', async () => { + const basis = ausgangsstand('keins') + const eingabe = { version: 0, einstellung: neueEinstellung(basis) } + assert.equal((await send('PATCH', PATCH_URL, { body: { projectId: 'p1', ...eingabe } })).status, 401) + assert.equal((await send('PATCH', PATCH_URL, { cookie, body: eingabe })).status, 400) + assert.equal((await patch({ ...eingabe, projectId: 'gibt es nicht' })).status, 403) + config.termine.pilotEmails = ['andere@example.invalid'] + const gesperrt = await patch(eingabe) + assert.equal(gesperrt.status, 409) + assert.equal(gesperrt.body.code, 'nicht_verfuegbar') + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +// ---------------------------------------------------------------- fehlt: der Entwurf überspringt nur die Pflichtangaben + +for (const zustand of ['keins', 'off']) { + test(`PATCH ${zustand} ohne Telefon und Anschrift => 200 mit fehlt [betrieb.telefon, betrieb.adresse]; GET meldet dasselbe`, async () => { + const basis = ausgangsstand(zustand) + const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis, ohneTelefonUndAnschrift) }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.fehlt, FEHLT_BEIDES) + assert.equal(res.body.einstellung.betrieb.telefon, '') + assert.equal(JSON.parse(termineConfigDoc().einstellung).betrieb.adresse, '') + assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.fehlt, FEHLT_BEIDES) + }) +} + +test('PATCH ohne Telefon und Anschrift: in active 400 (streng), im Entwurf (off, Kundenansicht in ordered) 200 - dieselbe Eingabe', async () => { + const basis = ausgangsstand('active') + const eingabe = neueEinstellung(basis, ohneTelefonUndAnschrift) + const vorher = dokumente() + const streng = await patch({ version: 1, einstellung: eingabe }) + assert.equal(streng.status, 400, JSON.stringify(streng.body)) + assert.deepEqual(Object.keys(streng.body.fehler).sort(), [...FEHLT_BEIDES].sort()) + assert.match(streng.body.error, /Telefonnummer/) + assert.equal(dokumente(), vorher, 'nichts gespeichert') + + await deleteDocument('portalFeatures', FEATURE) + await deleteDocument('termineConfig', FEATURE) + setzeZustand('ordered', { stand: basis }) + const entwurf = await patch({ version: 1, einstellung: eingabe }, kv()) + assert.equal(entwurf.status, 200, JSON.stringify(entwurf.body)) + assert.deepEqual(entwurf.body.fehlt, FEHLT_BEIDES) +}) + +test('PATCH ungültige Angaben bleiben im Entwurf 400 (nur die Pflichtangaben entfallen), ohne Dokument entsteht nichts', async () => { + for (const zustand of ['keins', 'off']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + const basis = ausgangsstand(zustand) + const vorher = dokumente() + const version = zustand === 'keins' ? 0 : 1 + const url = await patch({ version, einstellung: neueEinstellung(basis, (e) => ({ ...e, betrieb: { ...e.betrieb, websiteUrl: 'kein link' } })) }) + assert.equal(url.status, 400, `${zustand}: ${JSON.stringify(url.body)}`) + assert.ok(url.body.fehler['betrieb.websiteUrl'], zustand) + const farbe = await patch({ version, einstellung: neueEinstellung(basis, (e) => ({ ...e, farbe: 'rot' })) }) + assert.equal(farbe.status, 400, zustand) + assert.ok(farbe.body.fehler.farbe, zustand) + const keinObjekt = await patch({ version, einstellung: 'text' }) + assert.equal(keinObjekt.status, 400, zustand) + for (const falsch of [undefined, -1, 1.5, '1', null]) { + const res = await patch({ version: falsch, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 400, `${zustand} version ${String(falsch)}`) + assert.ok(res.body.fehler.version) + } + assert.equal(dokumente(), vorher, `${zustand}: ein abgelehnter Entwurf legt nichts an und ändert nichts`) + } +}) + +test('PATCH mit veralteter Version im Entwurf => 409 EINSTELLUNG_VERALTET, der gespeicherte Stand bleibt', async () => { + const basis = ausgangsstand('off') + const vorher = dokumente() + const res = await patch({ version: 0, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'EINSTELLUNG_VERALTET') + assert.equal(dokumente(), vorher) +}) + +// ---------------------------------------------------------------- Abo: ein Entwurf entsteht nur mit laufendem Wartungs-Abo + +const KEIN_ABO = aboMeldung(T, 'kein_abo') + +for (const zustand of ['keins', 'off']) { + for (const [zeile, auth] of ZEILEN) { + test(`Abo: ${zustand}, ${zeile}: nur Hosting oder gar kein Abo => PATCH 409 kein_abo mit dem Text aus aboMeldung; kein Dokument entsteht, nichts ändert sich`, async () => { + const basis = ausgangsstand(zustand) + const vorher = dokumente() + assert.equal(KEIN_ABO, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.') + for (const vorbereitung of [ + () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })), // nur Hosting + () => deleteDocument('subscriptions', 'sub_h'), // gar keins + ]) { + await vorbereitung() + const res = await patch({ version: zustand === 'keins' ? 0 : 1, einstellung: neueEinstellung(basis) }, auth()) + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, 'kein_abo') + assert.equal(res.body.error, KEIN_ABO) + assert.equal(dokumente(), vorher) + } + if (zustand === 'keins') { + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) + } + }) + } +} + +test('Abo: vorgemerkte Kündigung oder offene Zahlung (past_due) sperren den Entwurf nicht - PATCH bleibt 200 (die Bestellung sperrt Gate (h))', async () => { + const basis = ausgangsstand('off') + let version = 1 + for (const aenderung of [{ cancelAtPeriodEnd: true }, { cancelAt: '2026-11-30T00:00:00.000Z' }, { status: 'past_due' }, { status: 'trialing' }]) { + wartung(aenderung) + const res = await patch({ version, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 200, `${JSON.stringify(aenderung)}: ${JSON.stringify(res.body)}`) + version = res.body.version + } + // dasselbe ohne Dokument: der Entwurf entsteht auch bei vorgemerkter Kündigung + await deleteDocument('portalFeatures', FEATURE) + await deleteDocument('termineConfig', FEATURE) + wartung({ cancelAtPeriodEnd: true }) + assert.equal((await patch({ version: 0, einstellung: neueEinstellung(basis) })).status, 200) + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') +}) + +test('Abo: ein beendetes Wartungs-Abo (canceled) ist kein laufendes => PATCH 409 kein_abo', async () => { + const basis = ausgangsstand('off') + wartung({ status: 'canceled', endedAt: '2026-10-01T00:00:00.000Z' }) + const res = await patch({ version: 1, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'kein_abo') +}) + +for (const zustand of ['keins', 'off']) { + test(`Abo: Lesefehler (${zustand}) => PATCH 503 abo_pruefung ohne Appwrite-Text; nichts angelegt oder geändert; der nächste Versuch geht`, async () => { + const basis = ausgangsstand(zustand) + const vorher = dokumente() + const version = zustand === 'keins' ? 0 : 1 + aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) + const res = await patch({ version, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 503, JSON.stringify(res.body)) + assert.equal(res.body.code, 'abo_pruefung') + assert.equal(res.body.error, aboMeldung(T, 'unbekannt')) + assert.ok(!JSON.stringify(res.body).includes('geheimer')) + assert.ok(console.error.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log') + assert.equal(dokumente(), vorher) + assert.equal((await patch({ version, einstellung: neueEinstellung(basis) })).status, 200, 'nichts wird gemerkt') + }) +} + +test('Abo: gelesen wird nur in off (und ohne Dokument); in active und in der Kundenansicht von ordered und setup gibt es keine Abfrage, und es geht auch ohne Abo', async () => { + await deleteDocument('subscriptions', 'sub_h') + for (const [zustand, auth] of [['active', { cookie }], ['ordered', kv()], ['setup', kv()]]) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + const basis = ausgangsstand(zustand) + aw.resetListCalls() + const res = await patch({ version: 1, einstellung: neueEinstellung(basis) }, auth) + assert.equal(res.status, 200, `${zustand}: ${JSON.stringify(res.body)}`) + assert.deepEqual(aw.listCalls('subscriptions'), [], zustand) + } + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + wartung() + setzeZustand('off') + aw.resetListCalls() + assert.equal((await patch({ version: 1, einstellung: neueEinstellung(bestellbarerStand({ now: JETZT })) })).status, 200) + assert.equal(aw.listCalls('subscriptions').length, 1, 'in off genau eine Abfrage') +}) + +test('Schreibregeln: die anderen Schreib-Routen behalten die Schranke active (Vorlage und Feed-Link im Entwurf bleiben 409 "nicht aktiv")', async () => { + for (const zustand of ['keins', 'off', 'ordered', 'setup']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const vorher = dokumente() + for (const [method, pfad, body] of [ + ['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p1', vorlage: 'salon' }], + ['POST', '/api/termine/ressourcen/r_aaaaaaa1/feed', { projectId: 'p1' }], + ]) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${zustand} ${pfad}`) + assert.equal(res.body.code, 'nicht_aktiv') + } + assert.equal(dokumente(), vorher) + } +}) + +// ---------------------------------------------------------------- öffentlich: ein Entwurf ist nie sichtbar + +const feedToken = (stand) => stand.einstellung.ressourcen[0].feedToken +/** Frisch lesen: der Speicher der öffentlichen Schnittstelle (30 s je siteKey) darf zwischen zwei Zuständen nichts merken. */ +const oeffentlichConfig = (siteKey = SITE_KEY) => { + resetKontextForTests() + return send('GET', `${PUBLIC}/${siteKey}/config`) +} +const oeffentlichBuchung = (siteKey = SITE_KEY) => { + resetKontextForTests() + return send('POST', `${PUBLIC}/${siteKey}/buchungen`, { body: { name: 'Gast', email: 'gast@example.invalid' }, headers: { origin: app.url } }) +} +const oeffentlichKalender = (token, siteKey = SITE_KEY) => { + resetKontextForTests() + return send('GET', `${PUBLIC}/kalender/${siteKey}/${token}.ics`) +} + +for (const zustand of ['off', 'ordered', 'setup', 'disabled']) { + test(`öffentlich: ${zustand} mit vollständiger Einstellung => config und Buchung { aktiv: false }, Kalender-Link mit dem richtigen Token 404`, async () => { + const stand = setzeZustand(zustand) + assert.deepEqual(stand.einstellung.betrieb.telefon !== '' && stand.einstellung.betrieb.adresse !== '', true, 'die Einstellung ist vollständig') + const config_ = await oeffentlichConfig() + assert.equal(config_.status, 200) + assert.deepEqual(config_.body, { aktiv: false }) + const buchung = await oeffentlichBuchung() + assert.equal(buchung.status, 200, JSON.stringify(buchung.body)) + assert.deepEqual(buchung.body, { aktiv: false }) + const kalender = await oeffentlichKalender(feedToken(stand)) + assert.equal(kalender.status, 404) + assert.ok(!String(kalender.body).includes('BEGIN:VCALENDAR')) + }) +} + +test('öffentlich: ein Entwurf, der per PATCH gespeichert wurde, bleibt unsichtbar - erst die Aktivierung macht ihn sichtbar', async () => { + const basis = ausgangsstand('keins') + const res = await patch({ version: 0, einstellung: neueEinstellung(basis) }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const siteKey = aw.docs('portalFeatures').get(FEATURE).siteKey // beim ersten Schreiben vergeben + const token = JSON.parse(termineConfigDoc().einstellung).ressourcen[0].feedToken + assert.deepEqual((await oeffentlichConfig(siteKey)).body, { aktiv: false }) + assert.deepEqual((await oeffentlichBuchung(siteKey)).body, { aktiv: false }) + assert.equal((await oeffentlichKalender(token, siteKey)).status, 404) + + // Aktivierung (Team): ab jetzt gilt dieselbe Einstellung öffentlich + await updateDocument('portalFeatures', FEATURE, { state: 'active', enabled: true }) + const aktiv = await oeffentlichConfig(siteKey) + assert.equal(aktiv.body.aktiv, true, JSON.stringify(aktiv.body)) + const buchung = await oeffentlichBuchung(siteKey) + assert.notDeepEqual(buchung.body, { aktiv: false }, 'die Buchung wird jetzt geprüft (Formular-Token fehlt), nicht als inaktiv abgewiesen') + assert.equal(buchung.status, 400) + const kalender = await oeffentlichKalender(token, siteKey) + assert.equal(kalender.status, 200) + assert.match(kalender.body, /BEGIN:VCALENDAR/) +}) + +test('öffentlich: active mit gespeicherter Einstellung geht (Gegenprobe zu off, ordered, setup), auch der Kalender-Link', async () => { + const stand = setzeZustand('active') + const aktiv = await oeffentlichConfig() + assert.equal(aktiv.body.aktiv, true, JSON.stringify(aktiv.body)) + assert.equal((await oeffentlichKalender(feedToken(stand))).status, 200) + assert.equal((await oeffentlichKalender('x'.repeat(43))).status, 404, 'ein falsches Token bleibt 404') +}) + +test('Einbauwünsche nie öffentlich: fragebogen.einbau.stelle und knopfText stehen in keiner öffentlichen Antwort', async () => { + const stand = bestellbarerStand({ now: JETZT }) + stand.fragebogen.einbau = { modus: 'knopf', knopfText: 'GEHEIMKNOPF', stelle: 'GEHEIMWUNSCH', gestaltung: 'GEHEIMDESIGN', bemerkung: 'GEHEIMNOTIZ' } + setzeZustand('active', { stand }) + const res = await oeffentlichConfig() + assert.equal(res.body.aktiv, true, 'die Funktion ist aktiv, die Antwort enthält also Daten') + const text = JSON.stringify(res.body) + for (const verboten of ['GEHEIMWUNSCH', 'GEHEIMKNOPF', 'GEHEIMDESIGN', 'GEHEIMNOTIZ', 'fragebogen', 'einbau', 'knopfText']) { + assert.ok(!text.includes(verboten), `"${verboten}" darf in der öffentlichen Config nicht stehen`) + } + // der Kunde selbst sieht seinen Entwurf natürlich weiter + assert.equal((await send('GET', einstellungenUrl(), { cookie })).body.fragebogen.einbau.stelle, 'GEHEIMWUNSCH') + // ... und derselbe Stand in den Entwurfszuständen ist öffentlich gar nicht erst erreichbar + for (const zustand of ['off', 'ordered', 'setup']) { + await deleteDocument('portalFeatures', FEATURE) + await deleteDocument('termineConfig', FEATURE) + setzeZustand(zustand, { stand }) + assert.deepEqual((await oeffentlichConfig()).body, { aktiv: false }, zustand) + } +}) diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 07908bd..0fa500b 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -246,7 +246,7 @@ test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegba assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409) }) -test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), alles andere bleibt 409 "nicht aktiv"', async () => { +test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), Vorlage, Feed-Link, Buchungen und Zeiten bleiben 409 "nicht aktiv"', async () => { // Lesen der Einstellungen: in jedem Zustand und auch ohne Dokument (Zustandsmatrix: test/termineFragebogenRoutes.test.js) for (const projectId of ['p2', 'p5']) { const lesen = await send('GET', einstellungenUrl(projectId), { cookie }) @@ -254,13 +254,12 @@ test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen assert.equal(lesen.body.state, 'off', projectId) assert.equal(lesen.body.einstellung, null, projectId) assert.equal(lesen.body.version, 0, projectId) - // alles andere, auch das Schreiben der Einstellungen (Schreibregeln des Entwurfs folgen in C4b) + // alles andere (PATCH der Einstellungen im Entwurf: Schreibregeln in test/termineFragebogenRoutes.test.js, Teil 1b) for (const [method, pfad, body] of [ ['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`], ['GET', `/api/termine/zeiten?projectId=${projectId}`], ['GET', `/api/termine/export.csv?projectId=${projectId}`], ['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }], - ['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }], ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }], ['POST', '/api/termine/buchungen', { projectId, art: 'sperre' }], ]) { @@ -415,7 +414,7 @@ test('R55: Betrieb nur mit Anfrage-Leistungen (Privatpersonen) braucht die Ansch const komplett = { ...nurTelefon, betrieb: { ...nurTelefon.betrieb, adresse: 'Weg 1, 12345 Ort' } } const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } }) assert.equal(ok.status, 200, JSON.stringify(ok.body)) - assert.deepEqual(ok.body.fehlt ?? [], []) + assert.deepEqual(ok.body.fehlt, []) const config = await oeffentlich(SCHLUESSEL) assert.equal(config.aktiv, true) assert.equal(config.widerruf, true) @@ -460,7 +459,8 @@ test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (a neu.ressourcen.push({ name: 'Clara', typ: 'person', plan: { 2: [['09:00', '12:00']] } }) const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: vorher.version, einstellung: neu } }) assert.equal(res.status, 200, JSON.stringify(res.body)) - assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'version']) + assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'version']) + assert.deepEqual(res.body.fehlt, []) assert.equal(res.body.version, 2) assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu') assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))