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