Files
ClaudeandClaude Opus 5.5 ed99234ae4 Öffnungszeiten: Hinweis-Leiste, Kontakt-Leiste und JSON-LD im Website-Skript
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>
2026-10-05 04:27:04 +00:00

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
}