Ö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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:27:04 +00:00
1 parent a6a4eae294
commit ed99234ae4
4 files changed
+1542 -8

No files matched your search

+532
View File
@@ -0,0 +1,532 @@
// Playwright-Checks des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 26: Hinweis-Leiste, Kontakt-Leiste,
// JSON-LD, Platzhalter) gegen den UI-Testserver (test/ui/zeitenTestServer.mjs): echte oeffentliche Schnittstelle, Nachbau von Appwrite.
// Nicht Teil von npm test.
// Aufruf: free -m && nice node test/ui/zeiten-embed.ui.mjs (es laeuft nur ein Browser)
// Screenshots: /root/reports/oeffnungszeiten-ui/embed-*.png (bei Fehlern fehler-embed-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Seiten: Sie laufen auf srv.pageOrigin (http://localhost:PORT) und sprechen die Schnittstelle unter srv.url (http://127.0.0.1:PORT)
// an: echte Cross-Origin-Anfragen wie auf Kundenseiten. Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (fest, srv.setzeUhr);
// Pruefungen mit Zeitsprung installieren dieselbe Zeit im Browser (context.clock.install) und spulen mit fastForward vor.
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startZeitenUiServer, zeitenEingabe, hinweis, FIRMA, ADRESSE } from './zeitenTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const SHOTS = '/root/reports/oeffnungszeiten-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(embed|fehler-embed)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startZeitenUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/zeiten/v1/${KEY}`
// ---------------------------------------------------------------- Helfer
function assert(cond, msg) { if (!cond) throw new Error(msg) }
function eq(ist, soll, msg) {
const a = JSON.stringify(ist)
const b = JSON.stringify(soll)
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
}
const nahe = (a, b, tol = 1) => Math.abs(a - b) <= tol
const fmt = (z) => (typeof z === 'number' ? z.toFixed(1) : String(z))
const SKRIPT = `<script src="/embed/zeiten.js" data-site="${KEY}" data-api="${srv.url}" defer></script>`
const STATUS = '<p>Status: <span id="status" data-webklar-zeiten data-modus="status">Ersatz-Status</span></p>'
const lang = (n = 40) => Array.from({ length: n }, (_, i) => `<p>Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.</p>`).join('\n')
/** Testseite: kopf = <head>-Zusatz, stil = CSS, anfang = Anfang von <body> (z. B. Kopfzeile), inhalt = <main>. */
function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}<p id="ende">Ende der Seite</p>`, skript = SKRIPT } = {}) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bäckerei Sonnenschein</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#1f2937}main{max-width:760px;margin:0 auto;padding:16px}${stil}</style>${kopf}</head>
<body>${anfang}<main>${inhalt}</main>${skript}</body></html>`
}
const KOPF_FEST = '<header id="kopf" style="position:fixed;top:0;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const KOPF_KLEBT = '<header id="kopf" style="position:sticky;top:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const HOST = (art) => `[data-wkz-leiste="${art}"]`
const anzahl = (page, sel) => page.locator(sel).count()
const hoehe = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().height, sel)
const oben = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().top, sel)
const scrolle = (page, y) => page.evaluate((v) => window.scrollTo(0, v), y)
const shot = async (page, name) => {
await page.waitForTimeout(450) // die Einblendung der Leiste (0,3 s) ist fertig
await page.screenshot({ path: `${SHOTS}/embed-${name}.png` })
}
/** Wartet, bis das Skript die Platzhalter gefuellt hat (und damit in derselben Runde die Leisten gezeichnet). */
const warteBereit = (page) => page.waitForFunction(() => { const s = document.getElementById('status'); return s && !s.textContent.includes('Ersatz') })
/** Wartet auf die Antwort der Schnittstelle und ein paar Bilder danach (fuer Seiten, auf denen nichts gefuellt wird). */
async function warteAufSchnittstelle(page, aktion) {
const antwort = page.waitForResponse((r) => r.url().includes('/api/public/zeiten/v1/'))
await aktion()
await antwort
await page.waitForTimeout(400)
}
const STATUS_OFFEN = 'Jetzt geöffnet · schließt um 18 Uhr'
const STATUS_ZU = 'Geschlossen · öffnet morgen um 9 Uhr'
const statusText = (page) => page.locator('#status').innerText()
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, team, viewport = { width: 1280, height: 800 }, uhr, context = {}, initScript } = {}) {
await srv.zuruecksetzen({ eingabe, team })
if (uhr) srv.setzeUhr(uhr)
const ctx = await browser.newContext({ viewport, ...context })
if (uhr) await ctx.clock.install({ time: new Date(uhr) })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const fehler = []
page.on('pageerror', (e) => fehler.push(e.message))
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
if (initScript) await page.addInitScript(initScript)
try {
await fn(page, ctx)
assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`)
results.push([name, true])
console.log(`ok ${name}`)
} catch (e) {
results.push([name, false])
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
await page.screenshot({ path: `${SHOTS}/fehler-embed-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
const INFO = hinweis('info', 'Heute frische Brezeln aus dem Ofen.')
const HANDY = { width: 390, height: 844 }
// ---------------------------------------------------------------- Pruefungen
async function pruefungen() {
await check('hinweisleiste_zeigt_gueltigen_hinweis', async (page) => {
await page.goto(srv.seite('hinweis.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('hinweis'))
await host.waitFor({ state: 'attached' })
const region = page.getByRole('region', { name: 'Hinweis' })
assert(await region.isVisible(), 'Region "Hinweis" nicht sichtbar')
assert((await region.innerText()).includes('Heute frische Brezeln aus dem Ofen.'), 'Text fehlt')
eq(await region.locator('button, [role=button]').count(), 0, 'Schliessen-Knopf vorhanden')
assert(!(await region.innerText()).includes('Laden schließt'), 'der Hinweis von morgen gilt noch nicht')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(224, 242, 254)', 'rgb(12, 74, 110)'], 'Farben der Art Info')
// erstes Kind von body, im Fluss (schiebt den Inhalt)
assert(await page.evaluate(() => document.body.firstElementChild.hasAttribute('data-wkz-leiste')), 'Host ist nicht das erste Kind von body')
assert(await page.evaluate(() => ['static', 'relative'].includes(getComputedStyle(document.querySelector('[data-wkz-leiste="hinweis"]')).position)), 'Host muss im Fluss stehen')
const h = await hoehe(page, HOST('hinweis'))
assert(h > 20, `Hoehe der Leiste ${fmt(h)}`)
assert(nahe(await oben(page, 'main'), h, 1.5), `main steht bei ${fmt(await oben(page, 'main'))} statt unter der Leiste (${fmt(h)})`)
await shot(page, 'hinweis-desktop')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Nur heute: Laden schließt um 14 Uhr.', { von: '2026-10-06', bis: '2026-10-06' })] }) })
await check('hinweis_wichtig_mit_knopf', async (page) => {
await page.goto(srv.seite('hinweis-wichtig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('Heute Betriebsfeier'), 'der Hinweis mit dem hoechsten Rang gewinnt')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(185, 28, 28)', 'rgb(255, 255, 255)'], 'Farben der Art Wichtig')
const knopf = region.getByRole('link', { name: 'Zur Aktion' })
eq(await knopf.getAttribute('href'), 'https://baeckerei.example/aktion?x=1', 'href')
eq(await knopf.getAttribute('rel'), 'noopener noreferrer', 'rel')
const box = await knopf.boundingBox()
assert(box.height >= 44 && box.width >= 44, `Tippflaeche ${box.width}x${box.height}`)
// sichtbarer Fokus
await knopf.focus()
const fokus = await knopf.evaluate((e) => getComputedStyle(e).outlineStyle)
assert(fokus !== 'none', 'kein sichtbarer Fokus')
await shot(page, 'hinweis-wichtig')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Heute Betriebsfeier: wir schließen um 14 Uhr.', { knopf: { art: 'link', ziel: 'https://baeckerei.example/aktion?x=1', text: 'Zur Aktion' } })] }) })
await check('hinweisleiste_aus', async (page) => {
await page.goto(srv.seite('hinweis-aus.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page) // das Skript hat gelaufen und die Platzhalter gefuellt ...
eq(await anzahl(page, HOST('hinweis')), 0, 'Leiste trotz Team-Schalter')
// ... und nichts an der Seite veraendert
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Kopf angefasst')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top angefasst')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { hinweisleisteAus: true } })
await check('fester_header_rueckt', async (page) => {
await page.goto(srv.seite('fest.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang()}` })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), 'important', 'Prioritaet des Inline-top')
// scroll-padding-top um die Hoehe erhoeht
const sp = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
assert(nahe(sp, h, 1), `scroll-padding-top ${fmt(sp)} statt ${fmt(h)}`)
// halb gescrollt: der Kopf folgt dem unteren Rand der Leiste
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
// weiter gescrollt: wieder bei 0
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
// zurueck nach oben: wieder unter der Leiste
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
await shot(page, 'fester-header')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('fester_header_mit_polster_bleibt', async (page) => {
// Die Seite haelt Platz fuer ihren festen Kopf per body-Polster frei: die Leiste steht erst darunter, der Kopf muss bleiben, wo er ist
await page.goto(srv.seite('polster.html', seitenHtml({ anfang: KOPF_FEST, stil: 'body{padding-top:60px}' })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(nahe(await oben(page, HOST('hinweis')), 60, 1), `Leiste steht bei ${fmt(await oben(page, HOST('hinweis')))} statt unter dem Polster`)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), `der Kopf wurde gerueckt (${fmt(await oben(page, '#kopf'))})`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Inline-top des Kopfes angefasst')
await shot(page, 'fester-header-polster')
await scrolle(page, 200)
await page.waitForTimeout(100)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nach dem Scrollen nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('klebender_header', async (page) => {
await page.goto(srv.seite('klebt.html', seitenHtml({ anfang: KOPF_KLEBT })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `klebender Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
await scrolle(page, 500)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('team_navselektor', async (page) => {
// der Schwebende hat top: 10px (nicht top: 0): nur der Selektor des Teams findet ihn
const seite = seitenHtml({
stil: '.schweber{position:fixed;top:10px;left:16px;z-index:100;background:#222;color:#fff;padding:8px 12px;border-radius:8px}',
anfang: '<nav class="schweber">Menü</nav>',
})
await page.goto(srv.seite('nav.html', seite))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '.schweber'), 10 + h, 1), `Schweber steht bei ${fmt(await oben(page, '.schweber'))} statt ${fmt(10 + h)}`)
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.querySelector('.schweber').getBoundingClientRect().top - 10) < 0.5)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '.schweber' } })
await check('ungueltiger_selektor', async (page) => {
await page.goto(srv.seite('selektor.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leiste ist trotzdem da
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'der feste Kopf wird auch ohne gueltigen Selektor erkannt und gerueckt')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '[[kaputt' } })
await check('hinweis_endet_und_stellt_wieder_her', async (page, ctx) => {
await page.goto(srv.seite('ende.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'Kopf unter der Leiste')
// 23:55 Berlin: der Hinweis gilt bis 24:00 und verschwindet dann von selbst
await ctx.clock.fastForward(6 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'detached' })
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Inline-top des Kopfes nicht zurueckgesetzt')
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Prioritaet nicht zurueckgesetzt')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top nicht zurueckgesetzt')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf wieder bei 0')
assert(nahe(await oben(page, 'main'), 0, 0.5), 'Inhalt rueckt wieder nach oben')
}, { eingabe: zeitenEingabe({ hinweise: [hinweis('info', 'Nur heute: Kuchen zum halben Preis.', { von: '2026-10-05', bis: '2026-10-05' })] }), uhr: '2026-10-05T21:55:00.000Z' })
await check('kontaktleiste_nur_handy', async (page) => {
await page.goto(srv.seite('handy.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
const stil = await host.evaluate((e) => { const c = getComputedStyle(e); return [c.position, c.zIndex, c.bottom] })
eq(stil, ['fixed', '40', '0px'], 'position, z-index, bottom der Kontakt-Leiste')
const knoepfe = host.locator('a')
eq(await knoepfe.count(), 3, 'Anzahl Knoepfe')
eq(await knoepfe.allInnerTexts(), ['Anrufen', 'Route', 'WhatsApp'], 'Beschriftungen')
const hrefs = await knoepfe.evaluateAll((l) => l.map((a) => a.getAttribute('href')))
eq(hrefs[0], 'tel:+491711234567', 'tel:')
assert(hrefs[1].startsWith('https://www.google.com/maps/dir/'), `Route: ${hrefs[1]}`)
assert(hrefs[2].startsWith('https://wa.me/491711234567'), `WhatsApp: ${hrefs[2]}`)
for (const a of await knoepfe.elementHandles()) {
const b = await a.boundingBox()
assert(b.height >= 44 && b.width >= 44, `Tippflaeche ${b.width}x${b.height}`)
eq(await a.getAttribute('rel'), 'noopener noreferrer', 'rel')
}
// Abstandhalter am Ende von body in Hoehe der Leiste, scroll-padding-bottom
const h = await hoehe(page, HOST('kontakt'))
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter')
assert(await page.evaluate(() => document.body.lastElementChild.hasAttribute('data-wkz-abstand')), 'Abstandhalter nicht am Ende von body')
assert(nahe(await hoehe(page, '[data-wkz-abstand]'), h, 1), 'Abstandhalter nicht so hoch wie die Leiste')
assert(nahe(await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom) || 0), h, 1), 'scroll-padding-bottom')
// ganz unten: der letzte Absatz liegt ueber der Leiste
await scrolle(page, 1e6)
await page.waitForFunction(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)
const unten = await page.evaluate(() => document.getElementById('ende').getBoundingClientRect().bottom)
const leisteOben = await oben(page, HOST('kontakt'))
assert(unten <= leisteOben + 0.5, `der letzte Absatz endet bei ${fmt(unten)}, die Leiste beginnt bei ${fmt(leisteOben)}`)
await shot(page, 'kontakt-handy')
// auf breitem Schirm weg (und wieder da: die Abfrage wird beobachtet)
await page.setViewportSize({ width: 1280, height: 800 })
await host.waitFor({ state: 'detached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter blieb stehen')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom), '', 'scroll-padding-bottom nicht zurueckgesetzt')
await page.setViewportSize(HANDY)
await host.waitFor({ state: 'attached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter nach der Rueckkehr')
}, { viewport: HANDY })
await check('kontaktleiste_nicht_auf_dem_desktop', async (page) => {
await page.goto(srv.seite('desktop.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste auf dem Desktop')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter auf dem Desktop')
})
await check('kontaktleiste_aus_per_team', async (page) => {
await page.goto(srv.seite('kontakt-aus.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste trotz Team-Schalter')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter trotz Team-Schalter')
}, { viewport: HANDY, team: { kontaktleisteAus: true } })
await check('kontaktleiste_nur_mit_daten', async (page) => {
await page.goto(srv.seite('kontakt-nur-route.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Route'], 'nur Route (kein Telefon, kein WhatsApp)')
}, { viewport: HANDY, eingabe: zeitenEingabe({ kontakt: { an: true, telefon: '', adresse: ADRESSE } }) })
await check('kontaktleiste_ohne_daten', async (page) => {
srv.ueberschreibe((a) => ({ ...a, kontakt: null })) // der Browser hat die Antwort eines frueheren Abrufs sonst 60 Sekunden im Zwischenspeicher
await page.goto(srv.seite('kontakt-leer.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Leiste ohne Daten')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter ohne Daten')
}, { viewport: HANDY })
await check('eigene_anrufleiste', async (page) => {
const eigene = '<div id="eigene" style="position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #ccc;z-index:50"><a href="tel:+496301123456" style="display:block;line-height:56px;text-align:center">Jetzt anrufen</a></div>'
await page.goto(srv.seite('eigene-leiste.html', seitenHtml({ anfang: eigene })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gezeichnet ...
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste neben einer eigenen Anruf-Leiste')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter neben einer eigenen Anruf-Leiste')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('anruf_im_kopf_stoert_nicht', async (page) => {
// ein tel:-Link in einem festen Kopf OBEN ist keine Anruf-Leiste unten
const kopf = '<header style="position:fixed;top:0;left:0;right:0;height:48px;background:#222;z-index:100"><a href="tel:+496301123456" style="color:#fff;line-height:48px">06301 123456</a></header>'
await page.goto(srv.seite('anruf-kopf.html', seitenHtml({ anfang: kopf })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
}, { viewport: HANDY })
await check('unsichere_daten_werden_nicht_verwendet', async (page) => {
srv.ueberschreibe((a) => ({
...a,
hinweise: [{ art: 'wichtig', text: '<img src=x onerror="window.__x=1">Achtung', knopf: { art: 'link', ziel: 'javascript:window.__y=1', text: 'Los' }, ab: 0, bis: 8.64e15, rang: 4 }],
kontakt: { telefon: '491711234567', routeUrl: 'javascript:window.__z=1', whatsappUrl: 'http://wa.me/491711234567' },
}))
await page.goto(srv.seite('boese.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('<img src=x onerror="window.__x=1">Achtung'), 'Text nicht als Text gezeigt')
eq(await region.locator('img').count(), 0, 'img eingeschleust')
eq(await region.locator('a').count(), 0, 'javascript:-Knopf wurde ein Link')
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Anrufen'], 'nur der Anruf-Knopf bleibt (Route: javascript:, WhatsApp: http:)')
eq(await page.evaluate(() => [window.__x, window.__y, window.__z]), [undefined, undefined, undefined], 'Skript ausgefuehrt')
}, { viewport: HANDY })
await check('jsonld_ergaenzt', async (page) => {
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, telephone: '+49 171 1234567', address: ADRESSE, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA, url: 'https://baeckerei.example/' }
const kaputt = '{ "@type": "Bakery", kaputt'
const kopf = `<script type="application/ld+json">${JSON.stringify(web)}</script><script type="application/ld+json">${kaputt}</script><script type="application/ld+json">${JSON.stringify(bekannt)}</script>`
await page.goto(srv.seite('jsonld.html', seitenHtml({ kopf })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 3, 'Anzahl der JSON-LD-Bloecke (kein zweiter Block fuer ein vorhandenes Unternehmen)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
eq(texte[1], kaputt, 'unparsbarer Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[2])
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten nicht aus der Schnittstelle')
eq(neu.specialOpeningHoursSpecification ?? [], api.jsonld.specialOpeningHoursSpecification, 'Sonderzeiten')
assert(neu.openingHoursSpecification.length === 6, `Mo-Sa = 6 Eintraege (statt ${neu.openingHoursSpecification.length})`)
assert(!JSON.stringify(neu).includes('"Sunday"'), 'alter Sonntag-Eintrag blieb stehen')
eq([neu['@type'], neu.name, neu.telephone, neu.address], ['Bakery', FIRMA, '+49 171 1234567', ADRESSE], 'uebrige Felder des Blocks')
})
await check('jsonld_neuer_block', async (page) => {
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA }
await page.goto(srv.seite('jsonld-neu.html', seitenHtml({ kopf: `<script type="application/ld+json">${JSON.stringify(web)}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 2, 'Anzahl der Bloecke (WebSite + neuer Block)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[1])
eq(neu['@type'], 'LocalBusiness', '@type')
eq(neu['@context'], api.jsonld.neuerBlock['@context'], '@context kommt vom Server')
eq([neu.name, neu.address], [FIRMA, ADRESSE], 'name/address')
eq(neu.url, `${srv.pageOrigin}/`, 'url')
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten')
})
await check('jsonld_ohne_zeiten_bleibt_unberuehrt', async (page) => {
srv.ueberschreibe((a) => ({ ...a, jsonld: null }))
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const text = JSON.stringify(bekannt)
await page.goto(srv.seite('jsonld-null.html', seitenHtml({ kopf: `<script type="application/ld+json">${text}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte, [text], 'ohne Zeiten bleibt das JSON-LD wie es ist (und es kommt kein Block dazu)')
})
await check('aktiv_false_ersatz_bleibt', async (page) => {
srv.setzeFunktion('disabled')
const ld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [] })
const html = seitenHtml({
kopf: `<script type="application/ld+json">${ld}</script>`,
inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="tabelle"><table><caption>Öffnungszeiten</caption><tr><th>Montag</th><td>09:00&#8211;18:00</td></tr></table></div>${lang(5)}`,
})
await warteAufSchnittstelle(page, () => page.goto(srv.seite('aus.html', html)))
assert((await page.locator('#ersatz').innerText()).includes('09:00–18:00'), 'Ersatz-HTML ist weg')
eq(await anzahl(page, '#ersatz table'), 1, 'Ersatz-Tabelle')
eq(await anzahl(page, '#ersatz .wkz-tabelle'), 0, 'Tabelle des Skripts trotz aktiv: false')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste trotz aktiv: false')
eq(await page.evaluate(() => document.querySelector('script[type="application/ld+json"]').textContent), ld, 'JSON-LD veraendert')
eq(await anzahl(page, 'script[type="application/ld+json"]'), 1, 'neuer JSON-LD-Block trotz aktiv: false')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('unbekannter_plan', async (page) => {
const meldungen = []
page.on('console', (m) => { if (m.type() === 'warning') meldungen.push(m.text()) })
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status" data-plan="gibtsnicht">Ersatz-Status</div>${lang(5)}` })
await page.goto(srv.seite('plan.html', html))
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leisten haengen nicht am Plan
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML bei unbekanntem Plan')
assert(meldungen.some((m) => m.includes('gibtsnicht')), `Konsole-Warnung fehlt: ${JSON.stringify(meldungen)}`)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('kein_storage', async (page) => {
await page.goto(srv.seite('storage.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
const s = await page.evaluate(async () => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length, db: (await indexedDB.databases()).length }))
eq(s, { c: '', l: 0, s: 0, db: 0 }, 'Cookies/Storage')
eq(await page.context().cookies(), [], 'Cookies im Browser')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), viewport: HANDY })
await check('doppelt_geladen', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('doppelt.html', seitenHtml({ skript: `${SKRIPT}${SKRIPT}` })))
await warteBereit(page)
await page.waitForTimeout(300)
eq(await anzahl(page, HOST('hinweis')), 1, 'Hinweis-Leisten')
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
eq(anfragen.length, 1, 'Abrufe der Schnittstelle')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('reduzierte_bewegung', async (page) => {
await page.goto(srv.seite('ruhig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
eq(await region.evaluate((e) => getComputedStyle(e).animationName), 'none', 'Animation trotz prefers-reduced-motion')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'reduce' } })
await check('normale_bewegung_hat_einblendung', async (page) => {
await page.goto(srv.seite('bewegt.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.evaluate((e) => getComputedStyle(e).animationName)) !== 'none', 'ohne Praeferenz soll die Leiste einblenden')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'no-preference' } })
await check('status_wechselt_um_18_uhr', async (page, ctx) => {
await page.goto(srv.seite('status.html', seitenHtml()))
await warteBereit(page)
eq(await statusText(page), STATUS_OFFEN, 'Status um 17:55')
await ctx.clock.fastForward(6 * 60 * 1000) // 18:01
await page.waitForFunction((text) => document.getElementById('status').textContent.trim() === text, STATUS_ZU)
eq(await statusText(page), STATUS_ZU, 'Status nach 18 Uhr')
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('neu_laden_nach_zehn_minuten', async (page, ctx) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('neu-laden.html', seitenHtml()))
await warteBereit(page)
eq(anfragen.length, 1, 'ein Abruf beim Laden')
eq(await anzahl(page, HOST('hinweis')), 0, 'noch kein Hinweis')
// das Team stellt einen Hinweis ein; die Server-Uhr steht auf dem neuen Zeitpunkt
await srv.speichere(zeitenEingabe({ hinweise: [INFO] }))
srv.setzeUhr('2026-10-05T16:06:00.000Z')
await ctx.clock.fastForward(11 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(anfragen.length === 2, `zweiter Abruf nach 10 Minuten (Abrufe: ${anfragen.length})`)
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('fetch_wirft_sofort', async (page) => {
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status">Ersatz-Status</div>${lang(3)}` })
await page.goto(srv.seite('fetch-wirft.html', html))
await page.waitForTimeout(500)
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste ohne Daten')
}, { initScript: () => { window.fetch = () => { throw new TypeError('fetch kaputt') } } })
await check('screenshots_handy', async (page) => {
await page.goto(srv.seite('shot.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang(12)}` })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
await page.waitForTimeout(400)
await shot(page, 'handy-hinweis-und-kontakt')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [hinweis('urlaub', 'Betriebsurlaub vom 12. bis 16. Oktober – ab dem 17. sind wir wieder für Euch da.', { knopf: { art: 'anruf', ziel: '06301 1234', text: 'Anrufen' } })] }) })
}
browser = await chromium.launch()
try {
await pruefungen()
} finally {
await browser.close()
await srv.stop()
}
// Nur der Testserver (und kein Dritter) wurde angefragt
const erlaubt = new Set([new URL(srv.url).host, new URL(srv.pageOrigin).host])
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
if (fremd.length) {
results.push(['kein_fremder_host', false])
console.log(`FAIL kein_fremder_host: ${fremd.join(', ')}`)
} else {
results.push(['kein_fremder_host', true])
console.log('ok kein_fremder_host')
}
const failed = results.filter(([, ok]) => !ok)
console.log(`${results.length - failed.length}/${results.length} Pruefungen bestanden`)
process.exit(failed.length ? 1 : 0)
+208
View File
@@ -0,0 +1,208 @@
// 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
}