Files
Webklar-Kundenbereich/test/ui/termin-seiten.ui.mjs
T

623 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playwright-Checks der Gaesteseiten public/termin.html + termin.js + termin.css 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/termin-seiten.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/termin-*.png (bei Fehlern fehler-termin-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Ablauf: Buchungen und Bestaetigungen, die nicht selbst geprueft werden, legt das Skript ueber die oeffentliche Schnittstelle an;
// den Link der Mail (G01 #v=, G02 #m=) holt es aus der Mail-Senke. Die Seiten laufen unter srv.url (gleiche Herkunft wie die
// Schnittstelle, wie im Betrieb). Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (laeuft in Echtzeit weiter, srv.schiebeUhr/setzeUhr).
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import {
startTermineUiServer, salonEingabe,
R_ANNA, S_DAMEN, S_KENNEN, S_ANFRAGE,
} 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 (/^(termin|fehler-termin)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startTermineUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/termine/v1`
const SEITE = `${srv.url}/termin.html`
const BETRIEB_MAIL = 'info@salon-mueller.example'
// ---------------------------------------------------------------- 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 JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
/** Buchung ueber die oeffentliche Schnittstelle (wie das Widget); liefert den Bestaetigungslink-Token (#v=) aus der Mail G01. */
async function buche(o = {}) {
const v = { leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', vorzeitig: true, ...o }
const conf = await (await fetch(`${API}/${KEY}/config`)).json()
const res = await fetch(`${API}/${KEY}/buchungen`, {
method: 'POST',
headers: JSON_KOPF,
body: JSON.stringify({
leistung: v.leistung, ressource: v.ressource, tag: v.tag, zeit: v.zeit, name: v.name, email: v.email, telefon: '', nachricht: '', zusatz: {},
erinnerung: false, zustimmungen: { storno: true, vorzeitig: v.vorzeitig }, kontakt_url: '', formToken: conf.formToken,
}),
})
eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
await srv.warteAufVersand()
return { ...v, vToken: tokenAusMail(v.email, 'v') }
}
/** Token (43 Zeichen) des Links #v= oder #m= aus der letzten passenden Mail an die Adresse. */
function tokenAusMail(email, art) {
const mails = srv.mails().filter((m) => m.to.some((t) => t.address === email)).reverse()
for (const m of mails) {
const treffer = new RegExp(`termin\\.html#${art}=([A-Za-z0-9_-]{43})`).exec(`${m.text}\n${m.html}`)
if (treffer) return treffer[1]
}
throw new Error(`Kein Link #${art}= in den Mails an ${email}`)
}
/** Bestaetigung ueber die Schnittstelle (wie der Klick auf der Seite); liefert den Verwaltungs-Token (#m=) aus der Mail G02. */
async function bestaetige(b) {
const res = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token: b.vToken }) })
eq(res.status, 200, 'Bestaetigung')
await srv.warteAufVersand()
return tokenAusMail(b.email, 'm')
}
/** Gebucht und bestaetigt: { mToken, ... }. */
async function buchungBestaetigt(o) {
const b = await buche(o)
return { ...b, mToken: await bestaetige(b) }
}
const zuletzt = () => srv.buchungen().at(-1)
const buchungVon = (email) => srv.buchungen().find((b) => b.gastEmail === email)
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
const shot = (page, name) => page.screenshot({ path: `${SHOTS}/termin-${name}.png`, fullPage: true })
const h1 = (page) => page.locator('h1.t-titel')
const warteH1 = (page, text) => page.locator('h1.t-titel', { hasText: text }).waitFor()
const knopf = (page, name) => page.getByRole('button', { name, exact: true })
const oeffne = (page, art, token) => page.goto(`${SEITE}#${art}=${token}`)
const text = (page) => page.locator('#karte').innerText()
/** Kein Streifen rechts: Seite und Karte passen in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
/** Alle sichtbaren Knoepfe mindestens 44 px hoch (Touch-Ziele). */
async function knoepfeGross(page) {
const kleine = await page.evaluate(() => [...document.querySelectorAll('#karte button')]
.filter((b) => b.offsetParent !== null && !b.classList.contains('t-link'))
.map((b) => ({ t: b.textContent, h: b.getBoundingClientRect().height }))
.filter((b) => b.h < 39.5)) // Tageszellen im 390er Raster sind 40 px, alles andere 44
return kleine
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, context = {} } = {}) {
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(`JavaScript-Fehler: ${e.message}`))
page.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
page.on('request', (r) => {
try {
const u = new URL(r.url())
if (!u.protocol.startsWith('http')) return
hosts.add(u.host)
if (u.pathname.startsWith('/api/') && !(r.method() === 'POST' && u.pathname.startsWith('/api/public/termine/v1/termin/'))) fehler.push(`unerwartete Anfrage ${r.method()} ${u.pathname}`)
} catch { /* data: u. a. */ }
})
page.on('response', (r) => { if (r.headers()['set-cookie']) fehler.push(`Set-Cookie von ${r.url()}`) })
try {
await fn(page, ctx)
assert(fehler.length === 0, 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-termin-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
async function pruefungen() {
// ------------------------------------------------------------ 1. G01-Link: bestaetigen, G02 im Sink, zweiter Aufruf
await check('g01_bestaetigen_g02_im_sink', async (page, ctx) => {
const b = await buche()
eq(mailsAn('max@example.com').length, 1, 'nur G01 vor dem Klick')
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
// Token weg aus der Adresszeile, Verlauf und Seite
eq(page.url(), SEITE, 'Adresszeile ohne Hash')
eq(await page.evaluate(() => location.hash), '', 'location.hash leer')
// Kopf in der Farbe des Betriebs (#0f766e), Schrift weiss; Betriebsname im Kopf
const kopf = await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, farbe: getComputedStyle(e).color, name: e.innerText.trim() }))
eq(kopf, { bg: 'rgb(15, 118, 110)', farbe: 'rgb(255, 255, 255)', name: 'Salon Müller' }, 'Kopf')
eq(await page.title(), 'Termin bestätigen – Salon Müller', 'Seitentitel')
// Zusammenfassung ausschliesslich aus der Antwort des Servers (Berliner Zeit)
const t = await text(page)
for (const erwartet of ['Damenschnitt', 'Montag, 12.10.2026', '10:00 Uhr', '45 Minuten', 'ab 45,00 €', 'Hauptstr. 1, 67722 Winnweiler', 'Bei', 'Anna']) assert(t.includes(erwartet), `Zusammenfassung ohne "${erwartet}": ${t}`)
assert(/Buchungsnummer\s+[A-Z0-9]{8}/.test(t), 'Buchungsnummer fehlt')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 1, 'Knopf des Servers')
eq(await page.evaluate(() => document.activeElement.tagName), 'H1', 'Fokus auf der Ueberschrift')
await shot(page, 'bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
assert((await text(page)).includes('Bestätigung mit allen Angaben per E-Mail'), 'Hinweis auf die Mail')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'Buchung bestaetigt')
await srv.warteAufVersand()
const anGast = mailsAn('max@example.com')
eq(anGast.length, 2, 'G01 und G02 an den Gast')
assert(/termin\.html#m=[A-Za-z0-9_-]{43}/.test(`${anGast[1].text}${anGast[1].html}`), 'G02 enthaelt den Verwaltungslink')
assert(anGast[1].attachments?.length || anGast[1].icalEvent, 'G02 mit Kalendereinladung')
eq(mailsAn(BETRIEB_MAIL).length, 1, 'K20 an den Betrieb')
await shot(page, 'bestaetigt')
// zweiter Aufruf desselben Links (neue Seite): bereits bestaetigt, kein Knopf
const zweite = await ctx.newPage()
await oeffne(zweite, 'v', b.vToken)
await warteH1(zweite, 'bereits bestätigt')
eq(await zweite.getByRole('button').count(), 0, 'kein Knopf mehr')
assert((await text(zweite)).includes('Sie müssen nichts weiter tun'), 'Hinweis ohne weitere Aktion')
await shot(zweite, 'bereits-bestaetigt')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 2, 'der zweite Aufruf schickt keine Mail')
})
// ------------------------------------------------------------ 2. Zwei Seiten, zwei Klicks: genau eine Bestaetigung
await check('zweiter_klick_schon_bestaetigt', async (page, ctx) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const zweite = await ctx.newPage()
await oeffne(zweite, 'v', b.vToken)
await warteH1(zweite, 'Termin bestätigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
await knopf(zweite, 'Zahlungspflichtig bestätigen').click()
await warteH1(zweite, 'Dieser Termin ist bereits bestätigt.')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 2, 'genau eine G02')
})
// ------------------------------------------------------------ 3. Abgelaufen
await check('abgelaufen_reservierung', async (page, ctx) => {
const b = await buche()
// Seite offen, dann laeuft die Haltung (30 min) ab: der Klick fuehrt zur Ansicht "Abgelaufen"
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
srv.schiebeUhr(31 * 60 * 1000)
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(buchungVon('max@example.com').status === 'bestaetigt', false, 'abgelaufene Haltung wird nicht bestaetigt')
// frischer Aufruf: dieselbe Ansicht mit Link zur Website und Telefon des Betriebs
const neu = await ctx.newPage()
await oeffne(neu, 'v', b.vToken)
await warteH1(neu, 'Diese Reservierung ist abgelaufen.')
eq(await neu.locator('#betrieb').innerText(), 'Salon Müller', 'Betriebsname im Kopf')
const link = neu.getByRole('link', { name: 'Zurück zur Website von Salon Müller' })
eq(await link.getAttribute('href'), 'https://salon-mueller.example/', 'Link zur Website')
eq(await link.getAttribute('rel'), 'noopener noreferrer', 'rel des Links')
const tel = neu.getByRole('link', { name: '06301 1234' })
eq(await tel.getAttribute('href'), 'tel:063011234', 'Telefonnummer als tel-Link')
eq(await neu.getByRole('button').count(), 0, 'keine Aktion in der Ansicht')
const kopf = await neu.locator('#kopf').evaluate((e) => getComputedStyle(e).backgroundColor)
eq(kopf, 'rgb(31, 41, 55)', 'neutrale Kopffarbe ohne Betriebsfarbe')
await shot(neu, 'abgelaufen')
})
await check('abgelaufen_verwaltungslink', async (page) => {
const b = await buchungBestaetigt()
srv.setzeUhr('2026-10-14T08:00:00.000Z') // Terminende + 1 Tag ist vorbei
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
eq(await page.getByRole('button').count(), 0, 'keine Aktion')
})
// ------------------------------------------------------------ 4. Knopftexte und Anfrage
await check('knopftexte_und_anfrage', async (page, ctx) => {
const a = await buche({ leistung: S_DAMEN, email: 'a@example.com' })
const k = await buche({ leistung: S_KENNEN, email: 'k@example.com', zeit: '11:00', vorzeitig: false })
const f = await buche({ leistung: S_ANFRAGE, email: 'f@example.com', zeit: '14:00', vorzeitig: false, name: 'Frieda Fischer' })
await oeffne(page, 'v', a.vToken)
await warteH1(page, 'Termin bestätigen')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 1, 'Preis: Zahlungspflichtig bestätigen')
await oeffne(page, 'v', k.vToken)
await warteH1(page, 'Termin bestätigen')
eq(await knopf(page, 'Termin bestätigen').count(), 1, 'ohne Preis: Termin bestätigen')
await oeffne(page, 'v', f.vToken)
await warteH1(page, 'Anfrage bestätigen')
eq(await knopf(page, 'Anfrage absenden').count(), 1, 'Anfrage: Anfrage absenden')
await shot(page, 'anfrage-bestaetigen')
await knopf(page, 'Anfrage absenden').click()
await warteH1(page, 'Ihre Anfrage ist eingegangen.')
assert((await text(page)).includes('Salon Müller prüft Ihre Anfrage'), 'Text der Anfrage')
await srv.warteAufVersand()
eq(buchungVon('f@example.com').status, 'angefragt', 'Status angefragt')
eq(mailsAn('f@example.com').length, 2, 'G01 und G03')
eq(mailsAn(BETRIEB_MAIL).length, 1, 'K20 (Anfrage) an den Betrieb')
await shot(page, 'anfrage-eingegangen')
// Verwaltungslink der Anfrage: angefragt, Absagen moeglich, kein Widerruf (noch kein Vertrag)
const m = tokenAusMail('f@example.com', 'm')
await oeffne(page, 'm', m)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Angefragt'), 'Status Angefragt')
assert((await text(page)).includes('noch nicht angenommen'), 'Hinweis: noch nicht angenommen')
eq(await knopf(page, 'Termin absagen').count(), 1, 'Anfrage kann abgesagt werden')
eq(await knopf(page, 'Vertrag widerrufen').count(), 0, 'kein Widerruf bei einer Anfrage')
await shot(page, 'anfrage-verwalten')
})
// ------------------------------------------------------------ 5. Ungueltige Links, Neuladen
await check('ungueltige_links_und_neuladen', async (page) => {
await page.goto(SEITE)
await warteH1(page, 'Bitte öffnen Sie den Link aus Ihrer E-Mail')
await page.goto(`${SEITE}#v=zukurz`)
await warteH1(page, 'unvollständig oder ungültig')
eq(page.url(), SEITE, 'auch ein ungueltiger Hash verschwindet')
await page.goto(`${SEITE}#m=${'A'.repeat(43)}`)
await warteH1(page, 'Dieser Link ist ungültig oder abgelaufen.')
await shot(page, 'unbekannt')
// Neuladen: der Token ist aus der Adresse entfernt, die Seite bittet um den Link aus der Mail
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
await page.reload()
await warteH1(page, 'Bitte öffnen Sie den Link aus Ihrer E-Mail')
eq(await knopf(page, 'Zahlungspflichtig bestätigen').count(), 0, 'nach dem Neuladen kein Knopf')
})
// ------------------------------------------------------------ 6. Verwalten und Absagen
await check('verwalten_absagen', async (page) => {
const b = await buchungBestaetigt()
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
eq(page.url(), SEITE, 'Adresszeile ohne Hash')
const t = await text(page)
assert(t.includes('Bestätigt') && t.includes('Montag, 12.10.2026') && t.includes('10:00 Uhr'), `Verwaltungsansicht: ${t}`)
for (const name of ['Termin verschieben', 'Termin absagen', 'Vertrag widerrufen']) eq(await knopf(page, name).count(), 1, `Knopf ${name}`)
eq(await page.getByRole('button', { name: 'Zahlungspflichtig' }).count(), 0, 'kein Bestaetigungsknopf')
await shot(page, 'verwalten')
// Rueckfrage in der Seite (kein Browser-Dialog: check() meldet jeden Dialog als Fehler); "Nein" laesst alles wie es war
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await shot(page, 'absagen-frage')
await knopf(page, 'Nein, Termin behalten').click()
await warteH1(page, 'Ihr Termin')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'nach "Nein" unveraendert')
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await knopf(page, 'Ja, Termin absagen').click()
await warteH1(page, 'Ihr Termin wurde abgesagt.')
const doc = buchungVon('max@example.com')
eq([doc.status, doc.abgesagtVon], ['abgesagt', 'gast'], 'abgesagt vom Gast')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G06 an den Gast')
eq(mailsAn(BETRIEB_MAIL).length, 2, 'K20 und K21 an den Betrieb')
await shot(page, 'abgesagt')
// erneut geoeffnet: abgesagt, keine Aktionen
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Abgesagt'), 'Status Abgesagt')
eq(await page.getByRole('button').count(), 0, 'keine Aktion fuer einen abgesagten Termin')
})
// ------------------------------------------------------------ 7. Verschieben
await check('verwalten_verschieben', async (page) => {
const b = await buchungBestaetigt()
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Termin verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
assert((await text(page)).includes('Ihr bisheriger Termin: Montag, 12.10.2026, 10:00 Uhr (Damenschnitt)'), 'bisheriger Termin')
const tag = (t) => page.locator(`button[data-tag="${t}"]`)
eq(await tag('2026-10-04').isDisabled(), true, 'Sonntag ohne freie Zeit')
eq(await tag('2026-10-13').isDisabled(), false, 'Dienstag frei')
await shot(page, 'verschieben-kalender')
// (a) derselbe Termin: die Zeit steht zur Auswahl (die eigene Buchung belegt sie nicht), der Server lehnt sie ab
await tag('2026-10-12').click()
await page.getByRole('heading', { name: 'Freie Zeiten am Montag, 12.10.2026' }).waitFor()
await page.locator('.t-zeit', { hasText: /^10:00$/ }).click()
await warteH1(page, 'Termin verschieben?')
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-meldung', { hasText: 'Das ist bereits der gebuchte Termin.' }).waitFor()
eq(new Date(buchungVon('max@example.com').startsAt).toISOString(), '2026-10-12T08:00:00.000Z', 'Termin unveraendert')
// (b) ein anderer Gast ist schneller: Zeit inzwischen vergeben, Auswahl bleibt mit Meldung stehen
await tag('2026-10-13').click()
await page.getByRole('heading', { name: 'Freie Zeiten am Dienstag, 13.10.2026' }).waitFor()
await page.locator('.t-zeit', { hasText: /^11:30$/ }).click()
await warteH1(page, 'Termin verschieben?')
const frage = await text(page)
assert(frage.includes('Bisher') && frage.includes('Montag, 12.10.2026, 10:00 Uhr') && frage.includes('Neu') && frage.includes('Dienstag, 13.10.2026, 11:30 Uhr'), `Rueckfrage: ${frage}`)
await shot(page, 'verschieben-frage')
await buche({ email: 'anderer@example.com', name: 'Anderer Gast', tag: '2026-10-13', zeit: '11:30' })
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Termin verschieben')
await page.locator('.t-meldung', { hasText: 'Diese Zeit ist leider nicht mehr frei.' }).waitFor()
await page.getByRole('heading', { name: 'Freie Zeiten am Dienstag, 13.10.2026' }).waitFor()
eq(await page.locator('.t-zeit', { hasText: /^11:30$/ }).count(), 0, '11:30 steht nicht mehr zur Auswahl')
// Monatswechsel: November, wieder zurueck
await page.getByRole('button', { name: 'Nächster Monat' }).click()
await page.locator('.t-monat', { hasText: 'November 2026' }).waitFor()
await page.getByRole('button', { name: 'Vorheriger Monat' }).click()
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
// (c) neue Zeit: Dienstag 14:00
await tag('2026-10-13').click()
await page.locator('.t-zeit', { hasText: /^14:00$/ }).click()
await warteH1(page, 'Termin verschieben?')
await knopf(page, 'Termin jetzt verschieben').click()
await warteH1(page, 'Ihr Termin wurde verschoben.')
const t = await text(page)
assert(t.includes('Dienstag, 13.10.2026') && t.includes('14:00 Uhr'), `neuer Termin in der Zusammenfassung: ${t}`)
eq(new Date(buchungVon('max@example.com').startsAt).toISOString(), '2026-10-13T12:00:00.000Z', 'Termin gespeichert (14:00 Berlin = 12:00 UTC)')
await shot(page, 'verschoben')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G07 an den Gast')
// Zurueck zur Uebersicht: neuer Stand vom Server
await knopf(page, 'Zur Terminübersicht').click()
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Dienstag, 13.10.2026'), 'Uebersicht mit dem neuen Termin')
})
// ------------------------------------------------------------ 8. Absagefrist vorbei: Hinweis, Widerruf bleibt
await check('frist_vorbei_hinweis', async (page) => {
const b = await buchungBestaetigt({ tag: '2026-10-05', zeit: '15:00' }) // heute in 5 Stunden, Absagefrist 24 Stunden
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
assert((await text(page)).includes('Absage nur telefonisch: 06301 1234'), 'Hinweis auf die Telefonnummer')
eq(await knopf(page, 'Termin absagen').count(), 0, 'kein Absagen')
eq(await knopf(page, 'Termin verschieben').count(), 0, 'kein Verschieben')
eq(await knopf(page, 'Vertrag widerrufen').count(), 1, 'Widerruf bleibt moeglich (unabhaengig von der Stornofrist)')
await shot(page, 'frist-vorbei')
})
await check('unternehmer_ohne_widerruf', async (page) => {
const b = await buchungBestaetigt({ vorzeitig: false })
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
eq(await knopf(page, 'Termin absagen').count(), 1, 'Absagen moeglich')
eq(await knopf(page, 'Vertrag widerrufen').count(), 0, 'Unternehmer: kein Widerruf')
}, { eingabe: salonEingabe({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) })
// ------------------------------------------------------------ 9. Widerruf (Browser in New York, Eingang in Berliner Zeit)
await check('widerruf_new_york_berliner_eingang', async (page) => {
const zone = await page.evaluate(() => new Intl.DateTimeFormat().resolvedOptions().timeZone)
eq(zone, 'America/New_York', 'Browser laeuft in New York')
const b = await buchungBestaetigt()
srv.setzeUhr('2026-10-05T08:30:00.000Z') // 10:30 Berlin = 04:30 New York
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
const t = await text(page)
assert(t.includes('Montag, 12.10.2026') && t.includes('10:00 Uhr'), `Termin in Berliner Zeit trotz New York: ${t}`)
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
// vorbefuellt: Name (aenderbar), E-Mail-Adresse und Buchungsnummer (nur zum Lesen)
eq(await page.locator('#t-f-name').inputValue(), 'Max Mustermann', 'Name vorbefuellt')
eq(await page.locator('#t-f-email').inputValue(), 'max@example.com', 'E-Mail vorbefuellt')
const nummer = await page.locator('#t-f-nummer').inputValue()
assert(/^[A-Z0-9]{8}$/.test(nummer), `Buchungsnummer vorbefuellt: ${nummer}`)
eq([await page.locator('#t-f-email').getAttribute('readonly') !== null, await page.locator('#t-f-nummer').getAttribute('readonly') !== null], [true, true], 'E-Mail und Nummer schreibgeschuetzt')
assert((await text(page)).includes(`Buchungsnummer ${nummer}).`) && (await text(page)).includes('Hiermit widerrufe ich'), 'Erklaerung mit Buchungsnummer')
eq(await knopf(page, 'Widerruf bestätigen').count(), 1, 'Knopf "Widerruf bestätigen"')
await shot(page, 'widerruf-formular')
// ohne Namen geht nichts raus
await page.locator('#t-f-name').fill(' ')
await knopf(page, 'Widerruf bestätigen').click()
await page.locator('#t-f-name-fehler', { hasText: 'Bitte geben Sie Ihren Namen an.' }).waitFor()
eq(await page.locator('#t-f-name').getAttribute('aria-invalid'), 'true', 'Name als fehlerhaft markiert')
eq(buchungVon('max@example.com').status, 'bestaetigt', 'nichts widerrufen ohne Namen')
await page.locator('#t-f-name').fill('Max Mustermann')
await knopf(page, 'Widerruf bestätigen').click()
await warteH1(page, 'Ihr Widerruf ist bei uns eingegangen.')
const eingang = await page.locator('.t-eingang').innerText()
assert(/^Eingang: 05\.10\.2026, 10:[0-5]\d Uhr$/.test(eingang), `Eingang in Berliner Zeit (nicht 04:xx aus New York): ${eingang}`)
assert((await text(page)).includes('per E-Mail an max@example.com geschickt'), 'Hinweis auf die Eingangsbestaetigung')
const doc = buchungVon('max@example.com')
eq([doc.status, Boolean(doc.widerrufenAt)], ['abgesagt', true], 'widerrufen')
await srv.warteAufVersand()
eq(mailsAn('max@example.com').length, 3, 'G01, G02 und G08 (Eingangsbestaetigung) an den Gast')
eq(mailsAn(BETRIEB_MAIL).length, 2, 'K20 und K21 an den Betrieb')
await shot(page, 'widerruf-eingang')
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' } })
// ------------------------------------------------------------ 10. Du-Form
await check('du_anrede', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
assert((await text(page)).includes('Bitte prüfe die Angaben und bestätige mit dem Knopf unten.'), 'Du-Form beim Bestaetigen')
await shot(page, 'du-bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Dein Termin ist bestätigt.')
await srv.warteAufVersand()
await oeffne(page, 'm', tokenAusMail('max@example.com', 'm'))
await warteH1(page, 'Dein Termin')
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
assert((await text(page)).includes('Hier kannst du den Vertrag über diesen Termin widerrufen'), 'Du-Form im Widerruf')
await shot(page, 'du-widerruf')
}, { eingabe: salonEingabe({ anrede: 'du' }) })
// ------------------------------------------------------------ 11. Farben und Fuss
await check('kopf_farben_kontrast', async (page) => {
const b = await buche()
const farben = []
for (const farbe of ['#fde68a', '#ea580c']) {
await srv.speichere(salonEingabe({ farbe }))
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const f = await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, fg: getComputedStyle(e).color }))
const grau = (rgb) => rgb.match(/\d+/g).slice(0, 3).map(Number)
const lum = (rgb) => grau(rgb).map((c) => c / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)).reduce((s, c, i) => s + c * [0.2126, 0.7152, 0.0722][i], 0)
const [x, y] = [lum(f.bg), lum(f.fg)]
const kontrast = (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
assert(kontrast >= 4.5, `${farbe}: Kontrast im Kopf nur ${kontrast.toFixed(2)} : 1`)
farben.push(f)
}
eq(farben[0].bg, 'rgb(253, 230, 138)', 'helle Betriebsfarbe im Kopf')
eq(farben[0].fg, 'rgb(17, 24, 39)', 'dunkle Schrift auf heller Farbe')
})
await check('fuss_impressum_datenschutz', async (page) => {
const b = await buche()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
const links = await page.locator('#fuss a').evaluateAll((as) => as.map((a) => ({ t: a.textContent, href: a.getAttribute('href'), rel: a.getAttribute('rel'), target: a.getAttribute('target') })))
eq(links, [
{ t: 'Impressum', href: 'https://salon-mueller.example/impressum', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Datenschutz', href: 'https://salon-mueller.example/datenschutz', rel: 'noopener noreferrer', target: '_blank' },
{ t: 'Impressum WEBklar', href: '/impressum.html', rel: 'noopener noreferrer', target: '_blank' },
], 'Fusszeile')
assert((await page.locator('#fuss').innerText()).includes('Terminbuchung über WEBklar'), 'Hinweis "Terminbuchung über WEBklar"')
await page.locator('#fuss').scrollIntoViewIfNeeded()
})
// ------------------------------------------------------------ 12. Netz weg, Grenze erreicht
await check('ausfall_und_grenze', async (page) => {
const b = await buche()
await page.route('**/termin/vorschau', (route) => route.abort())
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Das hat leider nicht geklappt')
assert((await text(page)).includes('Gerade nicht erreichbar.'), 'Meldung bei fehlendem Netz')
await shot(page, 'ausfall')
await page.unroute('**/termin/vorschau')
await knopf(page, 'Erneut versuchen').click()
await warteH1(page, 'Termin bestätigen')
// Grenze (429): Text des Servers, Knopf zum erneuten Versuch
await page.route('**/termin/bestaetigen', (route) => route.fulfill({ status: 429, contentType: 'application/json', body: JSON.stringify({ fehler: { _: 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' } }) }))
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Das hat leider nicht geklappt')
assert((await text(page)).includes('Zu viele Anfragen.'), 'Meldung bei 429')
eq(buchungVon('max@example.com').status, 'gehalten', 'nichts bestaetigt')
})
// ------------------------------------------------------------ 13. Kein Speicher, keine Cookies, nur die eigene Herkunft
await check('keine_cookies_kein_speicher_noindex', async (page, ctx) => {
const b = await buchungBestaetigt()
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'bereits bestätigt')
await oeffne(page, 'm', b.mToken)
await warteH1(page, 'Ihr Termin')
const speicher = await page.evaluate(() => ({ cookie: document.cookie, local: localStorage.length, session: sessionStorage.length, robots: document.querySelector('meta[name=robots]')?.content, referrer: document.querySelector('meta[name=referrer]')?.content }))
eq(speicher, { cookie: '', local: 0, session: 0, robots: 'noindex, nofollow', referrer: 'no-referrer' }, 'Speicher und Metadaten')
eq(await ctx.cookies(), [], 'Browser hat keine Cookies')
const dbs = await page.evaluate(() => (indexedDB.databases ? indexedDB.databases() : []))
eq(dbs, [], 'keine IndexedDB')
})
// ------------------------------------------------------------ 14. Handy (390 px)
await check('handy_390', async (page) => {
const b = await buche()
const pruefe = async (name) => {
assert(await passtInBreite(page), `${name}: Seite breiter als 390 px`)
const klein = await knoepfeGross(page)
eq(klein, [], `${name}: Knoepfe unter 40 px Hoehe`)
}
await oeffne(page, 'v', b.vToken)
await warteH1(page, 'Termin bestätigen')
await pruefe('bestaetigen')
await shot(page, '390-1-bestaetigen')
await knopf(page, 'Zahlungspflichtig bestätigen').click()
await warteH1(page, 'Ihr Termin ist bestätigt.')
await pruefe('bestaetigt')
await shot(page, '390-2-bestaetigt')
const m = tokenAusMail('max@example.com', 'm')
await srv.warteAufVersand()
await oeffne(page, 'm', tokenAusMail('max@example.com', 'm'))
await warteH1(page, 'Ihr Termin')
await pruefe('verwalten')
await shot(page, '390-3-verwalten')
await knopf(page, 'Termin verschieben').click()
await page.locator('.t-monat', { hasText: 'Oktober 2026' }).waitFor()
await page.locator('button[data-tag="2026-10-13"]').click()
await page.locator('.t-zeit').first().waitFor()
await pruefe('verschieben')
await shot(page, '390-4-verschieben')
await page.locator('.t-zeit').first().click()
await warteH1(page, 'Termin verschieben?')
await pruefe('verschieben-frage')
await shot(page, '390-5-verschieben-frage')
await oeffne(page, 'm', m)
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Termin absagen').click()
await warteH1(page, 'Termin wirklich absagen?')
await pruefe('absagen')
await shot(page, '390-6-absagen')
await knopf(page, 'Nein, Termin behalten').click()
await warteH1(page, 'Ihr Termin')
await knopf(page, 'Vertrag widerrufen').click()
await warteH1(page, 'Vertrag widerrufen')
await pruefe('widerruf')
await shot(page, '390-7-widerruf')
await knopf(page, 'Widerruf bestätigen').click()
await warteH1(page, 'Ihr Widerruf ist bei uns eingegangen.')
await pruefe('widerruf-eingang')
await shot(page, '390-8-widerruf-eingang')
await srv.zuruecksetzen({ uhrNeu: false })
const c = await buche({ email: 'c@example.com' })
srv.schiebeUhr(31 * 60 * 1000)
await oeffne(page, 'v', c.vToken)
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
await pruefe('abgelaufen')
await shot(page, '390-9-abgelaufen')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => h !== new URL(srv.url).host)
if (fremd.length) {
results.push(['nur_eigene_herkunft', false])
console.log(`FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}`)
} else {
results.push(['nur_eigene_herkunft', true])
console.log('ok nur_eigene_herkunft')
}
} 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)