// UI-Testserver des Terminplaners (gemeinsam fuer Playwright-Skripte der Aufgaben 19 bis 21): die echten Express-Router // (/api/public/termine/v1, /api/termine, /api/funktionen) und public/ als statische Dateien, dahinter der Nachbau von Appwrite // und Stripe aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1). // // Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import): // import { startTermineUiServer } from './termineTestServer.mjs' // const srv = await startTermineUiServer() // ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.sink.sent, srv.buchungen(), srv.speichere(...) ... // await srv.stop() // erst den Browser schliessen, dann den Server // // Uhr: Die Server-Uhr beginnt bei jetzt (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit) und laeuft dann in Echtzeit weiter, // damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem // ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu. // // Cookies fuer das Portal (Aufgaben 21a/21b): sessionCookie/customerCookie aus test/support/testApp.js und fixtures.js erst NACH dem // Start dieses Servers dynamisch importieren (testApp.js prueft beim Laden, dass die Umgebung auf die Nachbauten zeigt): // const srv = await startTermineUiServer() // const { sessionCookie } = await import('../support/testApp.js') // // Portal (Aufgaben 21a/21b): srv.portalCookie() liefert { name, value, url } fuer addCookies; srv.zuruecksetzen({ ohneEinstellung: true }) // startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen // Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route. // // Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl: // kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift // (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), // srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks), // srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung), // srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die // Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand. // // Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host). // Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit // data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt. import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' import { startAppwriteMock } from '../support/appwriteMock.js' import { startFakeStripe } from '../support/fakeStripe.js' import { mailSink } from '../support/mailSink.js' import { schemaKeys } from '../support/schema.js' import { useTestEnv } from '../support/testEnv.js' const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') export const SITE_KEY = 'abcdefghijklmnop' export const KUNDE = 'c1' export const PROJEKT = 'p1' export const FEATURE = 'f_p1_termine' export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ) // Feste IDs (aus den Mustern r_ + 8 und s_ + 8), damit Skripte sie in Seiten schreiben koennen (data-leistung) export const R_ANNA = 'r_anna0001' export const R_BEN = 'r_ben00002' export const S_HERREN = 's_herren01' export const S_DAMEN = 's_damen001' export const S_KINDER = 's_kinder01' export const S_FAERBEN = 's_faerben1' export const S_KENNEN = 's_kennen01' // ohne Preis, sofort: Knopf "Termin buchen" export const S_ANFRAGE = 's_anfrage1' // ohne Preis, Anfrage: Knopf "Termin anfragen" /** * Eingabe fuer normalisiereEinstellung: Salon (Leistungen der Vorlage "salon", zwei Personen), Mo bis Fr 09:00 bis 12:30 und * 13:30 bis 18:00, Sa 09:00 bis 13:00, So zu; keine Feiertage, kein Vorlauf, 10 offene Termine je E-Mail erlaubt. */ export function salonEingabe(ueberschreibe = {}) { const woche = { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } for (const tag of [2, 3, 4, 5]) woche[tag] = woche[1] const ressourcen = [R_ANNA, R_BEN] const leistung = (id, name, dauer, pufferNach, preisArt, preisCent, buchungsart, beschreibung = '') => ({ id, name, beschreibung, dauer, pufferNach, preisArt, preisCent, buchungsart, ressourcen, }) return { vorlage: 'salon', betrieb: { name: 'Salon Müller', email: 'info@salon-mueller.example', telefon: '06301 1234', adresse: 'Hauptstr. 1, 67722 Winnweiler', kleinunternehmer: false, websiteUrl: 'https://salon-mueller.example', impressumUrl: 'https://salon-mueller.example/impressum', datenschutzUrl: 'https://salon-mueller.example/datenschutz', agbUrl: 'https://salon-mueller.example/agb', }, anrede: 'sie', farbe: '#0f766e', bundesland: 'RP', feiertageGeschlossen: false, ressourcen: [ { id: R_ANNA, name: 'Anna', typ: 'person', plan: woche }, { id: R_BEN, name: 'Ben', typ: 'person', plan: woche }, ], leistungen: [ leistung(S_HERREN, 'Herrenschnitt', 30, 5, 'fest', 2800, 'sofort', 'Waschen, Schneiden und Föhnen.'), leistung(S_DAMEN, 'Damenschnitt', 45, 5, 'ab', 4500, 'sofort', 'Mit Beratung zur Frisur.'), leistung(S_KINDER, 'Kinderschnitt', 30, 5, 'fest', 1800, 'sofort'), leistung(S_FAERBEN, 'Färben', 120, 10, 'ab', 7500, 'sofort'), leistung(S_KENNEN, 'Kennenlern-Beratung', 15, 0, 'keiner', 0, 'sofort', 'Kostenlos und unverbindlich.'), leistung(S_ANFRAGE, 'Brautfrisur', 90, 15, 'keiner', 0, 'anfrage', 'Wir melden uns und bestätigen den Termin.'), ], regeln: { raster: 15, vorlaufMin: 0, horizontTage: 60, absageBisStd: 24, maxOffeneJeEmail: 10, personWaehlbar: true }, gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] }, erinnerung: { anbieten: true, stundenVorher: 24 }, storno: { text: 'Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben. Danach berechnen wir 50 % des Preises.' }, recht: { gaesteSind: 'verbraucher', freizeit: false }, zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec', 'kreditkarte'] }, ...ueberschreibe, } } /** * Startet Nachbauten, Router und Seiten. * @param {object} [opt] * @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr * @param {boolean} [opt.crossOrigin] pageOrigin (localhost) gilt als Herkunft des Portals * @param {(eingabe: object) => object} [opt.anpassen] aendert die Eingabe der Einstellung vor dem Speichern */ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, anpassen = (e) => e } = {}) { const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js') const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), termineClaims: schemaKeys('termineClaims'), termineOutbox: schemaKeys('termineOutbox'), customerViewLog: schemaKeys('customerViewLog'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), }, datetimes: TERMINE_DATETIMES, defaultLimit: 25, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../../server/config.js') const { startTestApp } = await import('../support/testApp.js') const { setMailTransportForTests } = await import('../../server/services/mailer.js') const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js') const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js') const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js') const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js') const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js') const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js') const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js') const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js') const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js') const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js') const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js') const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js') const { hostingDoc, wartungDoc } = await import('../support/fixtures.js') // Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per // route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen. const seiten = new Map() const seitenRouter = express.Router() seitenRouter.get('/:name', (req, res) => { const html = seiten.get(req.params.name) if (html === undefined) return res.status(404).type('text').send('nicht gefunden') return res.set('Cache-Control', 'no-store').type('text/html; charset=utf-8').send(html) }) const app = await startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/ui-seiten': seitenRouter, '/': express.static(PUBLIC_DIR), }) const pageOrigin = app.url.replace('127.0.0.1', 'localhost') const startMs = new Date(jetzt).getTime() let versatz = startMs - Date.now() const uhr = () => new Date(Date.now() + versatz) const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails, formMinSekunden: config.termine.formMinSekunden, minAntwortMs: config.termine.minAntwortMs, publicBaseUrl: config.publicBaseUrl, smtpFrom: config.smtp.from, } const srv = { url: app.url, pageOrigin, SITE_KEY, KUNDE, PROJEKT, FEATURE, aw, st, config, sink: null, uhr, /** Testseite unter pageOrigin/ui-seiten/ bereitstellen; liefert die Adresse. */ seite(name, html) { seiten.set(name, html) return `${pageOrigin}/ui-seiten/${name}` }, /** Zeitpunkt der Server-Uhr verschieben (ms) oder neu setzen. */ schiebeUhr(ms) { versatz += ms }, setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() }, /** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */ featureDoc(state = 'active', extra = {}) { return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z', ...extra } }, /** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); extra: weitere Felder des Dokuments (z. B. disabledReason, disabledAt). Der Kontext-Speicher des Servers wird geleert. */ async setzeFunktion(state, extra = {}) { aw.seed('portalFeatures', srv.featureDoc(state, extra)) resetKontextForTests() }, /** Einstellung speichern: Eingabe fuer normalisiereEinstellung (z. B. salonEingabe({ anrede: 'du' })). Liefert die normalisierte Einstellung. */ async speichere(eingabe = salonEingabe()) { const erg = normalisiereEinstellung(eingabe, { now: uhr() }) if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`) resetKontextForTests() const doc = aw.docs('termineConfig').get(FEATURE) await speichereEinstellung(srv.featureDoc(), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() }) return erg.einstellung }, /** Gespeicherte Einstellung (Objekt) oder null. */ einstellung() { const doc = aw.docs('termineConfig').get(FEATURE) return doc ? JSON.parse(doc.einstellung) : null }, /** Alle Buchungen (Appwrite-Dokumente), alle Mails der Senke (nach warteAufVersand(): die Warteschlange sendet im Hintergrund). */ buchungen() { return [...aw.docs('termineBookings').values()] }, mails() { return srv.sink.sent }, warteAufVersand, /** Nur Grenzen und Zwischenspeicher leeren (zwischen Pruefungen, damit die Grenzen je IP nicht greifen). */ limitsZuruecksetzen() { resetTermineLimitsForTests() resetKontextForTests() }, /** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. Das Portal (dashboard.html) liegt unter srv.url. */ async portalCookie({ email = 'silke@salon-mueller.example', name = 'Silke Müller' } = {}) { const { sessionCookie } = await import('../support/testApp.js') const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name, email }) const i = roh.indexOf('=') return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url } }, /** * Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo), * 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv). */ async abo(zustand = 'wartung') { for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id) const docs = { wartung: [wartungDoc], hosting: [hostingDoc], keins: [], gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }], nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }], }[zustand] if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`) for (const doc of docs) aw.seed('subscriptions', doc) }, /** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */ async kundenansichtCookie({ customerId = KUNDE } = {}) { const { kundenansichtCookie } = await import('../support/fixtures.js') const roh = kundenansichtCookie({ customerId }) const i = roh.indexOf('=') return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url } }, /** * Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe, * gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen * ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }). */ async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) { const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true }) if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`) resetKontextForTests() aw.seed('portalFeatures', srv.featureDoc('off')) const doc = aw.docs('termineConfig').get(FEATURE) await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() }) await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() }) }, /** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */ fragebogen() { const doc = aw.docs('termineConfig').get(FEATURE) return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null }, version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 }, SCHRITTE, /** * Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered * (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit). * Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung. */ async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) { const register = featureConfig('termine') st.checkoutSessions.set(sitzungId, { id: sitzungId, object: 'checkout.session', url: `https://checkout.stripe.test/c/pay/${sitzungId}`, mode: 'payment', customer: null, client_reference_id: KUNDE, subscription: null, metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' }, status: 'open', payment_status: 'unpaid', invoice: null, }) const klickAt = uhr().toISOString() await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } }) if (mitZustimmung) { await speichereZustimmung({ customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', stripeSessionId: sitzungId, fassungen: zustimmungsfassungen(register), texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }), ip: '127.0.0.1', userAgent: 'ui-test', konto: 'Silke Müller ', at: klickAt, }) } resetKontextForTests() return sitzungId }, /** * Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht * geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt). */ async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) { aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' }) await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl }) resetKontextForTests() }, /** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */ async loescheZustimmungen() { for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id) }, /** * Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. * ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen). * entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein * Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer. */ async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) { await warteAufVersand() const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders'] for (const collection of sammlungen) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } st.checkoutSessions.clear() st.calls.length = 0 resetKontextForTests() resetOutboxForTests() resetTermineLimitsForTests() resetPublicTermineForTests() resetTermineRoutesForTests() if (uhrNeu) versatz = startMs - Date.now() setzeUhrPublic(uhr) setzeUhrKunde(uhr) config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] config.termine.formMinSekunden = 0 config.termine.minAntwortMs = 0 config.smtp.from = 'noreply@webklar.com' config.publicBaseUrl = crossOrigin ? pageOrigin : app.url srv.sink = mailSink() setMailTransportForTests(srv.sink) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' }) aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example', ...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}), }) if (entwurf) { await srv.abo('wartung') return } aw.seed('portalFeatures', srv.featureDoc()) if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe())) }, /** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */ async stop() { await warteAufVersand().catch(() => {}) setMailTransportForTests(null) resetPublicTermineForTests() resetTermineRoutesForTests() Object.assign(config.termine, { mode: vorher.mode, pilotEmails: vorher.pilotEmails, formMinSekunden: vorher.formMinSekunden, minAntwortMs: vorher.minAntwortMs }) config.publicBaseUrl = vorher.publicBaseUrl config.smtp.from = vorher.smtpFrom await Promise.all([srv.beendeApp(), aw.close(), st.close()]) }, /** Nur die App (die Seiten und Schnittstellen) beenden; Appwrite-Nachbau bleibt (Test "Server gestoppt"). */ async beendeApp() { if (srv.appBeendet) return srv.appBeendet = true let frist = null const geschlossen = app.close() app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })]) clearTimeout(frist) }, appBeendet: false, } await srv.zuruecksetzen() return srv }