zeiten.js zeichnet mit denselben Daten wie die Platzhalter eine Hinweis-Leiste (Shadow DOM, erstes Kind von body, rückt feste und klebende Köpfe, scroll-padding-top), eine Kontakt-Leiste nur auf schmalen Schirmen (Abstandhalter, scroll-padding-bottom, entfällt bei eigener Anruf-Leiste) und ergänzt das JSON-LD der Seite (nur die beiden Zeiten-Felder, sonst eigener Block). Alle Adressen aus der Schnittstelle werden im Browser erneut geprüft. Ruling R29: tick mit try/finally, sofort werfender fetch, erst zeichnen dann laden beim Zurückkehren in den Tab. Tests: statische Funktionstests der reinen Funktionen und eine Playwright-Prüfung gegen den UI-Testserver (echte öffentliche Schnittstelle, Nachbau von Appwrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
10 KiB
JavaScript
209 lines
10 KiB
JavaScript
// 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/<name> 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
|
|
}
|