Terminplaner: Einbau-Skript, Demo und Einbauanleitung

public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog,
Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url,
Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit
Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer
Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent bb45d35ec7
commit 5fef7e47f0
6 files changed
+2825

No files matched your search

+852
View File
@@ -0,0 +1,852 @@
// Playwright-Checks des Terminplaner-Widgets public/embed/termine.js gegen den UI-Testserver (test/ui/termineTestServer.mjs):
// echte Router, Nachbau von Appwrite/Stripe, Mail-Senke. Nicht Teil von npm test.
// Aufruf: nice node test/ui/termine-widget.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/widget-*.png (bei Fehlern fehler-widget-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Seiten: Das Widget laeuft auf Seiten von srv.pageOrigin (http://localhost:PORT) und spricht die Schnittstelle unter
// srv.url (http://127.0.0.1:PORT) an: echte Cross-Origin-Anfragen mit Preflight. Die Demo-Seite (/embed/termine-demo.html)
// laeuft ohne data-api (gleiche Herkunft). Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (laeuft in Echtzeit weiter).
import http from 'node:http'
import { mkdir, readdir, readFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
startTermineUiServer, salonEingabe,
R_ANNA, S_DAMEN, S_HERREN, S_KENNEN,
} from './termineTestServer.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/terminplaner-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(widget|fehler-widget)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const WIDGET_JS = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public/embed/termine.js')
const srv = await startTermineUiServer({ crossOrigin: true })
const KEY = srv.SITE_KEY
const SEITE = srv.pageOrigin
const API = '**/api/public/termine/v1/**'
// ---------------------------------------------------------------- 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 eingebettet = (attrs = '', fallback = '<p id="ausweich">Termine telefonisch unter 06301 1234.</p>', key = KEY) =>
`<div data-webklar-termine="${key}" data-api="${srv.url}" ${attrs}>${fallback}</div>`
const seitenHtml = (widgets, { head = '', style = '', skript = '<script src="/embed/termine.js" defer></script>' } = {}) => `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Testseite</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937}main{max-width:760px;margin:0 auto;padding:24px 16px}${style}</style>${head}</head>
<body><main><h1>Testseite</h1>${widgets}</main>${skript}</body></html>`
/** Seite von srv.pageOrigin mit eigenem Inhalt (liefert der Testserver selbst unter /ui-seiten/). */
async function oeffne(page, html, name = 'testseite.html') {
await page.goto(srv.seite(name, html))
}
const shot = (page, name) => page.screenshot({ path: `${SHOTS}/widget-${name}.png`, fullPage: true })
const w = (page, i = 0) => page.locator('[data-webklar-termine]').nth(i)
const titel = (wd) => wd.locator('h2.wkt-titel').first()
const karte = (wd, text) => wd.locator('.wkt-karte', { hasText: text })
const tag = (wd, t) => wd.locator(`td[data-tag="${t}"]`)
const zeit = (wd, z) => wd.locator('.wkt-zeit', { hasText: new RegExp(`^${z}$`) })
const warteTitel = (wd, text) => wd.locator('h2.wkt-titel', { hasText: text }).waitFor()
/** Leistung, Person, Tag, Uhrzeit, Angaben ausfuellen bis zur Zusammenfassung. Fehlt ein Wert (null), entfaellt der Schritt. */
async function bisZusammenfassung(page, o = {}) {
const wd = w(page, o.widget ?? 0)
const v = { leistung: 'Damenschnitt', person: 'Egal', tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o }
if (v.leistung) await karte(wd, v.leistung).click()
if (v.person) await karte(wd, v.person).click()
await tag(wd, v.tag).click()
await zeit(wd, v.zeit).click()
await wd.locator('input[name=name]').fill(v.name)
await wd.locator('input[name=email]').fill(v.email)
if (v.telefon) await wd.locator('input[name=telefon]').fill(v.telefon)
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
return wd
}
async function hakenUndBuchen(wd, knopf) {
for (const n of ['storno', 'vorzeitig']) {
const cb = wd.locator(`input[name=${n}]`)
if (await cb.count()) await cb.check()
}
await wd.getByRole('button', { name: knopf }).click()
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
}
/** Kein Streifen rechts: Seite und Widget passen in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, context = {}, initScript } = {}) {
await srv.zuruecksetzen({ eingabe })
const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context })
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-widget-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
// ---------------------------------------------------------------- 1. Inline bis "Fast geschafft"
async function pruefungen() {
await check('inline_bis_fast_geschafft', async (page, ctx) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
assert(await page.locator('#ausweich').count() === 0, 'Ausweichtext blieb stehen, obwohl das Widget lief')
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Titel Schritt 1')
assert((await wd.innerText()).includes('Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'), 'Zahlungsart fehlt im ersten Schritt')
assert((await karte(wd, 'Damenschnitt').innerText()).includes('ab 45,00 €'), 'Preis "ab" fehlt')
assert((await karte(wd, 'Damenschnitt').innerText()).includes('45 Minuten'), 'Dauer fehlt')
await shot(page, 'inline-1-leistung')
await karte(wd, 'Damenschnitt').click()
await warteTitel(wd, 'Bei wem')
assert(await karte(wd, 'Egal').count() === 1 && await karte(wd, 'Anna').count() === 1 && await karte(wd, 'Ben').count() === 1, 'Personenwahl: Egal, Anna, Ben')
assert((await wd.locator('.wkt-karte').first().innerText()).startsWith('Egal'), '"Egal" muss die erste Option sein')
await shot(page, 'inline-2-person')
await karte(wd, 'Egal').click()
await warteTitel(wd, 'Wann passt es Ihnen?')
await wd.locator('td.wkt-tag').first().waitFor()
eq(await wd.locator('.wkt-monat').innerText(), 'Oktober 2026', 'Monat')
eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist gesperrt')
eq(await tag(wd, '2026-10-12').getAttribute('aria-disabled'), 'false', 'Montag ist frei')
eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'false', 'noch nichts gewaehlt')
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'true', 'gewaehlter Tag')
eq(await wd.locator('.wkt-zeiten-titel').innerText(), 'Freie Zeiten am Montag, 12.10.2026', 'Titel der Uhrzeiten')
const zeiten = await wd.locator('.wkt-zeit').allInnerTexts()
eq(zeiten.slice(0, 3), ['09:00', '09:15', '09:30'], 'erste Uhrzeiten (Berliner Wanduhr)')
assert(!zeiten.includes('12:30') && zeiten.includes('13:30'), 'Mittagspause (12:30 bis 13:30) muss fehlen')
await shot(page, 'inline-3-termin')
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.locator('input[name=telefon]').fill('0171 1234567')
await shot(page, 'inline-4-daten')
await wd.getByRole('button', { name: 'Weiter' }).click()
await warteTitel(wd, 'Bitte prüfen Sie Ihre Buchung')
const zus = await wd.locator('.wkt-zusammen').innerText()
for (const t of ['Damenschnitt (45 Minuten)', 'Montag, 12.10.2026', '10:00 Uhr', 'ab 45,00 € (inkl. USt)', 'Salon Müller, Hauptstr. 1, 67722 Winnweiler', 'Max Mustermann', 'max@example.com']) {
assert(zus.replace(/\s+/g, ' ').includes(t), `Zusammenfassung ohne "${t}": ${zus}`)
}
assert((await wd.locator('.wkt-box').innerText()).includes('Danach berechnen wir 50 % des Preises.'), 'Stornoregel im Klartext fehlt')
const det = wd.locator('details.wkt-belehrung')
assert(await det.count() === 1 && await det.evaluate((d) => !d.open), 'Widerrufsbelehrung muss aufklappbar und zu sein')
await det.locator('summary').click()
assert((await det.innerText()).includes('Widerrufsbelehrung') && (await det.innerText()).includes('Muster-Widerrufsformular'), 'Belehrung nicht lesbar')
await det.locator('summary').click()
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden Ihre Angaben'), 'Datenschutz-Hinweis')
eq(await wd.locator('.wkt-datenschutz a').getAttribute('rel'), 'noopener noreferrer', 'rel am Datenschutz-Link')
// Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig')
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung')
assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein')
assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken')
assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig')
eq(await wd.locator('.wkt-form button[type=submit]').innerText(), 'Zahlungspflichtig buchen', 'Knopftext aus der Konfiguration')
await shot(page, 'inline-5-pruefen')
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
eq(await titel(wd).innerText(), 'Fast geschafft', 'Titel Ergebnis')
eq(await wd.locator('.wkt-ergebnis-text').innerText(),
'Bitte bestätigen Sie den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.', 'Ergebnistext')
await shot(page, 'inline-6-fast-geschafft')
// Server: genau eine gehaltene Buchung, G01 an den Gast mit Bestaetigungslink
await srv.warteAufVersand()
const buchungen = srv.buchungen()
eq(buchungen.length, 1, 'Anzahl Buchungen')
eq([buchungen[0].status, buchungen[0].gastEmail, buchungen[0].leistungId, buchungen[0].gastName], ['gehalten', 'max@example.com', S_DAMEN, 'Max Mustermann'], 'Buchung')
const g01 = srv.mails().filter((m) => m.to.some((t) => t.address === 'max@example.com'))
eq(g01.length, 1, 'G01-Mails an den Gast')
assert(/termin\.html#v=[A-Za-z0-9_-]{43}/.test(g01[0].text), 'G01 ohne Bestätigungslink')
assert(!g01[0].bcc?.length, 'nie BCC')
// Kein Cookie, kein Speicher
const speicher = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length }))
eq(speicher, { c: '', l: 0, s: 0 }, 'Cookies/Storage')
eq((await ctx.cookies()).length, 0, 'Cookies im Browser')
})
// ---------------------------------------------------------------- 2. Demo-Seite und Aufbau
await check('demo_seite_stile_und_honigtopf', async (page) => {
await page.goto(`${SEITE}/embed/termine-demo.html`)
await karte(w(page, 0), 'Herrenschnitt').waitFor()
assert(await page.locator('#ausweich-inline').count() === 0, 'Ausweichtext (inline) muss ersetzt sein')
eq(await page.locator('#wkt-style').count(), 1, '<style id="wkt-style"> genau einmal')
assert((await page.locator('#wkt-style').textContent()).includes('@layer wkt'), 'Stile stehen nicht in @layer wkt')
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
assert(await knopf.isVisible(), 'Knopf der Demo fehlt')
const bg = await knopf.evaluate((b) => getComputedStyle(b).backgroundColor)
assert(bg !== 'rgba(0, 0, 0, 0)', `Knopf ohne Hintergrund (${bg})`)
const wd = w(page, 0)
await karte(wd, 'Kinderschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-13').click()
await zeit(wd, '09:00').click()
await warteTitel(wd, 'Ihre Angaben')
// Honigtopf: unsichtbar, nicht anwaehlbar, ohne Autovervollstaendigung, vor Screenreadern versteckt
const hp = wd.locator('input[name=kontakt_url]')
eq(await hp.getAttribute('autocomplete'), 'off', 'autocomplete')
eq(await hp.getAttribute('tabindex'), '-1', 'tabindex')
eq(await hp.evaluate((i) => i.closest('[aria-hidden="true"]') !== null), true, 'aria-hidden')
const box = await hp.evaluate((i) => { const r = i.closest('[aria-hidden]').getBoundingClientRect(); return { x: r.x + r.width, w: r.width } })
assert(box.x <= 0 || box.w <= 2, `Honigtopf im sichtbaren Bereich: ${JSON.stringify(box)}`)
// aria-live polite fuer Meldungen
assert(await page.locator('[aria-live="polite"]').count() >= 1, 'aria-live="polite" fehlt')
})
// ---------------------------------------------------------------- 3. Knopfmodus mit <dialog>
await check('knopf_dialog', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin online buchen"')))
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
await knopf.waitFor()
await page.waitForTimeout(400)
eq(anfragen.length, 0, 'Vor dem Klick darf nichts geladen werden')
assert(await page.locator('#ausweich').count() === 0 || !(await page.locator('#ausweich').isVisible()), 'Ausweichtext darf neben dem Knopf nicht stehen')
eq(await page.locator('dialog.wkt-dialog').count(), 1, 'ein <dialog>')
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
await knopf.click()
const dlg = page.locator('dialog.wkt-dialog')
await karte(dlg, 'Herrenschnitt').waitFor()
assert(anfragen.some((u) => u.includes('/config')), 'Konfiguration wird beim Klick geladen')
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog muss modal offen sein')
eq(await dlg.getAttribute('aria-label'), 'Termin online buchen', 'Name des Dialogs')
await shot(page, 'knopf-dialog')
// Esc schliesst, Fokus zurueck auf den Knopf, Wiederoeffnen ohne neuen Abruf der Konfiguration
await page.keyboard.press('Escape')
assert(!(await dlg.evaluate((d) => d.open)), 'Esc schliesst den Dialog nicht')
assert(await knopf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Knopf')
await knopf.click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'Wiederoeffnen laedt die Konfiguration nicht noch einmal')
// Klick auf den Hintergrund schliesst ebenfalls
await page.mouse.click(4, 4)
assert(!(await dlg.evaluate((d) => d.open)), 'Klick auf den Hintergrund schliesst nicht')
await knopf.click()
// Komplett durchbuchen im Dialog (kostenlose Leistung: "Termin buchen", ohne Haken fuer vorzeitigen Beginn)
await karte(dlg, 'Kennenlern-Beratung').click()
await karte(dlg, 'Ben').click()
await tag(dlg, '2026-10-14').click()
await shot(page, 'knopf-dialog-termin')
await zeit(dlg, '15:00').click()
await dlg.locator('input[name=name]').fill('Erika Muster')
await dlg.locator('input[name=email]').fill('erika@example.com')
await dlg.getByRole('button', { name: 'Weiter' }).click()
await dlg.locator('.wkt-zusammen').waitFor()
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
await dlg.locator('input[name=storno]').check()
await dlg.getByRole('button', { name: 'Termin buchen' }).click()
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.ressourceId, b.gastName]).sort(), [['r_ben00002', 'Erika Muster']], 'Buchung mit gewaehlter Person')
})
await check('knopf_mit_vorgewaehlter_leistung', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
await page.getByRole('button', { name: 'Damenschnitt buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Bei wem')
eq(await dlg.locator('.wkt-pfad').innerText(), 'Schritt 1 von 4', 'ohne Leistungsschritt')
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
await karte(dlg, 'Egal').click()
assert((await dlg.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'vorgewaehlte Leistung steht im Termin-Schritt')
})
// ---------------------------------------------------------------- 4. Nur Tastatur
await check('nur_tastatur', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
await karte(w(page), 'Herrenschnitt').waitFor()
const aktiv = (fn, arg) => page.evaluate(fn, arg)
const tabBis = async (bedingung, max = 25) => {
for (let i = 0; i < max; i += 1) {
await page.keyboard.press('Tab')
if (await page.evaluate(bedingung)) return
}
throw new Error(`Fokus nicht erreicht (${bedingung})`)
}
await tabBis(() => document.activeElement?.matches('[data-webklar-termine] .wkt-karte'))
eq(await aktiv(() => document.activeElement.querySelector('.wkt-karte-name').textContent), 'Herrenschnitt', 'erste Karte')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Bei wem')
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel')), 'Fokus muss auf der neuen Ueberschrift liegen')
await page.keyboard.press('Tab')
assert((await aktiv(() => document.activeElement.textContent)).startsWith('Egal'), 'Tab von der Ueberschrift: erste Option')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Wann passt es Ihnen?')
await w(page).locator('td.wkt-tag').first().waitFor()
await tabBis(() => document.activeElement?.matches('td.wkt-tag'))
const start = await aktiv(() => document.activeElement.dataset.tag)
eq(start, '2026-10-05', 'Raster startet beim ersten freien Tag')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
eq(await aktiv(() => document.activeElement.dataset.tag), '2026-10-19', 'zwei Wochen weiter')
await page.keyboard.press('Enter')
await w(page).locator('.wkt-zeit').first().waitFor()
eq(await tag(w(page), '2026-10-19').getAttribute('aria-selected'), 'true', 'Auswahl mit Enter')
await tabBis(() => document.activeElement?.matches('.wkt-zeit'))
eq(await aktiv(() => document.activeElement.textContent), '09:00', 'erste Uhrzeit')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Ihre Angaben')
await tabBis(() => document.activeElement?.matches('input[name=name]'))
await page.keyboard.type('Max Mustermann')
await page.keyboard.press('Tab')
await page.keyboard.type('max@example.com')
await page.keyboard.press('Enter') // Absenden des Formulars per Enter
await warteTitel(w(page), 'Bitte prüfen Sie Ihre Buchung')
for (let i = 0; i < 20; i += 1) {
await page.keyboard.press('Tab')
const a = await page.evaluate(() => ({ name: document.activeElement.name || '', submit: document.activeElement.matches('.wkt-form button[type=submit]'), text: document.activeElement.textContent }))
if (a.name === 'storno' || a.name === 'vorzeitig') await page.keyboard.press('Space')
if (a.submit) {
eq(a.text, 'Zahlungspflichtig buchen', 'Knopf per Tastatur erreicht')
await page.keyboard.press('Enter')
break
}
}
await w(page).getByRole('heading', { name: 'Fast geschafft' }).waitFor()
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel') && document.activeElement.textContent === 'Fast geschafft'), 'Fokus auf "Fast geschafft"')
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.leistungId, b.gastName, b.status]), [[S_HERREN, 'Max Mustermann', 'gehalten']], 'Buchung per Tastatur')
})
await check('monatsraster_tasten', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
const wd = w(page)
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
const grid = wd.locator('table[role=grid]')
eq(await grid.getAttribute('aria-labelledby') !== null, true, 'Raster ist benannt')
eq(await wd.locator('td[role=gridcell][tabindex="0"]').count(), 1, 'genau eine Zelle mit tabindex 0 (Roving)')
await wd.locator('td[tabindex="0"]').focus()
const aktTag = () => page.evaluate(() => document.activeElement.dataset.tag)
const monat = () => wd.locator('.wkt-monat').innerText()
eq(await aktTag(), '2026-10-05', 'Start')
await page.keyboard.press('ArrowRight'); eq(await aktTag(), '2026-10-06', 'Pfeil rechts')
await page.keyboard.press('ArrowDown'); eq(await aktTag(), '2026-10-13', 'Pfeil runter = +7')
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-10-12', 'Pfeil links')
await page.keyboard.press('ArrowUp'); eq(await aktTag(), '2026-10-05', 'Pfeil hoch = -7')
await page.keyboard.press('End'); eq(await aktTag(), '2026-10-11', 'Ende = Sonntag der Woche')
eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist aria-disabled, aber anwaehlbar')
await page.keyboard.press('Enter')
eq(await wd.locator('.wkt-zeit').count(), 0, 'Enter auf gesperrtem Tag waehlt nichts')
eq(await tag(wd, '2026-10-11').getAttribute('aria-selected'), 'false', 'gesperrter Tag bleibt unausgewaehlt')
await page.keyboard.press('Home'); eq(await aktTag(), '2026-10-05', 'Pos1 = Montag der Woche')
await page.keyboard.press('PageDown')
await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
eq(await aktTag(), '2026-11-05', 'Bild runter = naechster Monat, gleicher Tag')
await page.keyboard.press('Home'); eq(await aktTag(), '2026-11-02', 'Pos1 im November')
await page.keyboard.press('ArrowUp')
await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
eq(await aktTag(), '2026-10-26', 'Pfeil hoch ueber die Monatsgrenze')
await page.keyboard.press('ArrowDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
eq(await aktTag(), '2026-11-02', 'Pfeil runter zurueck in den November')
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-11-01', 'Pfeil links')
await page.keyboard.press('ArrowLeft'); await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
eq(await aktTag(), '2026-10-31', 'Pfeil links vom 1. in den Vormonat')
await page.keyboard.press('PageUp')
eq(await monat(), 'Oktober 2026', 'vor dem aktuellen Monat gibt es kein Zurueck')
eq(await wd.getByRole('button', { name: 'Vorheriger Monat' }).isDisabled(), true, 'Pfeil zurueck im aktuellen Monat gesperrt')
await page.keyboard.press('PageDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
await page.keyboard.press('Enter')
await wd.locator('.wkt-zeit').first().waitFor()
eq(await tag(wd, '2026-11-30').count(), 1, 'Tage des Novembers stehen im Raster')
assert(await wd.locator('td[aria-selected="true"]').count() === 1, 'genau ein Tag ausgewaehlt')
})
// ---------------------------------------------------------------- 5. Handy, 390 px
await check('handy_390', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
const pruefe = async (schritt) => {
assert(await passtInBreite(page), `${schritt}: seitliches Scrollen`)
const klein = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine] :is(button,input:not([name=kontakt_url]),textarea,select,summary,td.wkt-tag,a)')]
.filter((e) => e.getClientRects().length && !e.closest('[aria-hidden="true"]:not(td)') && !e.closest('.wkt-datenschutz'))
.map((e) => { const r = e.getBoundingClientRect(); return { t: e.tagName + '.' + e.className, w: Math.round(r.width), h: Math.round(r.height) } })
.filter((r) => r.w < 24 || r.h < 24))
assert(klein.length === 0, `${schritt}: Zielgroesse unter 24 px: ${JSON.stringify(klein)}`)
}
await pruefe('Leistung')
await shot(page, '390-1-leistung')
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
await pruefe('Termin')
await shot(page, '390-2-termin')
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await pruefe('Angaben')
await shot(page, '390-3-daten')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
await pruefe('Zusammenfassung')
await shot(page, '390-4-pruefen')
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
await pruefe('Ergebnis')
await shot(page, '390-5-fast-geschafft')
const breite = await wd.evaluate((e) => e.getBoundingClientRect().width)
assert(breite <= 390, `Widget breiter als der Bildschirm: ${breite}`)
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
await check('handy_390_dialog', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await karte(dlg, 'Herrenschnitt').waitFor()
const maße = await dlg.evaluate((d) => { const r = d.getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, b: r.bottom, w: window.innerWidth, h: window.innerHeight, scroll: d.scrollHeight > d.clientHeight } })
assert(maße.l >= 0 && maße.r <= maße.w + 0.5 && maße.t >= 0 && maße.b <= maße.h + 0.5, `Dialog ragt aus dem Bildschirm: ${JSON.stringify(maße)}`)
assert(maße.scroll, 'Der lange Inhalt muss im Dialog scrollen')
await page.screenshot({ path: `${SHOTS}/widget-390-dialog.png` })
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'seitliches Scrollen mit offenem Dialog')
}, { context: { viewport: { width: 390, height: 700 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ---------------------------------------------------------------- 6. Zeitzone: New York zeigt dasselbe wie Berlin
const erfasse = async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
const monat = await wd.locator('.wkt-monat').innerText()
const frei = await wd.locator('td.wkt-tag[aria-disabled="false"]').evaluateAll((l) => l.map((e) => e.dataset.tag))
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
const lang = await wd.locator('.wkt-zeiten-titel').innerText()
const zeiten = await wd.locator('.wkt-zeit').allInnerTexts()
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
const zus = (await wd.locator('.wkt-zusammen').innerText()).replace(/\s+/g, ' ')
return { monat, frei, lang, zeiten, zus }
}
const datumZaehler = () => {
// zaehlt jede Benutzung von Date und Intl im Seitenskript (das Widget darf keine brauchen)
window.__datum = 0
const D = Date
window.Date = new Proxy(D, {
construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) },
apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) },
get(t, p, r) { if (p === 'now') window.__datum += 1; return Reflect.get(t, p, r) },
})
const I = Intl.DateTimeFormat
Intl.DateTimeFormat = new Proxy(I, { construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) }, apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) } })
}
let berlin = null
await check('zeitzone_berlin_referenz', async (page) => {
berlin = await erfasse(page)
assert(berlin.zeiten[0] === '09:00' && berlin.zus.includes('10:00 Uhr'), `Berliner Uhrzeiten: ${berlin.zeiten[0]}`)
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl im Berliner Browser benutzt')
}, { context: { timezoneId: 'Europe/Berlin', locale: 'de-DE' }, initScript: datumZaehler })
await check('zeitzone_new_york_gleiche_zeiten', async (page) => {
const ny = await erfasse(page)
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl in New York benutzt')
assert(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone) === 'America/New_York', 'Browser steht nicht auf New York')
eq(ny, berlin, 'Tage, Wochentag und Uhrzeiten in New York weichen von Berlin ab')
eq(ny.zeiten.slice(0, 2), ['09:00', '09:15'], 'Uhrzeiten sind Berliner Wanduhr')
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' }, initScript: datumZaehler })
// ---------------------------------------------------------------- 7. Anrede Du
await check('anrede_du', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
eq(await titel(wd).innerText(), 'Welche Leistung möchtest du buchen?', 'Titel Du')
await karte(wd, 'Herrenschnitt').click()
eq(await titel(wd).innerText(), 'Bei wem möchtest du den Termin?', 'Titel Person Du')
await karte(wd, 'Egal').click()
eq(await titel(wd).innerText(), 'Wann passt es dir?', 'Titel Termin Du')
await tag(wd, '2026-10-26').click()
await zeit(wd, '11:00').click()
eq(await titel(wd).innerText(), 'Deine Angaben', 'Titel Angaben Du')
// Fehlermeldungen Du
await wd.getByRole('button', { name: 'Weiter' }).click()
eq(await wd.locator('#' + await wd.locator('input[name=name]').getAttribute('id') + '-e').innerText(), 'Bitte gib deinen Namen an.', 'Fehlertext Du')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
eq(await titel(wd).innerText(), 'Bitte prüfe deine Buchung', 'Titel Pruefen Du')
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden deine Angaben'), 'Datenschutz-Hinweis in Du')
// Zustimmungstexte bleiben Rechtstext in der Sie-Form (26.10. liegt nach der Widerrufsfrist: nur Storno)
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
eq(await wd.locator('input[name=vorzeitig]').count(), 0, 'nach der Widerrufsfrist kein Haken fuer vorzeitigen Beginn')
await shot(page, 'du-pruefen')
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
eq(await wd.locator('.wkt-ergebnis-text').innerText(),
'Bitte bestätige den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für dich reserviert.', 'Ergebnistext Du')
}, { eingabe: salonEingabe({ anrede: 'du' }) })
// ---------------------------------------------------------------- 8. Zwei Widgets
await check('zwei_widgets_stoeren_sich_nicht', async (page) => {
const zwei = eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`, '<p id="a1">Ausweich 1</p>') + eingebettet(`data-modus="inline" data-leistung="${S_DAMEN}"`, '<p id="a2">Ausweich 2</p>')
await oeffne(page, seitenHtml(zwei))
const w0 = w(page, 0)
const w1 = w(page, 1)
await warteTitel(w0, 'Bei wem')
await warteTitel(w1, 'Bei wem')
eq(await page.locator('#wkt-style').count(), 1, 'Stile genau einmal')
const ids = await page.evaluate(() => [...document.querySelectorAll('[id]')].map((e) => e.id))
eq(new Set(ids).size, ids.length, `doppelte IDs: ${ids.filter((x, i) => ids.indexOf(x) !== i)}`)
await karte(w0, 'Egal').click()
await warteTitel(w0, 'Wann passt')
eq(await karte(w1, 'Egal').count(), 1, 'Widget 2 steht noch bei der Personenwahl')
await tag(w0, '2026-10-12').click()
await w0.locator('.wkt-zeit').first().waitFor()
await karte(w1, 'Ben').click()
await w1.locator('td.wkt-tag').first().waitFor()
await tag(w1, '2026-10-13').click()
await w1.locator('.wkt-zeit').first().waitFor()
eq(await w0.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-12', 'Widget 1 behaelt seinen Tag')
eq(await w1.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-13', 'Widget 2 hat seinen Tag')
assert((await w0.locator('.wkt-hinweis').first().innerText()).startsWith('Herrenschnitt'), 'Widget 1: Herrenschnitt')
assert((await w1.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'Widget 2: Damenschnitt')
// Beschriftungen und ARIA-Bezuege zeigen innerhalb des eigenen Widgets
await zeit(w0, '10:00').click()
await warteTitel(w0, 'Ihre Angaben')
const bezuege = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine]')].map((root) => ({
labels: [...root.querySelectorAll('label[for]')].every((l) => root.contains(document.getElementById(l.htmlFor))),
aria: [...root.querySelectorAll('[aria-labelledby],[aria-describedby]')].every((e) => (e.getAttribute('aria-labelledby') || e.getAttribute('aria-describedby')).split(' ').every((id) => root.contains(document.getElementById(id)))),
})))
eq(bezuege, [{ labels: true, aria: true }, { labels: true, aria: true }], 'Bezuege innerhalb des Widgets')
await w0.locator('input[name=name]').fill('Max Mustermann')
eq(await w1.locator('input[name=name]').count(), 0, 'Widget 2 hat kein Namensfeld')
})
// ---------------------------------------------------------------- 9. Eingaben und Fehler
await check('pflichtfelder_und_haken', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-12').click()
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await wd.getByRole('button', { name: 'Weiter' }).click()
const name = wd.locator('input[name=name]')
const mail = wd.locator('input[name=email]')
eq(await name.getAttribute('aria-invalid'), 'true', 'Name ungueltig markiert')
eq(await mail.getAttribute('aria-invalid'), 'true', 'E-Mail ungueltig markiert')
assert(await name.evaluate((i) => document.activeElement === i), 'Fokus auf dem ersten Fehlerfeld')
const fehlerId = await name.getAttribute('aria-describedby')
eq(await wd.locator(`#${fehlerId}`).innerText(), 'Bitte geben Sie Ihren Namen an.', 'Fehlertext am Feld')
assert((await page.locator('[aria-live="polite"]').first().textContent()).includes('Bitte prüfen Sie Ihre Eingaben'), 'Fehler werden per aria-live angesagt')
await name.fill('Max Mustermann')
await mail.fill('keine-adresse')
await wd.locator('input[name=telefon]').fill('abc')
await wd.getByRole('button', { name: 'Weiter' }).click()
assert((await wd.locator('#' + await mail.getAttribute('aria-describedby')).innerText()).includes('gültige E-Mail-Adresse'), 'E-Mail-Fehler')
assert((await wd.locator('#' + await wd.locator('input[name=telefon]').getAttribute('aria-describedby')).innerText()).includes('gültige Telefonnummer'), 'Telefon-Fehler')
await mail.fill('max@example.com')
await wd.locator('input[name=telefon]').fill('')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
// ohne Haken: nichts wird gesendet, Fehler stehen an den Haken
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
eq(await wd.locator('input[name=storno]').getAttribute('aria-invalid'), 'true', 'Storno-Haken markiert')
eq(await wd.locator('input[name=vorzeitig]').getAttribute('aria-invalid'), 'true', 'Haken vorzeitiger Beginn markiert')
eq(srv.buchungen().length, 0, 'ohne Haken wird nichts gebucht')
// Zurueck behaelt die Eingaben
await wd.getByRole('button', { name: 'Zurück' }).click()
eq(await wd.locator('input[name=name]').inputValue(), 'Max Mustermann', 'Eingabe bleibt nach Zurueck')
})
await check('zeit_inzwischen_vergeben', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', person: 'Anna', tag: '2026-10-26', zeit: '10:00' })
// ein anderer Gast bucht Anna zur selben Zeit
const conf = await (await fetch(`${srv.url}/api/public/termine/v1/${KEY}/config`)).json()
const res = await fetch(`${srv.url}/api/public/termine/v1/${KEY}/buchungen`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: srv.url },
body: JSON.stringify({ leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-26', zeit: '10:00', name: 'Anderer Gast', email: 'anderer@example.com', telefon: '', nachricht: '', zusatz: {}, erinnerung: false, zustimmungen: { storno: true, vorzeitig: false }, kontakt_url: '', formToken: conf.formToken }),
})
eq(res.status, 201, 'zweiter Gast kann buchen')
await wd.locator('input[name=storno]').check()
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await warteTitel(wd, 'Wann passt es Ihnen?')
eq(await wd.locator('.wkt-fehlertext').first().innerText(), 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.', 'Hinweis auf die vergebene Zeit')
eq(srv.buchungen().filter((b) => b.gastEmail === 'max@example.com').length, 0, 'keine zweite Buchung')
await shot(page, 'fehler-zeit-vergeben')
})
await check('honigtopf_gefuellt_keine_buchung', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=kontakt_url]').evaluate((i) => { i.value = 'http://spam.example'; i.dispatchEvent(new Event('input', { bubbles: true })) })
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen') // der Server antwortet gleich (kein Hinweis fuer Automaten) ...
await srv.warteAufVersand()
eq(srv.buchungen().length, 0, '... speichert aber nichts')
eq(srv.mails().length, 0, '... und schickt nichts')
})
await check('formular_token_abgelaufen_neu_laden', async (page) => {
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
eq(konfig.length, 1, 'Konfiguration einmal geladen')
await wd.locator('input[name=storno]').check()
srv.schiebeUhr(3 * 60 * 60 * 1000) // Formular-Token ist beim Server jetzt aelter als 2 Stunden, im Browser sind erst Sekunden vergangen
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Hinweis nach neu_laden')
eq(konfig.length, 2, 'Konfiguration neu geladen (neues Formular-Token)')
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
eq(await wd.locator('input[name=storno]').isChecked(), true, 'Eingaben und Haken bleiben')
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht')
})
await check('konfiguration_nach_100_minuten_neu', async (page) => {
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-26').click()
await wd.locator('.wkt-zeit').first().waitFor()
eq(konfig.length, 1, 'bisher eine Konfiguration')
await page.evaluate(() => { window.__vorsprung = 99 * 60 * 1000 })
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
eq(konfig.length, 1, 'nach 99 Minuten wird noch nicht neu geladen')
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
eq(konfig.length, 2, 'nach 101 Minuten wird neu geladen')
}, { initScript: () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung } })
// ---------------------------------------------------------------- 10. Widerruf ohne Link
await check('vertrag_widerrufen_im_fuss', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss')
const ds = wd.locator('.wkt-fuss a')
eq([await ds.getAttribute('href'), await ds.getAttribute('rel'), await ds.getAttribute('target')], ['https://salon-mueller.example/datenschutz', 'noopener noreferrer', '_blank'], 'Datenschutz-Link')
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
await warteTitel(wd, 'Vertrag widerrufen')
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click() // leer: Fehler
eq(await wd.locator('input[name=name]').getAttribute('aria-invalid'), 'true', 'Name Pflicht')
await wd.locator('input[name=name]').fill('Gerda Gast')
await wd.locator('input[name=email]').fill('gerda@example.com')
await wd.locator('input[name=nummer]').fill('ABCD1234')
await shot(page, 'widerruf')
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await wd.locator('.wkt-ergebnis-text').waitFor()
eq(await wd.locator('.wkt-ergebnis-text').innerText(), 'Ihre Erklärung ist eingegangen.', 'Eingangsmeldung')
assert(/Eingang: \d{2}\.\d{2}\.\d{4}, \d{2}:\d{2} Uhr/.test(await wd.innerText()), 'Datum und Uhrzeit des Eingangs')
await srv.warteAufVersand()
const an = srv.mails().map((m) => m.to.map((t) => t.address).join())
assert(an.includes('gerda@example.com'), `Eingangsbestaetigung (G08) fehlt: ${an}`)
assert(an.includes('info@salon-mueller.example'), `Weiterleitung an den Betrieb (K21) fehlt: ${an}`)
await wd.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(wd, 'Herrenschnitt').waitFor()
})
// ---------------------------------------------------------------- 11. Stile der Seite
await check('seitenstile_und_betriebsfarbe', async (page, ctx) => {
const leer = await ctx.newPage()
await oeffne(leer, seitenHtml(eingebettet('data-modus="inline" data-leistung="' + S_HERREN + '"')), 'standard.html')
const wdL = w(leer)
await karte(wdL, 'Egal').click()
await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
const standard = await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().evaluate((e) => getComputedStyle(e).borderTopColor)
eq(standard, 'rgb(15, 118, 110)', 'ohne Variable gilt die Betriebsfarbe als Akzent')
await leer.close()
const eigen = 'style="--wkt-akzent:rgb(10, 20, 30);--wkt-akzent-text:rgb(250, 250, 250);--wkt-radius:2px"'
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}" ${eigen}`), { style: '.wkt-titel{color:rgb(1, 2, 3)}' }))
const wd = w(page)
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
const werte = await page.evaluate(() => ({
akzent: getComputedStyle(document.querySelector('td.wkt-tag[aria-disabled="false"]')).borderTopColor,
radius: getComputedStyle(document.querySelector('.wkt-pfeil')).borderTopLeftRadius,
titel: getComputedStyle(document.querySelector('.wkt-titel')).color,
}))
eq(werte.akzent, 'rgb(10, 20, 30)', 'CSS-Variable --wkt-akzent der Seite gewinnt')
eq(werte.radius, '2px', 'CSS-Variable --wkt-radius der Seite gewinnt')
eq(werte.titel, 'rgb(1, 2, 3)', 'Regel der Seite (ohne Layer) schlaegt @layer wkt')
await tag(wd, '2026-10-12').click()
await zeit(wd, '10:00').click()
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
const knopfFarbe = await wd.locator('.wkt-form button[type=submit]').evaluate((b) => [getComputedStyle(b).backgroundColor, getComputedStyle(b).color])
eq(knopfFarbe, ['rgb(10, 20, 30)', 'rgb(250, 250, 250)'], 'Akzent und Akzent-Text am Knopf')
})
// ---------------------------------------------------------------- 12. Fehler und Ausweichtext
await check('ohne_javascript_bleibt_ausweichtext', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt')
}, { context: { javaScriptEnabled: false } })
await check('funktion_aus_oder_unbekannt_ausweichtext', async (page) => {
await srv.setzeFunktion('disabled')
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"') + eingebettet('data-modus="knopf" data-knopf="Termin buchen"', '<p id="ausweich-knopf">Termine telefonisch unter 06301 1234.</p>') + eingebettet('data-modus="inline"', '<p id="ausweich-unbekannt">Unbekannter Schluessel</p>', 'zzzzzzzzzzzzzzzz')))
await page.waitForLoadState('networkidle')
await page.waitForTimeout(300)
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext (Funktion aus) fehlt')
assert(await page.locator('#ausweich-unbekannt').isVisible(), 'Ausweichtext (unbekannter Schluessel) fehlt')
eq(await page.locator('[data-modus="inline"].wkt-root').count(), 0, 'kein Widget (inline) bei abgeschalteter Funktion')
eq(await page.locator('#wkt-style').count(), 1, 'nur der Knopf bringt Stile mit (inline: keine)')
// Knopf: erst beim Klick bemerkt das Widget, dass nichts buchbar ist; im Dialog steht der Ausweichtext
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await dlg.locator('#ausweich-knopf').waitFor()
assert(await dlg.locator('#ausweich-knopf').isVisible(), 'Ausweichtext im Dialog')
eq(await dlg.locator('.wkt-karte').count(), 0, 'keine Leistungen')
await shot(page, 'fehler-funktion-aus')
})
await check('netzfehler_mitten_in_der_strecke', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
await page.route(API, (route) => route.abort())
await tag(wd, '2026-10-12').click()
const meldung = wd.locator('.wkt-ausfall')
await meldung.waitFor()
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234', 'Ausfalltext mit Telefonnummer')
eq(await meldung.locator('a').getAttribute('href'), 'tel:063011234', 'Telefonlink')
assert(await wd.locator('.wkt-fuss').isVisible(), 'Fuss bleibt')
await shot(page, 'fehler-nicht-erreichbar')
await page.unroute(API)
await wd.getByRole('button', { name: 'Erneut versuchen' }).click()
await wd.locator('td.wkt-tag').first().waitFor()
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
})
await check('keine_fremden_anfragen_keine_cookies', async (page, ctx) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
await bisZusammenfassung(page, { tag: '2026-10-26' })
eq((await ctx.cookies()).length, 0, 'Cookies')
const erlaubt = new Set([new URL(srv.url).host, new URL(SEITE).host])
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
eq(fremd, [], 'Anfragen an fremde Hosts')
})
// Zum Schluss: Server gestoppt. Seite und Skript liefert ein eigener kleiner Server, die Schnittstelle ist weg (Verbindung abgelehnt).
await check('server_gestoppt_ausweichtext', async (page) => {
const skript = await readFile(WIDGET_JS, 'utf8')
const seiten = new Map()
const kleiner = http.createServer((req, res) => {
const pfad = new URL(req.url, 'http://x').pathname
if (pfad === '/embed/termine.js') return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(skript)
if (seiten.has(pfad)) return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(seiten.get(pfad))
return res.writeHead(404).end()
})
await new Promise((resolve) => kleiner.listen(0, '127.0.0.1', resolve))
const eigen = `http://localhost:${kleiner.address().port}`
const oeffneEigen = async (html, name) => { seiten.set(`/${name}`, html); await page.goto(`${eigen}/${name}`) }
await srv.beendeApp()
try {
await oeffneEigen(seitenHtml(eingebettet('data-modus="inline"')), 'gestoppt.html')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt bei gestopptem Server')
eq(await page.locator('.wkt-root').count(), 0, 'kein halbes Widget')
eq(await page.locator('#wkt-style').count(), 0, 'keine Stile ohne Widget')
await shot(page, 'fehler-ausweichtext')
// Knopfmodus: der Dialog zeigt "Gerade nicht erreichbar." und den Ausweichtext
await oeffneEigen(seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')), 'gestoppt2.html')
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await dlg.locator('#ausweich').waitFor()
assert((await dlg.innerText()).includes('Gerade nicht erreichbar.'), 'Meldung im Dialog')
await shot(page, 'fehler-ausweichtext-knopf')
} finally {
kleiner.closeAllConnections?.()
await new Promise((resolve) => kleiner.close(resolve))
}
})
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
+269
View File
@@ -0,0 +1,269 @@
// 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.
//
// 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',
},
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
await Promise.race([app.close(), new Promise((resolve) => setTimeout(resolve, 3000))])
},
appBeendet: false,
}
await srv.zuruecksetzen()
return srv
}