Files
Webklar-Kundenbereich/test/ui/termine-portal.ui.mjs
T
ClaudeandClaude Sonnet 5.5 43e4826ae3 Terminplaner: Portal-Ansicht, Kachel, Termine
Kachel in Funktionen (Kommt bald / Aus / Aktiv mit Anfragen-Zahl / Abgeschaltet),
Ansicht #termine als Unterseite von Funktionen: Projektauswahl, Vorlagenauswahl
beim ersten Oeffnen, Reiter-Geruest (Platzhalter fuer Leistungen, Team & Plaetze,
Einstellungen) und der Reiter Termine mit Tag/Woche/Monat, Filtern, Aktionen mit
Rueckfragen in der Seite, Termin eintragen und Zeit sperren.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

889 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playwright-Checks der Portal-Ansicht Terminplaner (public/termine.js, termine-view.js, termine.css, kachel in funktionen.js) – Aufgabe 21a:
// Kachel, Vorlagenauswahl, Reiter-Geruest und Reiter "Termine". Nicht Teil von npm test.
// Aufruf: nice node test/ui/termine-portal.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/portal-*.png (bei Fehlern fehler-portal-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Zwei Arten von Pruefungen auf demselben UI-Testserver (test/ui/termineTestServer.mjs):
// - "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten; der Server liefert nur dashboard.html und die Dateien aus public/.
// - "echt_*": die echten Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite; nur die uebrigen Portal-Schnittstellen
// (/api/auth/me usw.) mockt page.route. Portal-Sitzung per Cookie (srv.portalCookie()).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln).
// Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import {
startTermineUiServer, salonEingabe,
R_ANNA, R_BEN, S_HERREN, S_DAMEN, 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 (/^(portal|fehler-portal)-.*\.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 JETZT = new Date('2026-10-05T08:00:00.000Z')
// ---------------------------------------------------------------- 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}`)
}
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
async function shot(page, name) {
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
const fenster = page.viewportSize()
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/portal-${name}.png` })
await page.setViewportSize(fenster)
}
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: Knoepfe in Zeilen tragen den Kontext im aria-label
const reihe = (page, text) => page.locator('li.tm-eintrag', { hasText: text })
const meldung = (page) => page.locator('.tm-meldung:not([hidden])')
const pillen = (page) => page.locator('.tm-pille').allInnerTexts()
const textVon = (loc) => loc.innerText()
/** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
// Berliner Wanduhr im Oktober 2026 (MESZ, bis 25.10.) als ISO-Zeit
const utc = (tag, zeit) => new Date(`${tag}T${zeit}:00+02:00`).toISOString()
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
verfuegbar: true,
projekte: [{ projectId: 'p1', projectName: 'Salon Müller', liveUrl: 'https://salon-mueller.example', features: { news: { state: 'off' }, termine: { state: 'active' } } }],
einstellung: srv.einstellung(),
vorlagen: [
{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben, Styling.' },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', beschreibung: 'Behandlungen und Massagen.' },
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
})
return fx
}
/** Buchung in der Form von GET /api/termine/buchungen. */
function buchung(id, tag, zeit, dauer, o = {}) {
const startsAt = utc(tag, zeit)
const b = {
id, nummer: id.toUpperCase().padEnd(8, 'X').slice(0, 8), status: 'bestaetigt', quelle: 'online', startsAt, endsAt: new Date(new Date(startsAt).getTime() + dauer * 60000).toISOString(),
datum: tag, zeit, leistungId: S_DAMEN, leistung: 'Damenschnitt', dauer, titel: '', person: 'Anna', ressourceId: R_ANNA, gastName: 'Max Mustermann', gastEmail: 'max@example.com',
gastTelefon: '06301 555', gastNachricht: '', erinnerung: false, zustellFehler: false, angenommenAt: null, widerrufenAt: null, abgesagtVon: '', absageGrund: '', ...o,
}
b.titel = b.quelle === 'sperre' ? (o.titel || 'Gesperrt') : b.gastName ? `${b.leistung}: ${b.gastName}` : b.leistung
return b
}
/** Alle Status in einer Woche (Mo 2026-10-05 bis So 2026-10-11) plus eine Sperre. */
function alleStatus() {
return [
buchung('bnichter', '2026-10-05', '07:00', 30, { status: 'nichterschienen', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Jonas Weber' }),
buchung('berledigt', '2026-10-05', '08:00', 45, { status: 'erledigt', gastName: 'Petra Klein' }),
buchung('bbegonnen', '2026-10-05', '09:00', 30, { leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Karl Ohr' }),
buchung('bsperre', '2026-10-05', '12:00', 60, { quelle: 'sperre', status: 'bestaetigt', leistung: '', person: 'Ben', gastName: '', gastEmail: '', gastTelefon: '', titel: 'Mittagspause' }),
buchung('bkunft', '2026-10-05', '14:00', 45, { zustellFehler: true, gastName: 'Hans Gast' }),
buchung('banfrage', '2026-10-05', '15:00', 90, { status: 'angefragt', leistung: 'Brautfrisur', gastName: 'Lea Braun', gastNachricht: 'Hochzeit am 24.10., bitte mit Probetermin.' }),
buchung('bgehalten', '2026-10-05', '17:00', 30, { status: 'gehalten', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Tim Berg' }),
buchung('babgesagt', '2026-10-06', '10:00', 30, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krankheit', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Fritz Alt' }),
buchung('bwiderruf', '2026-10-06', '11:00', 45, { status: 'abgesagt', abgesagtVon: 'gast', widerrufenAt: utc('2026-10-04', '12:00'), absageGrund: 'Widerruf erklärt von: Eva Rot (eingegangen 04.10.2026 12:00)', gastName: 'Eva Rot' }),
buchung('babgelehnt', '2026-10-06', '14:00', 90, { status: 'abgelehnt', leistung: 'Brautfrisur', gastName: 'Uta Neu' }),
buchung('bverfallen', '2026-10-06', '15:00', 30, { status: 'verfallen', leistung: 'Kinderschnitt', person: 'Ben', gastName: 'Nils Spät' }),
buchung('bvermerk', '2026-10-06', '16:00', 45, { absageGrund: 'Widerruf nach Ablauf der Frist erklärt von: Anke Zu', gastName: 'Anke Zu' }),
buchung('bmanuell', '2026-10-08', '09:00', 30, { quelle: 'manuell', leistung: 'Herrenschnitt', person: 'Ben', gastName: 'Walter Laufkunde', gastEmail: '', gastTelefon: '' }),
]
}
/** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */
async function fixtureAntwort(fx, route, req, url, json) {
const p = url.pathname
const q = url.searchParams
const m = req.method()
if (p === '/api/funktionen') return json({ projects: fx.projekte, termine: { verfuegbar: fx.verfuegbar } })
if (p === '/api/termine/einstellungen' && m === 'GET') return json(fx.daten())
if (p === '/api/termine/einstellungen/vorlage' && m === 'POST') {
fx.aufrufe.push(['vorlage', req.postDataJSON()])
fx.einstellung = srv.einstellung()
fx.fehlt = ['betrieb.telefon', 'betrieb.adresse']
return json({ einstellung: fx.einstellung, version: 1, fehlt: fx.fehlt }, 201)
}
if (p === '/api/termine/buchungen' && m === 'GET') {
fx.aufrufe.push(['liste', Object.fromEntries(q)])
const status = q.get('status') ? q.get('status').split(',') : null
const liste = fx.buchungen.filter((b) => b.datum >= q.get('von') && b.datum <= q.get('bis') && (!status || status.includes(b.status)))
return json({ buchungen: liste.sort((a, b) => (a.startsAt < b.startsAt ? -1 : 1)) })
}
const aktion = /^\/api\/termine\/buchungen\/([^/]+)\/aktion$/.exec(p)
if (aktion && m === 'POST') {
const body = req.postDataJSON()
fx.aufrufe.push(['aktion', aktion[1], body])
const b = fx.buchungen.find((x) => x.id === aktion[1])
if (body.aktion === 'annehmen') { b.status = 'bestaetigt'; b.angenommenAt = JETZT.toISOString() }
if (body.aktion === 'absagen') Object.assign(b, { status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: body.grund })
return json({ status: b.status })
}
return json({})
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
/**
* Eine Pruefung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture() ⇒ alle
* Schnittstellen aus der Fixture; sonst die echten Router mit Portal-Cookie), reset (Optionen fuer srv.zuruecksetzen).
*/
async function check(name, fn, { context = {}, fixture = null, reset = {} } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
await srv.zuruecksetzen(reset)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(JETZT)
if (!fixture) await ctx.addCookies([await srv.portalCookie()])
const page = await ctx.newPage()
page.fx = fixture
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')) hosts.add(u.host) } catch { /* data: u. a. */ } })
await page.route('**/api/**', async (route) => {
const req = route.request()
const url = new URL(req.url())
const p = url.pathname
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return fixture ? fixtureAntwort(fixture, route, req, url, json) : route.continue()
return portalHilfe(p, json)
})
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-portal-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
}
}
const oeffne = async (page, hash) => {
await page.goto(`${srv.url}/dashboard.html${hash}`)
}
const warteAnsicht = async (page) => {
await page.locator('#view-termine.active').waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
const JSON_KOPF = { 'content-type': 'application/json', origin: srv.url }
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', ...o }
srv.limitsZuruecksetzen()
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: true }, kontakt_url: '', formToken: conf.formToken,
}),
})
eq(res.status, 201, `Buchung ${v.tag} ${v.zeit} (${await res.clone().text()})`)
await srv.warteAufVersand()
const mails = srv.mails().filter((m) => m.to.some((t) => t.address === v.email)).reverse()
let token = null
for (const mail of mails) {
const treffer = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(`${mail.text}\n${mail.html}`)
if (treffer) { token = treffer[1]; break }
}
assert(token, `kein Bestaetigungslink fuer ${v.email}`)
const best = await fetch(`${API}/termin/bestaetigen`, { method: 'POST', headers: JSON_KOPF, body: JSON.stringify({ token }) })
eq(best.status, 200, 'Bestaetigung')
await srv.warteAufVersand()
return srv.buchungen().find((b) => b.gastEmail === v.email && gleichZeit(b.startsAt, utc(v.tag, v.zeit)))
}
const gleichZeit = (a, b) => new Date(a).getTime() === new Date(b).getTime() // der Nachbau speichert Datumswerte als +00:00
const mailsAn = (adresse) => srv.mails().filter((m) => m.to.some((t) => t.address === adresse))
const buchungMit = (id) => srv.buchungen().find((b) => b.$id === id)
async function pruefungen() {
// ============================================================ Fixture
// ------------------------------------------------------------ Kachel in Funktionen
await check('fx_kachel_zustaende', async (page) => {
// Schalter aus ⇒ „Kommt bald“
await oeffne(page, '#funktionen')
await page.locator('.fn-tiles').waitFor()
const soon = page.locator('.fn-tile-soon', { hasText: 'Terminplaner' })
eq(await soon.count(), 1, 'Kommt-bald-Kachel')
assert((await textVon(soon)).includes('Kommt bald'), 'Pille Kommt bald')
eq(await page.locator('[data-fn-tile="termine"]').count(), 0, 'keine echte Kachel')
eq(await page.locator('[data-view="termine"]').count(), 0, 'kein Knopf zur Ansicht')
}, { fixture: Object.assign(neueFixture(), { verfuegbar: false }) })
await check('fx_kachel_aus_aktiv_abgeschaltet', async (page) => {
const fx = page.fx
// Aus
fx.projekte[0].features.termine = { state: 'off' }
await oeffne(page, '#funktionen')
let kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
let t = await textVon(kachel)
assert(t.includes('Aus') && t.includes('Wir schalten den Terminplaner für dich frei.'), `Aus: ${t}`)
await knopf(kachel, 'Chat öffnen').waitFor()
eq(await kachel.locator('[data-view="termine"]').count(), 0, 'Aus: kein Weg in die Ansicht')
// Aktiv mit zwei Anfragen
fx.projekte[0].features.termine = { state: 'active' }
fx.buchungen = alleStatus().map((b) => (b.id === 'bkunft' ? { ...b, status: 'angefragt' } : b))
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByText('2 Anfragen warten').waitFor()
t = await textVon(kachel)
assert(t.includes('Aktiv') && t.includes('Termine öffnen'), `Aktiv: ${t}`)
await shot(page, 'kachel-aktiv')
// eine Anfrage ⇒ Einzahl
fx.buchungen = alleStatus()
await page.reload()
await page.locator('[data-fn-tile="termine"]').getByText('1 Anfrage wartet').waitFor()
// keine Anfrage ⇒ keine Zeile
fx.buchungen = []
await page.reload()
await page.locator('[data-fn-tile="termine"]').waitFor()
await page.waitForTimeout(150)
eq(await page.locator('[data-fn-tile="termine"] [data-termine-count]:not([hidden])').count(), 0, 'ohne Anfragen keine Zahl')
// Abgeschaltet
fx.projekte[0].features.termine = { state: 'disabled' }
await page.reload()
kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
t = await textVon(kachel)
assert(t.includes('Abgeschaltet') && t.includes('abgeschaltet') && t.includes('Termine ansehen'), `Abgeschaltet: ${t}`)
await shot(page, 'kachel-abgeschaltet')
}, { fixture: neueFixture() })
// ------------------------------------------------------------ Vorlage waehlen
await check('fx_vorlage_waehlen', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// erstes Oeffnen ohne Einstellung: Auswahl statt Reiter
eq(await page.locator('.tm-reiterleiste').count(), 0, 'noch keine Reiter')
eq(await page.locator('input[name="termine-vorlage"]').count(), 3, 'Vorlagen aus der Antwort')
const start = knopf(page, 'Mit dieser Vorlage starten')
eq(await start.isDisabled(), true, 'Start erst nach der Wahl')
await shot(page, 'vorlage-waehlen')
await page.getByLabel('Friseur und Salon').check()
eq(await start.isDisabled(), false, 'Start nach der Wahl')
await start.click()
await page.locator('.tm-reiterleiste').waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'vorlage'), [['vorlage', { projectId: 'p1', vorlage: 'salon' }]], 'POST der Vorlage')
assert((await textVon(meldung(page))).includes('„Friseur und Salon“'), 'Meldung nennt die Vorlage')
// Pflichtangaben fehlen ⇒ Hinweis mit Weg zu den Einstellungen
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('Noch nicht buchbar') && hinweis.includes('deine Telefonnummer und deine Anschrift'), `Hinweis: ${hinweis}`)
await knopf(page.locator('.tm-box-warn'), 'Zu den Einstellungen').click()
eq(await page.locator('#tm-tab-einstellungen').getAttribute('aria-selected'), 'true', 'Reiter Einstellungen gewaehlt')
await shot(page, 'vorlage-angelegt')
}, { fixture: Object.assign(neueFixture(), { einstellung: null }) })
// ------------------------------------------------------------ Projektauswahl
await check('fx_projektwahl', async (page) => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('aktiv für 1 von 2 Websites'), 'Kachel zaehlt die Websites')
eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p2', 'Kachel zeigt auf die aktive Website')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
// andere Website ohne Terminplaner: Hinweis statt Reiter
await page.locator('#termine-project').selectOption('p1')
await page.getByText('noch nicht freigeschaltet').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
// Auswahl bleibt beim erneuten Oeffnen
await page.evaluate(() => { window.location.hash = '#funktionen' })
await page.locator('#view-funktionen.active').waitFor()
await page.evaluate(() => { window.location.hash = '#termine' })
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl bleibt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'off' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
]
return fx
})(),
})
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const tabs = page.getByRole('tab')
eq(await tabs.allInnerTexts(), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'], 'vier Reiter')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Termine zuerst')
eq(await page.getByRole('tabpanel').getAttribute('aria-labelledby'), 'tm-tab-termine', 'Panel gehoert zum Reiter')
// Tastatur: Pfeil rechts wechselt (Platzhalter), Pos1 zurueck
await page.locator('#tm-tab-termine').focus()
await page.keyboard.press('ArrowRight')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Leistungen', 'Pfeil rechts')
assert((await textVon(page.getByRole('tabpanel'))).includes('Leistungen'), 'Platzhalter-Panel')
await page.keyboard.press('End')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Einstellungen', 'Ende')
await page.keyboard.press('ArrowRight')
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Termine', 'Pfeil rechts vom letzten Reiter springt zum ersten')
// Auswahl bleibt beim Verlassen der Ansicht erhalten
await page.getByRole('tab', { name: 'Team & Plätze' }).click()
await page.evaluate(() => { window.location.hash = '#funktionen' })
await page.locator('#view-funktionen.active').waitFor()
await page.evaluate(() => { window.location.hash = '#termine' })
await page.locator('#view-termine.active').waitFor()
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.getByRole('tab', { selected: true }).innerText(), 'Team & Plätze', 'Reiter bleibt')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Liste mit allen Status (Fixture)
await check('fx_liste_alle_status', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// Tag: heute, Montag 5. Oktober, in Zeitfolge
eq((await textVon(page.locator('.tm-zeitraum'))), 'Montag, 5. Oktober 2026', 'Titel Tag')
await page.locator('li.tm-eintrag').first().waitFor()
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), ['bnichter', 'berledigt', 'bbegonnen', 'bsperre', 'bkunft', 'banfrage', 'bgehalten'], 'Tagesliste in Zeitfolge')
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-05' }, 'Abfrage nur fuer heute')
// Woche: alle acht Status und die Sperre
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-10-11' }, 'Abfrage fuer die Woche')
eq(await textVon(page.locator('.tm-zeitraum')), '5.–11. Oktober 2026', 'Titel Woche')
const alle = await pillen(page)
for (const status of ['Reserviert', 'Angefragt', 'Bestätigt', 'Erledigt', 'Nicht erschienen', 'Abgesagt', 'Abgelehnt', 'Verfallen', 'Gesperrt']) assert(alle.includes(status), `Pille ${status} fehlt: ${alle.join(', ')}`)
eq(await page.locator('.tm-tag').count(), 7, 'sieben Tage, auch leere')
const leer = await textVon(page.locator('.tm-tag[data-tag="2026-10-07"]'))
assert(leer.includes('Mittwoch, 7. Oktober') && leer.includes('Keine Termine'), `leerer Tag: ${leer}`)
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-05"] .tm-tag-kopf'))).startsWith('Heute · Montag, 5. Oktober'), 'Heute-Kopf')
assert((await textVon(page.locator('.tm-tag[data-tag="2026-10-06"] .tm-tag-kopf'))).startsWith('Morgen · Dienstag, 6. Oktober'), 'Morgen-Kopf')
// Sperre: Block „Gesperrt“ mit Titel, ohne Gastaktionen und ohne Details
const sperre = reihe(page, 'Mittagspause')
const st = await textVon(sperre)
assert(st.includes('Gesperrt') && st.includes('Ben') && st.includes('12:00–13:00'), `Sperre: ${st}`)
eq(await sperre.locator('[data-aktion]').allInnerTexts(), ['Sperre aufheben'], 'Sperre: nur aufheben')
eq(await sperre.locator('details').count(), 0, 'Sperre ohne Gastdetails')
// Zeilen: Uhrzeit mit Ende, Leistung, Person, Gast
const kunft = await textVon(reihe(page, 'Hans Gast'))
assert(kunft.includes('14:00–14:45') && kunft.includes('Damenschnitt') && kunft.includes('Anna · Hans Gast') && kunft.includes('Bestätigt'), `Zeile: ${kunft}`)
assert(kunft.includes('Bestätigung nicht zugestellt'), 'Hinweis Bestätigung nicht zugestellt')
eq(await page.locator('li.tm-eintrag:not([data-id="bkunft"]) .tm-warn').count(), 0, 'Hinweis nur an der betroffenen Zeile')
// absageGrund nur bei Status abgesagt; Vermerk an einem bestehenden Termin bleibt unsichtbar
const abgesagt = await textVon(reihe(page, 'Fritz Alt'))
assert(abgesagt.includes('Von dir abgesagt') && abgesagt.includes('Grund: Krankheit'), `Absage: ${abgesagt}`)
const widerruf = await textVon(reihe(page, 'Eva Rot'))
assert(widerruf.includes('Vom Gast widerrufen'), `Widerruf: ${widerruf}`)
const vermerk = await textVon(reihe(page, 'Anke Zu'))
assert(!vermerk.includes('Widerruf nach Ablauf') && !vermerk.includes('Grund'), `Vermerk darf nicht erscheinen: ${vermerk}`)
assert((await textVon(reihe(page, 'Walter Laufkunde'))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
// Knoepfe je Status
const aktionen = async (id) => page.locator(`li.tm-eintrag[data-id="${id}"] [data-aktion]`).evaluateAll((l) => l.map((e) => e.dataset.aktion))
eq(await aktionen('banfrage'), ['annehmen', 'ablehnen', 'verschieben', 'anonymisieren'], 'Anfrage')
eq(await aktionen('bkunft'), ['verschieben', 'absagen', 'anonymisieren'], 'bestaetigt in der Zukunft')
eq(await aktionen('bbegonnen'), ['nichterschienen', 'anonymisieren'], 'bestaetigt, begonnen')
eq(await aktionen('bgehalten'), [], 'Reservierung')
eq(await aktionen('bverfallen'), ['anonymisieren'], 'verfallen')
await shot(page, 'liste-woche-desktop')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Filter, Navigation, Monat
await check('fx_filter_navigation', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.getByRole('button', { name: 'Monat', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bmanuell"]').waitFor()
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-01', bis: '2026-10-31' }, 'Abfrage Monat')
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Titel Monat')
eq(await page.locator('.tm-tag').count(), 3, 'Monat zeigt nur Tage mit Terminen')
// Filter ⇒ Statusliste an der Schnittstelle
const filter = async (name, status, erwartet) => {
await page.getByRole('button', { name, exact: true }).click()
await page.waitForFunction((n) => document.querySelector('.tm-chip[aria-pressed="true"]')?.textContent === n, name)
await page.waitForTimeout(100)
eq(fx.aufrufe.at(-1)[1].status, status, `Filter ${name}`)
eq((await pillen(page)).filter((p) => p !== 'Gesperrt').every((p) => erwartet.includes(p)), true, `Pillen unter ${name}: ${(await pillen(page)).join(', ')}`)
}
await filter('Angefragt', 'angefragt', ['Angefragt'])
await filter('Bestätigt', 'bestaetigt', ['Bestätigt'])
await filter('Abgesagt', 'abgesagt,abgelehnt,verfallen', ['Abgesagt', 'Abgelehnt', 'Verfallen'])
await filter('Offen', 'angefragt,bestaetigt', ['Angefragt', 'Bestätigt'])
await page.getByRole('button', { name: 'Alle', exact: true }).click()
await page.waitForTimeout(100)
assert(fx.aufrufe.at(-1)[1].status === undefined, 'Alle: keine Statusliste')
// Navigation: Monat vor, Heute, Datumssprung
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
await page.locator('.tm-leer').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'November 2026', 'naechster Monat')
eq(await textVon(page.locator('.tm-leer')), 'Keine Termine in diesem Monat.', 'Leermeldung Monat')
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.locator('li.tm-eintrag').first().waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Oktober 2026', 'Heute')
await page.getByRole('button', { name: 'Tag', exact: true }).click()
await page.locator('#tm-sprung').fill('2026-10-06')
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Datumssprung')
await page.getByRole('button', { name: 'Zurück', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="bkunft"]').waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Montag, 5. Oktober 2026', 'ein Tag zurueck')
// Ansicht und Filter bleiben beim Wechsel der Ansicht gesetzt
eq(await page.getByRole('button', { name: 'Alle', exact: true }).getAttribute('aria-pressed'), 'true', 'Filter Alle gedrueckt')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Anfrage annehmen (Fixture)
await check('fx_anfrage_annehmen', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
// Banner „1 Anfrage wartet“ und Weg zu allen Anfragen
const banner = page.locator('.tm-box-info')
await banner.getByText('1 Anfrage wartet').waitFor()
await knopf(banner, 'Anfragen ansehen').click()
await page.waitForFunction(() => document.querySelectorAll('li.tm-eintrag').length === 1) // die Liste der Tagesansicht steht noch bis die neue da ist
eq(await textVon(page.locator('.tm-zeitraum')), 'Offene Anfragen', 'Titel Anfragen')
eq(fx.aufrufe.at(-1)[1], { projectId: 'p1', von: '2026-10-05', bis: '2026-12-06', status: 'angefragt' }, 'Abfrage: ab heute, 62 Tage, nur Anfragen')
eq(await page.locator('li.tm-eintrag').count(), 1, 'nur die Anfrage')
const zeile = reihe(page, 'Lea Braun')
// Details zeigen Kontakt und Nachricht als Text
await zeile.locator('summary').click()
const details = await textVon(zeile.locator('dl'))
assert(details.includes('max@example.com') && details.includes('Hochzeit am 24.10., bitte mit Probetermin.') && details.includes('90 Minuten'), `Details: ${details}`)
await shot(page, 'anfrage-offen')
await knopf(zeile, 'Annehmen').click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).startsWith('Anfrage angenommen.'), 'Meldung nach dem Annehmen')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'banfrage', { projectId: 'p1', aktion: 'annehmen' }]], 'POST annehmen')
// Liste neu geladen: keine Anfrage mehr, Banner weg
await page.getByText('Es warten keine Anfragen.').waitFor()
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
await knopf(page, 'Zurück zur Übersicht').click()
await page.locator('li.tm-eintrag[data-id="banfrage"][data-status="bestaetigt"]').waitFor()
await shot(page, 'anfrage-angenommen')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }) })
// ------------------------------------------------------------ Rueckfrage in der Seite, Fremdtext nur als Text
await check('fx_rueckfrage_und_text', async (page) => {
const fx = page.fx
fx.buchungen = [
buchung('bxss', '2026-10-05', '14:00', 45, { gastName: '<img src=x onerror=window.__xss=1>Max', gastNachricht: '<b>fett</b> & <script>window.__xss=2</script>', person: '<i>Anna</i>', leistung: 'Schnitt <u>1</u>' }),
buchung('bziel', '2026-10-05', '16:00', 30, { gastName: 'Zoe Ziel' }),
]
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('li.tm-eintrag[data-id="bxss"]').waitFor()
eq(await page.evaluate(() => window.__xss), undefined, 'kein Skript ausgefuehrt')
eq(await page.locator('#termine-body img, #termine-body b, #termine-body i, #termine-body u, #termine-body script').count(), 0, 'Fremdtext erzeugt keine Elemente')
assert((await textVon(page.locator('li.tm-eintrag[data-id="bxss"]'))).includes('<img src=x onerror=window.__xss=1>Max'), 'Text sichtbar als Text')
// Absagen: Rueckfrage in der Seite (kein Browserdialog), Grund ist Pflicht
const zeile = page.locator('li.tm-eintrag[data-id="bziel"]')
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
const frage = zeile.locator('.tm-frage-box')
await frage.waitFor()
assert((await textVon(frage)).includes('Termin absagen?'), 'Rueckfrage')
eq(await page.evaluate(() => document.activeElement.tagName), 'SELECT', 'Fokus im Grund-Feld')
await knopf(frage, 'Termin absagen').click()
assert((await textVon(frage)).includes('Bitte wähle einen Grund aus.'), 'Grund ist Pflicht')
eq(fx.aufrufe.filter((a) => a[0] === 'aktion').length, 0, 'noch nichts gesendet')
await frage.getByLabel('Grund').selectOption('Krankheit')
await shot(page, 'rueckfrage-absagen')
// Abbrechen: Rueckfrage weg, Fokus zurueck am Knopf
await knopf(frage, 'Abbrechen').click()
eq(await zeile.locator('.tm-frage-box').count(), 0, 'Rueckfrage zu')
eq(await page.evaluate(() => document.activeElement.dataset.aktion), 'absagen', 'Fokus zurueck am Knopf')
// Nur eine Rueckfrage zugleich
await knopf(zeile, 'Absagen: Damenschnitt, 05.10.2026 um 16:00 Uhr').click()
await knopf(page.locator('li.tm-eintrag[data-id="bxss"]'), 'Absagen: Schnitt <u>1</u>, 05.10.2026 um 14:00 Uhr').click()
eq(await page.locator('.tm-frage-box').count(), 1, 'eine Rueckfrage')
// Absagen mit Grund
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Grund').selectOption('Betriebliche Gründe')
await page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box').getByLabel('Nachricht an den Gast (optional)').fill('Tut uns leid.')
await knopf(page.locator('li.tm-eintrag[data-id="bxss"] .tm-frage-box'), 'Termin absagen').click()
await meldung(page).waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'aktion'), [['aktion', 'bxss', { projectId: 'p1', aktion: 'absagen', grund: 'Betriebliche Gründe', nachricht: 'Tut uns leid.' }]], 'POST absagen')
}, { fixture: neueFixture() })
// ------------------------------------------------------------ App-Ansicht 390 px
await check('fx_app_390', async (page) => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByText('1 Anfrage wartet').waitFor()
eq(await passtInBreite(page), true, 'Funktionen: kein Streifen rechts')
await shot(page, '390-1-funktionen')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('li.tm-eintrag').first().waitFor()
// Unterseite von Funktionen: Titel „Terminplaner“, Zurueck-Pfeil „Funktionen“, Tab Funktionen markiert
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse #termine')
eq(await textVon(page.locator('#view-termine .kb-head h1')), 'Terminplaner', 'Ueberschrift')
eq(await textVon(page.locator('#kb-app-title')), 'Terminplaner', 'Titel der App-Leiste')
eq(await page.locator('#kb-back').isVisible(), true, 'Zurueck-Pfeil sichtbar')
eq(await textVon(page.locator('#kb-back span')), 'Funktionen', 'Zurueck zu Funktionen')
eq(await page.locator('.kb-nav-item.active').getAttribute('data-view'), 'funktionen', 'Tab Funktionen markiert')
eq(await page.evaluate(() => document.body.dataset.kbView), 'termine', 'Ansicht termine')
eq(await passtInBreite(page), true, 'Termine: kein Streifen rechts')
// Reiter als 2×2-Raster, grosse Touch-Ziele
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button, #view-termine summary')]
.filter((e) => e.offsetParent !== null && !e.classList.contains('tm-link-knopf') && e.getBoundingClientRect().height < 43.5)
.map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knoepfe mindestens 44 px hoch')
const tabs = await page.locator('.tm-reiter').evaluateAll((l) => l.map((e) => Math.round(e.getBoundingClientRect().top)))
eq(tabs[0] === tabs[1] && tabs[2] === tabs[3] && tabs[2] > tabs[0], true, 'Reiter in zwei Zeilen')
await shot(page, '390-2-termine')
// Woche, Anfrage mit Rueckfrage
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="babgesagt"]').waitFor()
eq(await passtInBreite(page), true, 'Woche: kein Streifen rechts')
await shot(page, '390-3-woche')
const zeile = page.locator('li.tm-eintrag[data-id="banfrage"]')
await knopf(zeile, 'Ablehnen: Brautfrisur, 05.10.2026 um 15:00 Uhr').click()
await zeile.locator('.tm-frage-box').waitFor()
eq(await passtInBreite(page), true, 'Rueckfrage: kein Streifen rechts')
await zeile.scrollIntoViewIfNeeded()
await shot(page, '390-4-ablehnen')
// Zurueck-Pfeil fuehrt zu Funktionen
await page.locator('#kb-back').click()
await page.locator('#view-funktionen.active').waitFor()
eq(await page.evaluate(() => document.body.dataset.kbView), 'funktionen', 'zurueck in Funktionen')
}, { fixture: Object.assign(neueFixture(), { buchungen: alleStatus() }), context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ============================================================ Echter Server
await check('echt_kachel_vorlage_und_liste', async (page) => {
// aktiv, aber noch ohne Einstellung
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('Aktiv') && (await textVon(kachel)).includes('Termine öffnen'), 'Kachel aktiv')
await knopf(kachel, 'Termine öffnen').click()
await warteAnsicht(page)
// neun Vorlagen des Servers
eq(await page.locator('input[name="termine-vorlage"]').count(), 9, 'neun Vorlagen')
await shot(page, 'echt-vorlage')
await page.locator('input[name="termine-vorlage"][value="salon"]').check()
await knopf(page, 'Mit dieser Vorlage starten').click()
await page.locator('.tm-reiterleiste').waitFor()
const e = srv.einstellung()
eq(e.vorlage, 'salon', 'Einstellung aus der Vorlage gespeichert')
assert(e.betrieb.name.includes('Silke') || e.betrieb.name !== '', 'Betrieb aus dem Kundenkonto')
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('Noch nicht buchbar'), `Hinweis fehlende Angaben: ${hinweis}`)
await shot(page, 'echt-nach-vorlage')
// zweites Oeffnen: keine Auswahl mehr
await page.reload()
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('.tm-vorlagen').count(), 0, 'Vorlage nur beim ersten Oeffnen')
}, { reset: { ohneEinstellung: true } })
await check('echt_anfragen_annehmen_ablehnen_absagen_verschieben', async (page) => {
// Gaeste buchen ueber die oeffentliche Schnittstelle (echte Daten, echte Mails)
const bestaetigt = await buche({ name: 'Max Mustermann', email: 'max@example.com', tag: '2026-10-12', zeit: '10:00' })
const zweiter = await buche({ name: 'Maria Muster', email: 'maria@example.com', tag: '2026-10-12', zeit: '11:00', leistung: S_HERREN, ressource: R_BEN })
const anfrage1 = await buche({ name: 'Lea Braun', email: 'lea@example.com', tag: '2026-10-13', zeit: '14:00', leistung: S_ANFRAGE })
const anfrage2 = await buche({ name: 'Uta Neu', email: 'uta@example.com', tag: '2026-10-14', zeit: '09:00', leistung: S_ANFRAGE })
eq([anfrage1.status, anfrage2.status, bestaetigt.status], ['angefragt', 'angefragt', 'bestaetigt'], 'Status nach dem Bestaetigen')
// eine Zeile mit gescheiterter Mail
srv.aw.seed('termineOutbox', { $id: 'o_fehler01', featureId: srv.FEATURE, bookingId: bestaetigt.$id, art: 'G02', an: 'max@example.com', status: 'fehler', versuche: 3, naechsterVersuch: JETZT.toISOString(), createdAt: JETZT.toISOString() })
await oeffne(page, '#termine')
await warteAnsicht(page)
// Banner mit der Zahl; alle Anfragen ab heute
await page.locator('.tm-box-info').getByText('2 Anfragen warten').waitFor()
await knopf(page, 'Anfragen ansehen').click()
await page.locator('li.tm-eintrag[data-status="angefragt"]').first().waitFor()
eq(await page.locator('li.tm-eintrag').count(), 2, 'zwei Anfragen')
eq(await page.locator('li.tm-eintrag').evaluateAll((l) => l.map((e) => e.dataset.id)), [anfrage1.$id, anfrage2.$id], 'Anfragen in Zeitfolge')
await shot(page, 'echt-anfragen')
// annehmen: G02 an die Anfragende
const mailsVorher = mailsAn('lea@example.com').length
await knopf(reihe(page, 'Lea Braun'), 'Annehmen: Brautfrisur, 13.10.2026 um 14:00 Uhr').click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).includes('Lea Braun bekommt eine Bestätigung per E-Mail.'), 'Meldung annehmen')
await srv.warteAufVersand()
eq(buchungMit(anfrage1.$id).status, 'bestaetigt', 'Server: angenommen')
assert(buchungMit(anfrage1.$id).angenommenAt, 'angenommenAt gesetzt')
eq(mailsAn('lea@example.com').length, mailsVorher + 1, 'G02 an den Gast')
// ablehnen mit Nachricht: G04
const zeile2 = reihe(page, 'Uta Neu')
await knopf(zeile2, 'Ablehnen: Brautfrisur, 14.10.2026 um 09:00 Uhr').click()
await zeile2.getByLabel('Nachricht an den Gast (optional)').fill('Leider ausgebucht.')
await knopf(zeile2, 'Anfrage ablehnen').click()
await page.getByText('Es warten keine Anfragen.').waitFor()
await srv.warteAufVersand()
eq(buchungMit(anfrage2.$id).status, 'abgelehnt', 'Server: abgelehnt')
const g04 = mailsAn('uta@example.com').at(-1)
assert(`${g04.text}${g04.html}`.includes('Leider ausgebucht.'), 'G04 mit Nachricht des Betriebs')
eq(await page.locator('.tm-box-info:not([hidden])').count(), 0, 'Banner weg')
// zurueck zur Uebersicht, Woche des 12. Oktober
await knopf(page, 'Zurück zur Übersicht').click()
await page.getByRole('button', { name: 'Woche', exact: true }).click()
await page.locator('#tm-sprung').fill('2026-10-12')
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), '12.–18. Oktober 2026', 'Woche ab 12.10.')
const t1 = await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))
assert(t1.includes('10:00–10:45') && t1.includes('Damenschnitt') && t1.includes('Anna · Max Mustermann') && t1.includes('Bestätigt') && t1.includes('Bestätigung nicht zugestellt'), `Zeile Server: ${t1}`)
await shot(page, 'echt-woche')
// absagen mit Pflichtgrund: G06, Status, Grund in der Liste
const zeile3 = page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`)
await knopf(zeile3, 'Absagen: Herrenschnitt, 12.10.2026 um 11:00 Uhr').click()
await zeile3.getByLabel('Grund').selectOption('Krankheit')
await knopf(zeile3, 'Termin absagen').click()
await meldung(page).waitFor()
await srv.warteAufVersand()
const abgesagt = buchungMit(zweiter.$id)
eq([abgesagt.status, abgesagt.abgesagtVon, abgesagt.absageGrund], ['abgesagt', 'betrieb', 'Krankheit'], 'Server: abgesagt')
const abTxt = await textVon(page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"]`))
assert(abTxt.includes('Abgesagt') && abTxt.includes('Von dir abgesagt') && abTxt.includes('Grund: Krankheit'), `Zeile abgesagt: ${JSON.stringify(abTxt)}`)
assert(mailsAn('maria@example.com').some((m) => /Krankheit/.test(`${m.text}${m.html}`)), 'G06 nennt den Grund')
eq((await page.locator(`li.tm-eintrag[data-id="${zweiter.$id}"] [data-aktion]`).allTextContents()), ['Gastdaten löschen'], 'nach der Absage nur noch Gastdaten loeschen')
// verschieben: neuer Tag, freie Zeit der Schnittstelle
const zeile4 = page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`)
await knopf(zeile4, 'Verschieben: Damenschnitt, 12.10.2026 um 10:00 Uhr').click()
const frage = zeile4.locator('.tm-frage-box')
await frage.getByLabel('Neuer Tag').fill('2026-10-14')
await frage.locator('.tm-zeit-knopf').first().waitFor()
const zeiten = await frage.locator('.tm-zeit-knopf').allInnerTexts()
assert(zeiten.includes('09:00') && zeiten.includes('16:45') && !zeiten.includes('12:30'), `freie Zeiten: ${zeiten.join(' ')}`)
await shot(page, 'echt-verschieben')
await knopf(frage, 'Verschieben').click() // ohne Zeit: Hinweis
assert((await textVon(frage)).includes('Bitte wähle eine Uhrzeit aus.'), 'Zeit ist Pflicht')
await frage.locator('.tm-zeit-knopf', { hasText: '15:00' }).click()
eq(await frage.locator('.tm-zeit-knopf[aria-pressed="true"]').allInnerTexts(), ['15:00'], 'eine Zeit gewaehlt')
await knopf(frage, 'Verschieben').click()
await meldung(page).waitFor()
assert((await textVon(meldung(page))).includes('Termin verschoben auf 14.10.2026, 15:00 Uhr.'), 'Meldung verschieben')
await srv.warteAufVersand()
assert(gleichZeit(buchungMit(bestaetigt.$id).startsAt, utc('2026-10-14', '15:00')), `Server: neue Zeit ${buchungMit(bestaetigt.$id).startsAt}`)
assert(mailsAn('max@example.com').length >= 3, 'G07 an den Gast')
// der verschobene Termin steht in der Woche
await page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`).waitFor()
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${bestaetigt.$id}"]`))).includes('15:00–15:45'), 'verschobene Zeit in der Liste')
})
await check('echt_termin_eintragen_sperre_nichterschienen', async (page) => {
// begonnener Termin (09:00, jetzt ist 10:00) und eine Anfrage direkt im Nachbau
const basis = (id, o) => ({
$id: id, featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc('2026-10-05', '09:00'), endsAt: utc('2026-10-05', '09:30'), blockEndsAt: utc('2026-10-05', '09:35'), status: 'bestaetigt', quelle: 'online',
gastName: 'Karl Ohr', gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
preisCent: 2800, preisArt: 'fest', buchungsart: 'sofort', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0, ...o,
})
srv.aw.seed('termineBookings', basis('b_begonnen', {}))
await oeffne(page, '#termine')
await warteAnsicht(page)
const begonnen = page.locator('li.tm-eintrag[data-id="b_begonnen"]')
await begonnen.waitFor()
eq(await begonnen.locator('[data-aktion]').evaluateAll((l) => l.map((e) => e.dataset.aktion)), ['nichterschienen', 'anonymisieren'], 'begonnener Termin')
await knopf(begonnen, 'Nicht erschienen: Herrenschnitt, 05.10.2026 um 09:00 Uhr').click()
await knopf(begonnen.locator('.tm-frage-box'), 'Ja, nicht erschienen').click()
await meldung(page).waitFor()
await page.locator('li.tm-eintrag[data-id="b_begonnen"][data-status="nichterschienen"]').waitFor()
eq(buchungMit('b_begonnen').status, 'nichterschienen', 'Server: nicht erschienen')
// Gastdaten loeschen
await begonnen.locator('summary').click()
await knopf(begonnen, 'Gastdaten löschen').click()
assert((await textVon(begonnen.locator('.tm-frage-box'))).includes('lässt sich nicht rückgängig machen'), 'Warnung')
await knopf(begonnen.locator('.tm-frage-box'), 'Gastdaten löschen').click()
await page.locator('li.tm-eintrag[data-id="b_begonnen"] .tm-meta', { hasText: 'Gelöscht' }).waitFor()
eq([buchungMit('b_begonnen').gastName, buchungMit('b_begonnen').gastEmail], ['Gelöscht', ''], 'Server: Gastdaten geloescht')
// + Termin eintragen: Pflichtfelder, Feldfehler am Feld, dann Erfolg
await knopf(page, '+ Termin eintragen').click()
const form = page.getByRole('form', { name: 'Termin eintragen' })
await form.waitFor()
await shot(page, 'echt-termin-eintragen')
await form.getByLabel('Leistung', { exact: true }).selectOption(S_HERREN)
await form.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await form.locator('.tm-zeit-knopf').first().waitFor()
await form.locator('.tm-zeit-knopf', { hasText: '09:30' }).click()
await knopf(form, 'Termin eintragen').click() // ohne Namen
await form.locator('[data-fehler="name"]:not(:empty)').waitFor()
eq(await textVon(form.locator('[data-fehler="name"]')), 'Bitte einen Namen angeben.', 'Feldfehler am Namen')
eq(await form.getByLabel('Name des Gastes').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
eq(srv.buchungen().filter((b) => b.quelle === 'manuell').length, 0, 'nichts gespeichert')
await form.getByLabel('Name des Gastes').fill('Walter Laufkunde')
eq(await form.getByLabel('Bestätigung an den Gast senden').isDisabled(), true, 'Haken nur mit E-Mail')
await form.getByLabel('Telefon (optional)').fill('06301 999')
await knopf(form, 'Termin eintragen').click()
await meldung(page).waitFor()
eq(await textVon(meldung(page)), 'Termin eingetragen.', 'Meldung eintragen')
const manuell = srv.buchungen().find((b) => b.quelle === 'manuell')
eq([manuell.gastName, manuell.status, manuell.leistungName], ['Walter Laufkunde', 'bestaetigt', 'Herrenschnitt'], 'Server: Handeintrag')
assert(gleichZeit(manuell.startsAt, utc('2026-10-06', '09:30')), `Server: Zeit des Handeintrags ${manuell.startsAt}`)
// Ansicht springt auf den Tag des neuen Termins
await page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`).waitFor()
eq(await textVon(page.locator('.tm-zeitraum')), 'Dienstag, 6. Oktober 2026', 'Tag des Eintrags')
eq(await page.locator('.tm-formular').count(), 0, 'Formular zu')
assert((await textVon(page.locator(`li.tm-eintrag[data-id="${manuell.$id}"]`))).includes('von dir eingetragen'), 'Handeintrag gekennzeichnet')
// Zeit sperren: Hinweis 500 Minuten, zu lang ⇒ Fehler am Feld, dann Erfolg
await knopf(page, 'Zeit sperren').click()
const sperre = page.getByRole('form', { name: 'Zeit sperren' })
await sperre.waitFor()
assert((await textVon(sperre)).includes('höchstens 8 Stunden 20 Minuten (500 Minuten)'), 'Hinweis 500 Minuten')
await sperre.getByLabel('Person', { exact: true }).selectOption(R_BEN)
await sperre.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await sperre.getByLabel('Von (Uhrzeit)').fill('8:00')
await sperre.getByLabel('Bis (Uhrzeit)').fill('18:00')
await knopf(sperre, 'Zeit sperren').click()
await sperre.locator('[data-fehler="bis"]:not(:empty)').waitFor()
assert((await textVon(sperre.locator('[data-fehler="bis"]'))).includes('höchstens 8 Stunden 20 Minuten'), 'Fehler vom Server am Feld Bis')
await sperre.getByLabel('Von (Uhrzeit)').fill('abc')
await knopf(sperre, 'Zeit sperren').click()
await sperre.locator('[data-fehler="von"]:not(:empty)').waitFor()
await sperre.getByLabel('Von (Uhrzeit)').fill('12:00')
await sperre.getByLabel('Bis (Uhrzeit)').fill('13:00')
await sperre.getByLabel('Titel (optional)').fill('Mittagspause')
await knopf(sperre, 'Zeit sperren').click()
await meldung(page).waitFor()
eq(await textVon(meldung(page)), 'Zeit gesperrt.', 'Meldung sperren')
const sperreDoc = srv.buchungen().find((b) => b.quelle === 'sperre')
assert(sperreDoc.leistungName === 'Mittagspause' && gleichZeit(sperreDoc.startsAt, utc('2026-10-06', '12:00')) && gleichZeit(sperreDoc.endsAt, utc('2026-10-06', '13:00')), 'Server: Sperre')
const sperreZeile = page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`)
await sperreZeile.waitFor()
assert((await textVon(sperreZeile)).includes('Gesperrt') && (await textVon(sperreZeile)).includes('Mittagspause'), 'Sperre in der Liste')
// ueberschneidende Sperre ⇒ belegt
await knopf(page, 'Zeit sperren').click()
const zweite = page.getByRole('form', { name: 'Zeit sperren' })
await zweite.getByLabel('Person', { exact: true }).selectOption(R_BEN)
await zweite.getByLabel('Tag', { exact: true }).fill('2026-10-06')
await zweite.getByLabel('Von (Uhrzeit)').fill('12:30')
await zweite.getByLabel('Bis (Uhrzeit)').fill('14:00')
await knopf(zweite, 'Zeit sperren').click()
await zweite.locator('[data-fehler="von"]:not(:empty)').waitFor()
assert((await textVon(zweite.locator('[data-fehler="von"]'))).includes('schon einen Termin oder eine Sperre'), 'belegte Zeit am Feld Von')
await knopf(zweite, 'Abbrechen').click()
// Sperre aufheben (Rueckfrage)
await knopf(sperreZeile, 'Sperre aufheben: Sperre, 06.10.2026 um 12:00 Uhr').click()
await knopf(sperreZeile.locator('.tm-frage-box'), 'Sperre aufheben').click()
await page.locator(`li.tm-eintrag[data-id="${sperreDoc.$id}"]`).waitFor({ state: 'detached' })
eq(srv.buchungen().filter((b) => b.quelle === 'sperre').length, 0, 'Server: Sperre geloescht')
await shot(page, 'echt-nach-sperre')
})
await check('echt_abgeschaltet_nur_lesen', async (page) => {
srv.aw.seed('termineBookings', {
$id: 'b_alt', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_HERREN, leistungName: 'Herrenschnitt', ressourceId: R_BEN, ressourceName: 'Ben',
startsAt: utc('2026-10-06', '09:00'), endsAt: utc('2026-10-06', '09:30'), blockEndsAt: utc('2026-10-06', '09:35'), status: 'angefragt', quelle: 'online', gastName: 'Karl Ohr',
gastEmail: 'karl@example.com', gastTelefon: '', gastNachricht: '', gastZusatz: '', erinnerung: false, createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(),
preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
await srv.setzeFunktion('disabled')
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
assert((await textVon(kachel)).includes('Abgeschaltet'), 'Kachel abgeschaltet')
await knopf(kachel, 'Termine ansehen').click()
await warteAnsicht(page)
const hinweis = await textVon(page.locator('.tm-box-warn'))
assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`)
eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link')
eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben')
await page.getByRole('button', { name: 'Tag', exact: true }).click()
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
await page.locator('li.tm-eintrag[data-id="b_alt"]').waitFor()
eq(await page.locator('li.tm-eintrag [data-aktion]').count(), 0, 'keine Aktionen an den Zeilen')
await shot(page, 'echt-abgeschaltet')
// die Schnittstelle verweigert Schreiben auch direkt
const r = await page.evaluate(async () => (await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) })).status)
eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion')
})
await check('echt_390_liste_und_formular', async (page) => {
srv.aw.seed('termineBookings', {
$id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna',
startsAt: utc('2026-10-05', '14:00'), endsAt: utc('2026-10-05', '14:45'), blockEndsAt: utc('2026-10-05', '14:50'), status: 'angefragt', quelle: 'online', gastName: 'Katharina von und zu Hohenlohe-Langenburg',
gastEmail: 'katharina@example.com', gastTelefon: '+49 6301 123456', gastNachricht: 'Bitte nach Möglichkeit mit der Stylistin vom letzten Mal, Frau Schneider.', gastZusatz: '', erinnerung: true,
createdAt: JETZT.toISOString(), updatedAt: JETZT.toISOString(), preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage', verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
})
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="termine"]'), 'Termine öffnen').click()
await warteAnsicht(page)
await page.locator('li.tm-eintrag').waitFor()
eq(await passtInBreite(page), true, 'echt: Liste ohne Streifen')
await shot(page, '390-5-echt-liste')
await knopf(page, '+ Termin eintragen').click()
const form = page.getByRole('form', { name: 'Termin eintragen' })
await form.locator('.tm-zeit-knopf').first().waitFor()
eq(await passtInBreite(page), true, 'echt: Formular ohne Streifen')
await shot(page, '390-6-echt-termin-eintragen')
await knopf(form, 'Abbrechen').click()
await knopf(page, 'Zeit sperren').click()
await page.getByRole('form', { name: 'Zeit sperren' }).waitFor()
eq(await passtInBreite(page), true, 'echt: Sperre ohne Streifen')
await shot(page, '390-7-echt-zeit-sperren')
}, { 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)
results.push(['nur_eigene_herkunft', fremd.length === 0])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : '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)