// 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. // // 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'), }, 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 } = 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, 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') // 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') { return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z' } }, /** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); der Kontext-Speicher des Servers wird geleert. */ async setzeFunktion(state) { aw.seed('portalFeatures', srv.featureDoc(state)) 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 } }, /** * 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). */ async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) { await warteAufVersand() for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } 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' }) 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 }