Files
Webklar-Kundenbereich/test/ui/termineTestServer.mjs
T
ClaudeandClaude Sonnet 5.5 4465ac7b94 Terminplaner: Einbau-Skript – Nachbesserungen
429 ohne Schleife (Meldung und Erneut-versuchen-Knopf, Formulare bleiben stehen), Schriftfarbe
auf der Betriebsfarbe mit mindestens 4,5 : 1, AGB-Link aus der oeffentlichen Konfiguration
(betrieb.agbUrl), Haken starten bei geaenderter Auswahl ohne Haken, Fehler ohne Feld nicht
verschluckt, Konfiguration fail closed, ladeNeu fuer Neuladen und neu_laden, Tastatur und
Fokus (Modifikatoren, Monatspfeile, Belehrungstext), Aufraeumen. UI-Testserver: Timer,
offene Verbindungen, Hinweis auf sessionCookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

280 lines
14 KiB
JavaScript

// 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')
//
// 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/<name> 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()
},
/** Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. */
async zuruecksetzen({ eingabe, uhrNeu = true } = {}) {
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())
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
}