758 lines
44 KiB
JavaScript
758 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:+49063011234', 'bereinigter tel:-Link')
|
||
}, { 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)
|