// UI-Testserver der Oeffnungszeiten (fuer test/ui/zeiten-embed.ui.mjs): die echte oeffentliche Schnittstelle // (server/routes/publicZeiten.js samt Dienst, Speicher und Zeitlogik) hinter dem Nachbau von Appwrite aus test/support/, // public/ als statische Dateien (public/embed/zeiten.js) und Testseiten, die der Server selbst ausliefert. 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 { startZeitenUiServer, zeitenEingabe } from './zeitenTestServer.mjs' // const srv = await startZeitenUiServer() // ... srv.url, srv.pageOrigin, srv.SITE_KEY, srv.seite(name, html), srv.zuruecksetzen({ eingabe, team }) ... // await srv.stop() // erst den Browser schliessen, dann den Server // // Seitenherkunft: srv.url ist http://127.0.0.1:PORT (die Schnittstelle), srv.pageOrigin ist http://localhost:PORT (derselbe Server, // anderer Host): Seiten von pageOrigin mit data-api=srv.url sprechen die Schnittstelle als echte Cross-Origin-Anfrage an (CORS `*`, // Date-Kopf sichtbar), so wie Kundenseiten es im Betrieb tun. Die Seiten kommen vom Server selbst und nicht per page.route: Chromium // lehnt Anfragen von Seiten, die Playwright per route.fulfill liefert, an 127.0.0.1 ab (Local Network Access). // // Uhr: Server-Uhr und Date-Kopf der Antworten stehen fest (Vorgabe Montag 2026-10-05 10:00 Berliner Zeit); srv.setzeUhr(datum) stellt sie // um. Der Browser bekommt dieselbe Zeit per context.clock.install({ time: srv.uhr() }); ohne Uhr im Browser rechnet das Skript // ohnehin mit der Serverzeit (Date-Kopf weicht um mehr als 5 Minuten von der lokalen Uhr ab). import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' import { startAppwriteMock } from '../support/appwriteMock.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 = 'abcdabcdabcdabcd' export const KUNDE = 'c_kunde01' export const PROJEKT = 'p_projekt01' export const FEATURE = 'f_p_projekt01_zeiten' export const JETZT = '2026-10-05T08:00:00.000Z' // Montag, 10:00 Berlin (MESZ) export const ADRESSE = 'Hauptstraße 1, 67722 Winnweiler' export const FIRMA = 'Bäckerei Sonnenschein' const P_STANDARD = 'p_aaaaaaa1' const woche = (zeiten = {}) => { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = zeiten[d] ? { modus: 'zeiten', fenster: [zeiten[d]] } : { modus: 'zu', fenster: [] } return t } /** * Eingabe fuer speichereZeiten: ein Plan "standard" (Mo bis Fr 09:00 bis 18:00, Sa 09:00 bis 12:00, So zu), keine Ausnahmen, keine * Hinweise, Kontakt mit Telefon, Adresse und WhatsApp. `ueberschreibe` ersetzt oberste Felder (hinweise, kontakt, ausnahmen, ...). */ export function zeitenEingabe(ueberschreibe = {}) { return { land: 'RP', plaene: [ { id: P_STANDARD, kurz: 'standard', name: 'Öffnungszeiten', haupt: true, tage: woche({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '12:00'] }) }, ], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: true, telefon: '0171 1234567', adresse: ADRESSE, whatsapp: { nummer: '+49 171 1234567', starttext: 'Hallo, ich habe eine Frage!', bestaetigtAt: '2026-10-01T08:00:00.000Z' }, }, ...ueberschreibe, } } /** Ein Hinweis fuer zeitenEingabe({ hinweise: [hinweis(...)] }). */ export function hinweis(art, text, extra = {}) { return { id: `h_${art.slice(0, 4)}${String(text.length).padStart(4, '0')}`, art, text, knopf: null, ...extra } } /** * Startet Nachbau, Router und Seiten. * @param {object} [opt] * @param {string|Date} [opt.jetzt] Startzeit der Server-Uhr */ export async function startZeitenUiServer({ jetzt = JETZT } = {}) { const { ZEITEN_DATETIMES } = await import('../../server/services/zeiten/schema.js') const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: ZEITEN_DATETIMES, defaultLimit: 25, }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../../server/config.js') const { deleteDocument } = await import('../../server/services/appwriteAdmin.js') const { resetZeitenCacheForTests } = await import('../../server/services/zeiten/cache.js') const { speichereZeiten, speichereTeam } = await import('../../server/services/zeiten/speicher.js') const { oeffentlicheZeitenAntwort } = await import('../../server/services/zeiten/oeffentlich.js') const { default: publicZeitenRoutes, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../../server/routes/publicZeiten.js') let uhrMs = new Date(jetzt).getTime() const uhr = () => new Date(uhrMs) setzeUhrFuerTests(uhr) const modusVorher = config.zeiten.mode config.zeiten.mode = 'on' // Testseiten (HTML aus dem Skript) kommen vom Server selbst 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) }) let ueberschreibung = null // (antwort) => antwort: veraendert die echte Antwort vor dem Senden (Pruefungen mit boesen Daten) const app = express() app.disable('x-powered-by') app.use('/ui-seiten', seitenRouter) app.use('/api/public/zeiten/v1', (req, res, next) => { res.setHeader('Date', uhr().toUTCString()) // Serverzeit in der Antwort: das Skript rechnet bei mehr als 5 Minuten Abweichung damit // Die echte Antwort darf der Browser 60 Sekunden behalten (max-age=60). Dessen Zwischenspeicher laeuft nach der echten Uhr, die Pruefungen // spulen aber die Uhr der Seite vor: ein Neuladen nach "10 Minuten" soll deshalb das Netz erreichen, nicht den Zwischenspeicher. const setzeKopf = res.setHeader.bind(res) res.setHeader = (name, wert) => setzeKopf(name, String(name).toLowerCase() === 'cache-control' ? 'no-store' : wert) if (!ueberschreibung || req.method !== 'GET') return next() const siteKey = req.path.replace(/^\//, '') return oeffentlicheZeitenAntwort(siteKey, uhr()).then((antwort) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'Date', 'Cache-Control': 'no-store' }) res.json(ueberschreibung(JSON.parse(JSON.stringify(antwort)))) }, next) }) app.use('/api/public/zeiten/v1', publicZeitenRoutes) app.use('/', express.static(PUBLIC_DIR, { etag: false, lastModified: false, setHeaders: (res) => res.set('Cache-Control', 'no-store') })) const server = await new Promise((resolve, reject) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)) s.on('error', reject) }) const url = `http://127.0.0.1:${server.address().port}` const pageOrigin = url.replace('127.0.0.1', 'localhost') const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'zeiten', siteKey: SITE_KEY, state, enabled: state === 'active' }) const featureRef = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY } const srv = { url, pageOrigin, SITE_KEY, aw, config, uhr, /** Testseite unter pageOrigin/ui-seiten/ bereitstellen; liefert die Adresse. */ seite(name, html) { seiten.set(name, html) return `${pageOrigin}/ui-seiten/${name}` }, /** Server-Uhr (und Date-Kopf) neu setzen; der Zwischenspeicher der Schnittstelle wird geleert. */ setzeUhr(datum) { uhrMs = new Date(datum).getTime() resetZeitenCacheForTests() }, /** Zustand der Funktion aendern: 'active' | 'setup' | 'disabled' | 'off'. */ setzeFunktion(state) { aw.seed('portalFeatures', feature(state)) resetZeitenCacheForTests() }, /** Antwort der Schnittstelle vor dem Senden veraendern (null = echte Antwort): fn(antwort) liefert die neue Antwort. */ ueberschreibe(fn) { ueberschreibung = fn resetZeitenCacheForTests() }, /** Zeiten speichern (Eingabe wie zeitenEingabe) und Team-Einstellungen setzen ({ navSelektor, kontaktleisteAus, hinweisleisteAus }). */ async speichere(eingabe = zeitenEingabe(), team = null) { const doc = aw.docs('oeffnungszeiten').get(FEATURE) await speichereZeiten({ feature: featureRef, eingabe, version: doc?.version ?? 0, now: uhr() }) if (team) { const doc2 = aw.docs('oeffnungszeiten').get(FEATURE) await speichereTeam({ feature: featureRef, eingabe: team, teamVersion: doc2?.teamVersion ?? 0, now: uhr() }) } resetZeitenCacheForTests() }, /** Alles auf Anfang: Dokumente loeschen, Uhr zurueck, Funktion (active), Kunde, Projekt und Zeiten neu anlegen. */ async zuruecksetzen({ eingabe, team = null, uhrNeu = true, state = 'active' } = {}) { for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } resetZeitenCacheForTests() ueberschreibung = null if (uhrNeu) uhrMs = new Date(jetzt).getTime() config.zeiten.mode = 'on' aw.seed('customers', { $id: KUNDE, name: 'Frau Sonne', companyName: FIRMA }) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' }) aw.seed('portalFeatures', feature(state)) await srv.speichere(eingabe ?? zeitenEingabe(), team) }, /** Beendet Server und Nachbau (zuerst den Browser schliessen) und stellt config wieder her. */ async stop() { config.zeiten.mode = modusVorher resetPublicZeitenForTests() server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf await new Promise((resolve) => server.close(resolve)) await aw.close() }, } await srv.zuruecksetzen() return srv }