- R53: Das Widget wartet vor jedem Absenden, bis das Formular-Token formMinSekunden plus 0,5 s alt
ist ("Einen Moment …", Knopf gesperrt); gilt nach dem Neuladen der Konfiguration und beim Wiederholen
nach "neu_laden". Kein stilles Erfolgs-201 ohne Buchung mehr.
- R54: "Vertrag widerrufen" im Fuß ist ein zweiter Knopf (44 px). Im Knopfmodus lädt das Widget die
Konfiguration beim Laden der Seite (ein Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten
Knopf "Vertrag widerrufen" neben dem Buchungsknopf, der den Dialog beim Widerrufsformular öffnet.
- Telefonlink im Widget und auf der Gästeseite: Text mit Buchstaben oder Mehrfachangaben wird nie zum
tel:-Link, "(0)" hinter der Landesvorwahl entfällt.
- Portal, Einstellungen: Kasten "Einbau auf deiner Website" mit Einbau-Code und Kopierknopf; Hinweistexte
zum Widerruf passen zu R55 (auch Anfragen).
- G02: Widerrufssatz nennt, dass der Knopf bis zum Beginn des Termins gilt und danach das Formular auf der
Website oder die E-Mail an den Betrieb.
- Einbau-Anleitung angepasst (siteKey und Code im Portal, Knopfmodus, Checkliste); Tests und Playwright.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
765 lines
44 KiB
JavaScript
765 lines
44 KiB
JavaScript
// 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
|
||
}
|
||
|
||
/**
|
||
* Tab-Reihenfolge = Anzeige: die bedienbaren Elemente der Karte stehen in DOM-Reihenfolge auch auf dem Bildschirm in Leserichtung
|
||
* (Zeile fuer Zeile, links nach rechts). Liefert die Paare, bei denen das nicht stimmt.
|
||
*/
|
||
async function reihenfolgeFehler(page) {
|
||
const k = await page.evaluate(() => [...document.querySelectorAll('#karte button, #karte a[href], #karte input, #karte textarea')]
|
||
.filter((e) => e.offsetParent !== null && !e.disabled && e.tabIndex >= 0)
|
||
.map((e) => { const r = e.getBoundingClientRect(); return { t: (e.textContent || e.id || '').trim().slice(0, 30), x: Math.round(r.left), y: Math.round(r.top) } }))
|
||
const fehler = []
|
||
for (let i = 1; i < k.length; i += 1) {
|
||
const [a, b] = [k[i - 1], k[i]]
|
||
const spaeterZeile = b.y > a.y + 4
|
||
const gleicheZeile = Math.abs(b.y - a.y) <= 4 && b.x > a.x
|
||
if (!spaeterZeile && !gleicheZeile) fehler.push(`${a.t} -> ${b.t}`)
|
||
}
|
||
return fehler
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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)
|
||
if (initScript) await page.addInitScript(initScript)
|
||
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')
|
||
// Ueberschrift ohne Fokusrahmen, Bedienelemente (Tab) mit sichtbarem Rahmen
|
||
eq(await page.evaluate(() => getComputedStyle(document.activeElement).outlineStyle), 'none', 'Ueberschrift ohne Fokusrahmen')
|
||
await page.keyboard.press('Tab')
|
||
eq(await page.evaluate(() => ({ tag: document.activeElement.tagName, stil: getComputedStyle(document.activeElement).outlineStyle, breite: getComputedStyle(document.activeElement).outlineWidth })),
|
||
{ tag: 'BUTTON', stil: 'solid', breite: '3px' }, 'Knopf mit sichtbarem Fokusrahmen')
|
||
|
||
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(await page.locator('#kopf').evaluate((e) => getComputedStyle(e).backgroundColor), 'rgb(15, 118, 110)', 'mitten in der Sitzung bleibt die Betriebsfarbe im Kopf')
|
||
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')
|
||
// zweiter Aufruf des Bestaetigungslinks: die Anfrage ist schon abgesendet (nicht "bereits bestaetigt")
|
||
await oeffne(page, 'v', f.vToken)
|
||
await warteH1(page, 'Ihre Anfrage wurde bereits abgesendet.')
|
||
const zweit = await text(page)
|
||
assert(zweit.includes('Sie erhalten eine E-Mail, sobald Salon Müller sie annimmt.') && !zweit.includes('bereits bestätigt'), `zweiter Aufruf der Anfrage: ${zweit}`)
|
||
eq(await page.getByRole('button').count(), 0, 'kein Knopf beim zweiten Aufruf')
|
||
await shot(page, 'anfrage-bereits-abgesendet')
|
||
// 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')
|
||
// § 356a BGB: der Widerruf ist ein gleichrangiger Knopf in derselben Reihe, kein Textlink
|
||
eq(await page.locator('.t-nav button').allTextContents(), ['Termin verschieben', 'Termin absagen', 'Vertrag widerrufen'], 'drei Knoepfe in einer Reihe')
|
||
assert((await knopf(page, 'Vertrag widerrufen').getAttribute('class')).includes('t-btn-sek'), 'Widerruf als sekundaerer Knopf')
|
||
assert((await knopf(page, 'Vertrag widerrufen').boundingBox()).height >= 43.5, 'Widerruf-Knopf mindestens 44 px hoch')
|
||
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge der Verwaltungsansicht')
|
||
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?')
|
||
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge der Rueckfrage')
|
||
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"')
|
||
eq(await reihenfolgeFehler(page), [], 'Tab-Reihenfolge des Widerrufsformulars')
|
||
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' } })
|
||
|
||
// ------------------------------------------------------------ 9b. Token vor dem ersten Aufruf weg; replaceState wirft
|
||
await check('token_vor_dem_ersten_aufruf_entfernt', async (page) => {
|
||
const b = await buche()
|
||
const adressen = []
|
||
// Das Skript ruft den Server erst nach replaceState: beim ersten Aufruf steht in der Adresszeile kein Token mehr
|
||
await page.route('**/termin/vorschau', async (route) => {
|
||
adressen.push(await page.evaluate(() => location.href))
|
||
await route.continue()
|
||
})
|
||
await oeffne(page, 'v', b.vToken)
|
||
await warteH1(page, 'Termin bestätigen')
|
||
eq(adressen, [SEITE], 'Adresse beim ersten Aufruf ohne #v= und #m=')
|
||
assert(!adressen.some((a) => /#[vm]=/.test(a)), 'Token in der Adresse beim Aufruf')
|
||
})
|
||
|
||
await check('replacestate_wirft_seite_laeuft_weiter', async (page) => {
|
||
const b = await buche()
|
||
await oeffne(page, 'v', b.vToken)
|
||
await warteH1(page, 'Termin bestätigen') // nicht "Wird geladen …" stehen geblieben
|
||
assert(!(await text(page)).includes('Wird geladen'), '"Wird geladen" darf nicht stehen bleiben')
|
||
await knopf(page, 'Zahlungspflichtig bestätigen').click()
|
||
await warteH1(page, 'Ihr Termin ist bestätigt.')
|
||
}, { initScript: () => { history.replaceState = () => { throw new Error('gesperrt') } } })
|
||
|
||
// ------------------------------------------------------------ 9c. Abgelaufen mitten in der Sitzung: Farbe und Anrede bleiben
|
||
await check('abgelaufen_mitten_in_der_sitzung_du', 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'), 'Du-Form vor dem Ablauf')
|
||
srv.schiebeUhr(31 * 60 * 1000)
|
||
await knopf(page, 'Zahlungspflichtig bestätigen').click()
|
||
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
|
||
eq(await page.locator('#kopf').evaluate((e) => ({ bg: getComputedStyle(e).backgroundColor, fg: getComputedStyle(e).color })), { bg: 'rgb(124, 58, 237)', fg: 'rgb(255, 255, 255)' }, 'Betriebsfarbe bleibt im Kopf')
|
||
assert((await text(page)).includes('Bitte buche deinen Termin noch einmal oder ruf uns an.'), `Du-Form bleibt: ${await text(page)}`)
|
||
eq(await page.locator('#fuss a').evaluateAll((as) => as.map((a) => a.textContent)), ['Impressum', 'Datenschutz', 'Impressum WEBklar'], 'Fusslinks des Betriebs bleiben')
|
||
await shot(page, 'abgelaufen-mitten-in-der-sitzung')
|
||
}, { eingabe: salonEingabe({ anrede: 'du', farbe: '#7c3aed' }) })
|
||
|
||
// ------------------------------------------------------------ 9d. Feindliche Betriebsangaben (nichts wird Link oder Markup)
|
||
// Name mit Markup, Telefon mit javascript: und Adresse mit Markup gehen durch die Pruefung der Einstellung (freie Texte) und kommen
|
||
// ueber speichere() an. Die Adressen (Impressum, Datenschutz, Website) lehnt die Pruefung ab (nur https): sie werden danach direkt im
|
||
// Dokument der Einstellung im Nachbau von Appwrite ersetzt (Altdaten / umgangene Pruefung), damit die Seite selbst geprueft wird.
|
||
const FEIND_NAME = '<img src=x onerror=window.__xss=1>Salon <b>&</b>'
|
||
await check('feindliche_betriebsangaben', async (page, ctx) => {
|
||
const b = await buche()
|
||
// Betriebsangaben im gespeicherten Dokument ersetzen (aw.seed ueberschreibt das Dokument), Zwischenspeicher des Servers leeren
|
||
const setzeBetrieb = (felder) => {
|
||
const doc = srv.aw.docs('termineConfig').get(srv.FEATURE)
|
||
const e = JSON.parse(doc.einstellung)
|
||
Object.assign(e.betrieb, felder)
|
||
srv.aw.seed('termineConfig', { ...doc, einstellung: JSON.stringify(e) })
|
||
srv.limitsZuruecksetzen()
|
||
}
|
||
setzeBetrieb({ impressumUrl: 'javascript:alert(1)', datenschutzUrl: 'http://evil.example/datenschutz', websiteUrl: 'http://evil.example/' })
|
||
|
||
const vorschau = page.waitForResponse((r) => r.url().endsWith('/termin/vorschau'))
|
||
await oeffne(page, 'v', b.vToken)
|
||
await warteH1(page, 'Termin bestätigen')
|
||
const antwort = (await (await vorschau).json()).betrieb
|
||
eq([antwort.impressumUrl, antwort.datenschutzUrl, antwort.name, antwort.telefon], ['javascript:alert(1)', 'http://evil.example/datenschutz', FEIND_NAME, 'javascript:alert(1)'], 'die feindlichen Werte erreichen die Seite')
|
||
eq(await page.locator('#betrieb').innerText(), FEIND_NAME, 'Name als Text, nicht als Markup')
|
||
eq(await page.locator('#kopf img, #karte img, #kopf b, #karte b').count(), 0, 'kein Element aus dem Namen oder der Adresse')
|
||
assert((await text(page)).includes('<b>Hauptstr. 1</b>'), 'Adresse als Text')
|
||
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
|
||
eq(await page.locator('#fuss a').evaluateAll((as) => as.map((a) => a.getAttribute('href'))), ['https://webklar.com/impressum'], 'weder javascript: noch http: im Fuss')
|
||
assert((await page.title()).includes(FEIND_NAME), 'Seitentitel als Text')
|
||
|
||
// abgelaufen: Website (http) ist kein Link, Telefon mit javascript: ist Text (kein Link), nichts wird ausgefuehrt
|
||
srv.schiebeUhr(31 * 60 * 1000)
|
||
srv.limitsZuruecksetzen()
|
||
await oeffne(page, 'v', b.vToken)
|
||
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
|
||
eq(await page.locator('#betrieb').innerText(), FEIND_NAME, 'Name im abgelaufenen Zustand als Text')
|
||
eq(await page.locator('#karte a').count(), 0, 'kein Link (Website http, Telefon javascript:)')
|
||
assert((await text(page)).includes('Telefon: javascript:alert(1)'), 'Telefon als Text')
|
||
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt (abgelaufen)')
|
||
await shot(page, 'feindliche-angaben')
|
||
|
||
// eine ordentliche Nummer wird ein tel:-Link, nur mit Ziffern und +
|
||
setzeBetrieb({ telefon: '+49 (0) 6301-1234' })
|
||
await oeffne(page, 'v', b.vToken)
|
||
await page.getByRole('link', { name: '+49 (0) 6301-1234' }).waitFor()
|
||
eq(await page.getByRole('link', { name: '+49 (0) 6301-1234' }).getAttribute('href'), 'tel:+4963011234', 'bereinigter tel:-Link: die nationale Null (0) entfaellt')
|
||
|
||
// freier Text mit Buchstaben ist keine Nummer, die man waehlen kann: Text ohne Link (dieselbe Regel wie im Widget)
|
||
setzeBetrieb({ telefon: '06301 1234 (ab 9 Uhr)' })
|
||
await oeffne(page, 'v', b.vToken)
|
||
await warteH1(page, 'Diese Reservierung ist abgelaufen.')
|
||
assert((await text(page)).includes('Telefon: 06301 1234 (ab 9 Uhr)'), 'Telefon als Text')
|
||
eq(await page.locator('#karte a').count(), 0, 'kein tel:-Link fuer freien Text')
|
||
}, { eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, name: FEIND_NAME, telefon: 'javascript:alert(1)', adresse: '<b>Hauptstr. 1</b>' } }) })
|
||
|
||
// ------------------------------------------------------------ 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')
|
||
// Anfrage, zweiter Aufruf des Bestaetigungslinks (Du-Form)
|
||
const f = await buche({ leistung: S_ANFRAGE, email: 'f@example.com', zeit: '14:00', vorzeitig: false })
|
||
await oeffne(page, 'v', f.vToken)
|
||
await warteH1(page, 'Anfrage bestätigen')
|
||
await knopf(page, 'Anfrage absenden').click()
|
||
await warteH1(page, 'Deine Anfrage ist eingegangen.')
|
||
await oeffne(page, 'v', f.vToken)
|
||
await warteH1(page, 'Deine Anfrage wurde bereits abgesendet.')
|
||
assert((await text(page)).includes('Du erhältst eine E-Mail, sobald Salon Müller sie annimmt.'), 'Du-Form beim zweiten Aufruf der Anfrage')
|
||
}, { 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: 'https://webklar.com/impressum', 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`)
|
||
eq(await reihenfolgeFehler(page), [], `${name}: Tab-Reihenfolge entspricht nicht der Anzeige`)
|
||
}
|
||
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)
|