Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 04:42:31 +00:00
commit 1d5a565e9f
95 files changed
+12879 -546

No files matched your search

+739
View File
@@ -0,0 +1,739 @@
// Playwright-Checks Terminplaner TP2, Verkauf: vom Fragebogen bis zum Bestellklick (public/funktionen.js Kachel, termine.js Huelle,
// termine-fragebogen.js Schritte, funktion-bestellen.js Bestellseite und Statuskarte). Nicht Teil von npm test.
// Aufruf: NODE_OPTIONS=--max-old-space-size=1024 nice node test/ui/termine-fragebogen.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/fragebogen-*.png (bei Fehlern fehler-fragebogen-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Alles laeuft gegen die ECHTEN Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite und Stripe (test/ui/termineTestServer.mjs);
// nur die uebrigen Portal-Schnittstellen (/api/auth/me usw.) mockt page.route, und die Zahlungsseite (https://checkout.stripe.test/...) faengt
// page.route ab: dorthin geht kein Netzverkehr. Hosts ausser 127.0.0.1, localhost und checkout.stripe.test sind Fehler, ebenso Seitenfehler und
// Browser-Dialoge. Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startTermineUiServer, salonEingabe, FEATURE, KUNDE, PROJEKT } 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 (/^(fragebogen|fehler-fragebogen)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startTermineUiServer()
// erst nach dem Start des Testservers importieren (config.js liest die Umgebung beim ersten Import)
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
const { aboMeldung } = await import('../../server/services/featureOrder.js')
const { leseZustimmung, nachweisTexte, texteHash, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
const REGISTER = featureConfig('termine')
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const ERLAUBTE_HOSTS = new Set(['127.0.0.1', 'localhost', 'checkout.stripe.test'])
const STRIPE_SEITE = /^https:\/\/checkout\.stripe\.test\/c\/pay\/(cs_test_\w+)$/
// ---------------------------------------------------------------- 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 pause = (ms) => new Promise((fertig) => { setTimeout(fertig, ms) })
/** 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}/fragebogen-${name}.png` })
await page.setViewportSize(fenster)
}
/** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.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({})
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
/**
* Eine Pruefung in eigenem Browser-Kontext, ausgehend von einem Kunden vor der ersten Branchenwahl (srv.zuruecksetzen({ entwurf: true })).
* optionen: context (Playwright), abo (srv.abo: 'wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), cookie ('kunde' | 'kundenansicht').
* fn(page, t, ctx): t.anfragen sind alle Aufrufe der Seite an /api/ (Methode, Pfad, Koerper), t.stripe die Aufrufe der Zahlungsseite.
*/
async function check(name, fn, { context = {}, abo = 'wartung', cookie = 'kunde' } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
await srv.zuruecksetzen({ entwurf: true })
if (abo !== 'wartung') await srv.abo(abo)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(JETZT)
await ctx.addCookies([cookie === 'kundenansicht' ? await srv.kundenansichtCookie() : await srv.portalCookie()])
ctx.setDefaultTimeout(8000)
const fehler = []
const t = { anfragen: [], stripe: [] }
// jede Seite des Kontexts wird beobachtet
ctx.on('page', (seite) => {
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => {
let u
try { u = new URL(r.url()) } catch { return } // data: u. a.
if (!u.protocol.startsWith('http')) return
hosts.add(u.hostname)
if (!ERLAUBTE_HOSTS.has(u.hostname)) fehler.push(`Anfrage an fremden Host: ${u.host}`)
if (u.pathname.startsWith('/api/')) {
let body = null
try { body = r.postDataJSON() } catch { body = r.postData() }
t.anfragen.push({ methode: r.method(), pfad: u.pathname, body })
}
})
})
await ctx.route('**/api/**', async (route) => {
const p = new URL(route.request().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 route.continue()
return portalHilfe(p, json)
})
// die Zahlungsseite: nur eine Attrappe; festgehalten wird, wann sie aufgerufen wurde und wie viele Nachweise zu diesem Zeitpunkt schon lagen
await ctx.route('https://checkout.stripe.test/**', async (route) => {
t.stripe.push({ url: route.request().url(), at: Date.now(), nachweise: srv.aw.docs('featureConsents').size })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><meta charset="utf-8"><title>Zahlungsseite (Attrappe)</title><p>Zahlungsseite</p>' })
})
const page = await ctx.newPage()
try {
await fn(page, t, 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]}`)
if (process.env.STACK) console.log(e.stack)
await page.screenshot({ path: `${SHOTS}/fehler-fragebogen-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
}
}
// ---- Seite und Fragebogen
/** Das Portal frisch laden: ein Wechsel nur der Adresse nach # laedt die Daten nicht neu, darum erst eine leere Seite. */
async function oeffne(page, hash) {
if (page.url().startsWith(srv.url)) await page.goto('about:blank')
await page.goto(`${srv.url}/dashboard.html${hash}`)
}
const warteFragebogen = (page) => page.locator('#view-termine.active .tm-fragebogen').waitFor()
const markierung = (page, key) => page.locator(`.tm-schritt[aria-current="step"][data-schritt="${key}"]`)
const warteSchritt = (page, key) => markierung(page, key).waitFor()
/** „Schritt n von 8“ (Text steht immer im DOM, sichtbar ist er nur am Handy). */
const standText = (page) => page.locator('.tm-schritt-stand').textContent()
const schrittKnopf = (page, key) => page.locator(`.tm-schritt[data-schritt="${key}"] button`)
const weiterKnopf = (page) => page.locator('.tm-fb-form [data-aktion="weiter"]')
const feldIn = (page, name) => page.locator(`[data-feld="${name}"]`)
/** Der Kunde mit Entwurf kommt ueber die Kachel in Funktionen in die Ansicht (die Kachel merkt sich das Projekt). */
async function ueberKachelStarten(page, knopfText) {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByRole('button', { name: knopfText, exact: true }).click()
await warteFragebogen(page)
}
const GEBUCHT = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Auf der Seite Kontakt unter dem Text', gestaltung: '', bemerkung: '' }
/** Alle acht Schritte bestaetigt und alles ausgefuellt: der Entwurf, der direkt auf der Bestellseite landet. */
const vollstaendig = (extra = {}) => ({ bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true, preiseGeprueft: true, einbau: GEBUCHT, ...extra })
// Zaehler fuer "die Version steigt nur durch PATCH": diese Aufrufe der Seite schreiben die Einstellung (und erhoehen deren Version)
const schreibt = (a) => (a.methode === 'PATCH' && a.pfad === '/api/termine/einstellungen') || (a.methode === 'POST' && a.pfad === '/api/termine/einstellungen/vorlage')
const zaehleSchreiber = (t) => t.anfragen.filter(schreibt).length
const zaehlePuts = (t) => t.anfragen.filter((a) => a.methode === 'PUT' && a.pfad === '/api/termine/fragebogen').length
const schreibaufrufe = (t) => t.anfragen.filter((a) => a.methode !== 'GET')
// Eingaben der acht Schritte (ohne „Weiter“) und was danach gespeichert sein muss
const FELD_NAME = 'Salon Müller'
const FELD_ADRESSE = 'Hauptstr. 1, 67722 Winnweiler'
const FELD_TELEFON = '06301 1234'
const IMPRESSUM = 'https://salon-mueller.example/impressum'
const DATENSCHUTZ = 'https://salon-mueller.example/datenschutz'
const EINGABE = {
async wer(page) {
await page.locator('input[name="termine-wer-art"][value="team"]').check()
await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').fill('Anna\nBen')
},
async leistungen(page) {
await feldIn(page, 'preiseGeprueft').check()
},
async zeiten(page) {
await feldIn(page, 'bundesland').selectOption({ label: 'Rheinland-Pfalz' })
},
async regeln(page) {
await page.getByRole('button', { name: 'Alle sofort buchbar', exact: true }).click()
},
async gastfelder() {},
async benachrichtigung() {},
async website(page) {
await feldIn(page, 'betrieb.telefon').fill(FELD_TELEFON)
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
await feldIn(page, 'betrieb.datenschutzUrl').fill(DATENSCHUTZ)
await page.locator('input[name="termine-einbau-modus"][value="knopf"]').check()
await feldIn(page, 'einbau.knopfText').fill('Termin buchen')
},
}
const NACH_SCHRITT = {
wer: () => eq(srv.einstellung().ressourcen.map((r) => r.name), ['Anna', 'Ben'], 'Team mit zwei Namen gespeichert'),
leistungen: () => eq(srv.fragebogen().preiseGeprueft, true, 'Preise geprueft gespeichert'),
zeiten: () => {
eq(srv.fragebogen().bundeslandGewaehlt, true, 'Bundesland gewaehlt gespeichert')
eq(srv.einstellung().bundesland, 'RP', 'Bundesland Rheinland-Pfalz')
},
regeln: () => assert(srv.einstellung().leistungen.every((l) => l.buchungsart === 'sofort'), 'alle Leistungen sofort buchbar'),
gastfelder: () => {},
benachrichtigung: () => {},
website: () => {
eq(srv.einstellung().betrieb.telefon, FELD_TELEFON, 'Telefon gespeichert')
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum gespeichert')
eq(srv.einstellung().betrieb.datenschutzUrl, DATENSCHUTZ, 'Datenschutz gespeichert')
const e = srv.fragebogen().einbau
eq([e.modus, e.knopfText], ['knopf', 'Termin buchen'], 'Einbauwunsch gespeichert')
},
}
const FOLGE = srv.SCHRITTE
/**
* Schritt 1 ohne Entwurf: Branche „Friseur und Salon“ waehlen, Name und Anschrift stehen aus dem Konto da, „Mit dieser Branche starten“.
* Danach gibt es Funktionsdokument (off) und Einstellung, und der Schritt ist bestaetigt.
*/
async function brancheStarten(page, t) {
await warteSchritt(page, 'branche')
eq(await standText(page), 'Schritt 1 von 8', 'Fortschritt vor dem Start')
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Name aus dem Konto')
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Anschrift aus dem Konto')
eq(srv.aw.docs('portalFeatures').size, 0, 'vor dem Start kein Funktionsdokument')
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
const s0 = zaehleSchreiber(t)
await page.getByRole('button', { name: 'Mit dieser Branche starten', exact: true }).click()
await warteSchritt(page, 'wer')
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
assert(feature, 'Funktionsdokument angelegt')
eq([feature.state, feature.featureKey, feature.customerId, feature.projectId], ['off', 'termine', KUNDE, PROJEKT], 'Funktionsdokument im Zustand off')
assert(/^[a-z2-7]{16}$/.test(feature.siteKey), 'Funktionsdokument hat einen siteKey')
assert(srv.einstellung(), 'Einstellung angelegt')
eq(srv.einstellung().vorlage, 'salon', 'Vorlage der Branche')
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Name in der Einstellung')
eq(srv.einstellung().betrieb.adresse, FELD_ADRESSE, 'Anschrift in der Einstellung')
eq(srv.fragebogen().branche, 'salon', 'Branche im Fragebogen')
eq(srv.fragebogen().bestaetigt, ['branche'], 'Schritt 1 bestaetigt')
// die Version steigt durch die Vorlage (POST) und hoechstens einen Nachtrag (PATCH), nie durch den PUT
eq(srv.version(), zaehleSchreiber(t) - s0, 'Version = Zahl der schreibenden Aufrufe (POST vorlage, PATCH)')
eq(zaehlePuts(t), 1, 'ein PUT bestaetigt Schritt 1')
}
/**
* „Weiter“ in einem Schritt mit Feldern: danach steht der Schritt in fragebogen.bestaetigt (aus termineConfig gelesen), der naechste Schritt
* ist da, es gab genau einen PUT und die Version der Einstellung ist nur um die PATCH-Aufrufe gestiegen.
*/
async function weiter(page, t, key, naechster) {
const v0 = srv.version()
const s0 = zaehleSchreiber(t)
const p0 = zaehlePuts(t)
await weiterKnopf(page).click()
await warteSchritt(page, naechster)
assert(srv.fragebogen().bestaetigt.includes(key), `${key} steht in bestaetigt: ${JSON.stringify(srv.fragebogen().bestaetigt)}`)
eq(zaehlePuts(t) - p0, 1, `${key}: genau ein PUT`)
const patches = zaehleSchreiber(t) - s0
assert(patches <= 1, `${key}: hoechstens ein PATCH (${patches})`)
eq(srv.version() - v0, patches, `${key}: die Version steigt nur durch PATCH`)
}
/** Schritte 2 bis bis ausfuellen und bestaetigen (Schritt 1 ist schon gestartet). */
async function schritteAusfuellen(page, t, bis) {
const ende = FOLGE.indexOf(bis)
for (let i = 1; i <= ende; i += 1) {
const key = FOLGE[i]
await warteSchritt(page, key)
eq(await standText(page), `Schritt ${i + 1} von 8`, `Fortschritt in ${key}`)
await EINGABE[key](page)
await weiter(page, t, key, FOLGE[i + 1] ?? 'bestellen')
NACH_SCHRITT[key]()
}
}
/** Bestellseite: alle Haken. */
const hakenSetzen = async (page) => {
const haken = page.locator('.tm-bestellen [data-haken]')
const n = await haken.count()
assert(n > 0, 'die Bestellseite hat Haken')
for (let i = 0; i < n; i += 1) await haken.nth(i).check()
return n
}
const bestellKnopf = (page) => page.locator('.tm-bestellen [data-aktion="bestellen"]')
async function pruefungen() {
// ============================================================ ohne Abo
await check('ohne_abo', async (page, t) => {
const aus = [['hosting', 'kein_abo'], ['keins', 'kein_abo'], ['gekuendigt', 'gekuendigt'], ['nicht_aktiv', 'nicht_aktiv']]
for (const [i, [abo, grund]] of aus.entries()) {
if (i > 0) await srv.abo(abo)
const soll = aboMeldung(REGISTER, grund)
assert(soll.includes('Wartungs-Abo'), `Hinweis des Servers nennt das Wartungs-Abo: ${soll}`)
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
const kachelText = await kachel.innerText()
assert(kachelText.includes(soll), `Kachel zeigt den Hinweis (${abo}): ${kachelText}`)
eq(await kachel.locator('.fn-tile-cta').count(), 0, `keine Knoepfe in der Kachel (${abo})`)
eq(await kachel.locator('.fn-pill').innerText(), 'Aus', `Pille Aus (${abo})`)
if (grund === 'kein_abo') assert(kachelText.includes('Zu Verträge'), 'Link zu den Vertraegen bei kein_abo')
// die Ansicht zeigt denselben Hinweis, keinen Fragebogen und keine Vorlagenauswahl
await oeffne(page, '#termine')
const hinweis = page.locator('#view-termine.active [data-hinweis="abo"]')
await hinweis.waitFor()
assert((await hinweis.innerText()).includes(soll), `Ansicht zeigt den Hinweis (${abo}): ${await hinweis.innerText()}`)
eq(await page.locator('.tm-fragebogen, .tm-vorlagen, .tm-bestellen, .tm-reiterleiste').count(), 0, `kein Fragebogen (${abo})`)
if (i === 0) await shot(page, 'ohne-abo')
}
eq(srv.aw.docs('portalFeatures').size, 0, 'portalFeatures bleibt leer')
eq(srv.aw.docs('termineConfig').size, 0, 'termineConfig bleibt leer')
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
}, { abo: 'hosting' })
// ============================================================ Gesundheitsbereich blockt
await check('gesundheit_blockt', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await warteSchritt(page, 'branche')
await page.locator('label.tm-vorlage', { hasText: 'Gesundheitsbereich' }).locator('input').check()
const hinweis = page.locator('[data-hinweis="gesundheit"]')
await hinweis.waitFor()
const text = await hinweis.innerText()
assert(text.includes('Gesundheitsbereich') && text.includes('nicht verfügbar'), `Erklaertext: ${text}`)
eq(await page.getByRole('button', { name: /Weiter/ }).count(), 0, 'kein „Weiter“')
eq(await page.locator('[data-aktion="branche-starten"], .tm-fb-form [type="submit"]').count(), 0, 'kein Knopf zum Starten')
await shot(page, 'gesundheit')
// auch ein erzwungenes Absenden (Enter im Feld, requestSubmit) sendet nichts
await feldIn(page, 'betrieb.name').press('Enter')
await page.evaluate(() => document.querySelector('.tm-fb-form').requestSubmit())
await pause(500)
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
for (const sammlung of ['portalFeatures', 'termineConfig', 'featureConsents']) eq(srv.aw.docs(sammlung).size, 0, `kein Dokument in ${sammlung}`)
// ein anderer Branchenwunsch hebt die Sperre wieder auf: der Knopf kommt zurueck (nichts gesendet)
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
await page.locator('[data-aktion="branche-starten"]').waitFor()
eq(await hinweis.count(), 0, 'Erklaertext weg bei anderer Branche')
eq(schreibaufrufe(t), [], 'weiterhin kein Schreibaufruf')
})
// ============================================================ komplett bis zum Bestellklick
await check('fragebogen_komplett_bis_bestellklick', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await shot(page, 'schritt-1-start')
await brancheStarten(page, t)
await schritteAusfuellen(page, t, 'website') // 2 bis 8; nach dem letzten „Weiter“ steht die Bestellseite da
await warteSchritt(page, 'bestellen')
eq(srv.fragebogen().bestaetigt, FOLGE, 'alle acht Schritte bestaetigt')
eq(await standText(page), 'Prüfen und bestellen', 'Kopfzeile der Bestellseite')
// ---- Bestellseite
const seite = page.locator('.tm-bestellen')
await seite.waitFor()
eq(await seite.locator('.tm-bestellen-offen').count(), 0, 'keine offenen Punkte')
const knopf = bestellKnopf(page)
assert(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
const knopfText = (await knopf.innerText()).trim()
eq(knopfText, REGISTER.consent.knopf, 'Knopf traegt die Beschriftung des Registers')
const belehrung = await featureHaken('termine').belehrung()
const region = seite.locator('.tm-bestellen-belehrung .tm-rechtstext')
assert(await region.isVisible(), 'Widerrufsbelehrung ist sichtbar (nicht eingeklappt)')
const belehrungImDom = (await region.textContent()).trim()
eq(belehrungImDom, belehrung.trim(), 'Widerrufsbelehrung mit Muster-Formular wie im Register (bis F7: Platzhalter)')
const reihenfolge = await page.evaluate(() => {
const b = document.querySelector('.tm-bestellen-belehrung .tm-rechtstext')
const k = document.querySelector('.tm-bestellen [data-aktion="bestellen"]')
return { vorKnopf: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_FOLLOWING), umgekehrt: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_PRECEDING) }
})
eq(reihenfolge, { vorKnopf: true, umgekehrt: false }, 'Belehrung steht im DOM vor dem Knopf')
// die gezeigten Texte der Zustimmungen: genau sie muessen im Nachweis stehen
const gezeigt = await page.evaluate(() => [...document.querySelectorAll('.tm-bestellen-haken > *')].map((el) => ({
haken: el.matches('label') ? el.querySelector('input').getAttribute('data-haken') : null,
text: el.textContent.trim(),
})))
assert(gezeigt.length > 0, 'Zustimmungen sind zu sehen')
await shot(page, 'bestellseite-gesperrt')
const hakenZahl = await hakenSetzen(page)
assert(hakenZahl > 0 && (await knopf.isEnabled()), 'Knopf aktiv nach allen Haken')
await shot(page, 'bestellseite-bereit')
// ein einzelner Haken weniger sperrt wieder
await page.locator('.tm-bestellen [data-haken]').first().uncheck()
assert(await knopf.isDisabled(), 'Knopf wieder gesperrt bei fehlendem Haken')
await page.locator('.tm-bestellen [data-haken]').first().check()
// ---- Klick
const vorKlick = Date.now()
const checkoutAnfrage = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
await knopf.click()
const body = (await checkoutAnfrage).postDataJSON()
await page.waitForURL(STRIPE_SEITE)
const sitzungId = STRIPE_SEITE.exec(page.url())[1]
eq(t.stripe.length, 1, 'die Zahlungsseite wurde genau einmal aufgerufen')
const aufruf = t.stripe[0]
// der Koerper der Bestellung: Projekt, Fassung und die IDs der Haken (nie Texte)
eq(Object.keys(body).sort(), ['projectId', 'zustimmung'], 'Koerper der Bestellung')
eq(body.projectId, PROJEKT, 'Projekt der Bestellung')
eq(body.zustimmung.version, REGISTER.consent.version, 'Fassung der Zustimmung')
eq(body.zustimmung.haken, REGISTER.consent.items.filter((i) => i.haken).map((i) => i.id), 'IDs der Haken in Registerreihenfolge')
// Funktionsdokument ordered mit der Sitzung
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
eq([feature.state, feature.stripeSessionId], ['ordered', sitzungId], 'Funktionsdokument ordered mit stripeSessionId')
// genau eine Checkout-Sitzung bei Stripe, fuer den Terminplaner und das Projekt
const sitzungen = srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
eq(sitzungen.length, 1, 'genau ein POST /v1/checkout/sessions')
eq([sitzungen[0].body['metadata[featureKey]'], sitzungen[0].body['metadata[projectId]']], ['termine', PROJEKT], 'Metadaten der Sitzung')
// genau ein Nachweis mit den gezeigten Texten
const nachweise = [...srv.aw.docs('featureConsents').values()]
eq(nachweise.length, 1, 'genau ein featureConsents-Dokument')
const n = leseZustimmung(nachweise[0])
eq([n.customerId, n.projectId, n.featureKey, n.stripeSessionId], [KUNDE, PROJEKT, 'termine', sitzungId], 'Nachweis gehoert zu dieser Sitzung')
eq(n.fassungen, zustimmungsfassungen(REGISTER), 'Fassungen der Unterlagen im Nachweis')
for (const eintrag of n.texte.filter((x) => x.haken !== undefined && x.id !== 'belehrung' && x.id !== 'knopf')) {
const sichtbar = gezeigt.find((g) => (eintrag.haken ? g.haken === eintrag.id : g.haken === null) && g.text === eintrag.text)
assert(sichtbar, `Text im Nachweis wurde so gezeigt: ${eintrag.id}`)
}
eq(n.texte, nachweisTexte(REGISTER, { belehrung }), 'Texte im Nachweis = Texte des Registers samt Belehrung und Knopf, in Anzeigereihenfolge')
eq(n.texte.find((x) => x.id === 'belehrung').text.trim(), belehrungImDom, 'Belehrung im Nachweis = die gezeigte Belehrung')
const gespeicherterKnopf = n.texte.find((x) => x.id === 'knopf').text
eq([gespeicherterKnopf, REGISTER.consent.knopf, knopfText], [knopfText, knopfText, knopfText], 'Knopf: gespeichert = Register = Beschriftung des geklickten Knopfes')
eq(nachweise[0].textHash, texteHash(n.texte), 'Pruefsumme der Texte')
// Zeit des Klicks: nach dem Mausklick des Skripts, und gespeichert, BEVOR die Zahlungsseite aufgerufen wurde
const zeit = Date.parse(n.acceptedAt)
assert(Number.isFinite(zeit) && zeit >= vorKlick - 1000, `Zeit des Klicks liegt nach dem Start des Skripts: ${n.acceptedAt}`)
assert(zeit < aufruf.at, `Nachweis (${n.acceptedAt}) kleiner als der Aufruf der Zahlungsseite (${new Date(aufruf.at).toISOString()})`)
assert(Date.parse(nachweise[0].$createdAt) <= aufruf.at, 'Nachweis war angelegt, bevor die Zahlungsseite aufgerufen wurde')
eq(aufruf.nachweise, 1, 'beim Aufruf der Zahlungsseite lag der Nachweis schon in der Datenbank')
})
// ============================================================ offene Punkte sperren
await check('offene_punkte_sperren', async (page) => {
// (a) Impressum fehlt
await srv.entwurf({
eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, impressumUrl: '' } }),
fragebogen: vollstaendig(),
})
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
const offen = page.locator('.tm-bestellen-offen')
await offen.waitFor()
const punkt = offen.locator('li', { hasText: 'Impressum' })
eq(await punkt.count(), 1, 'genau ein Punkt zum Impressum')
eq(await offen.locator('li').count(), 1, 'sonst nichts offen')
eq(await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).innerText(), 'Zum Schritt', 'Knopf „Zum Schritt“')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf bleibt trotz aller Haken gesperrt')
assert((await page.locator('[data-hinweis="bestellen"]').innerText()).includes('offenen Punkte'), 'Hinweis am Knopf nennt die offenen Punkte')
await shot(page, 'offen-impressum')
await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).click()
await warteSchritt(page, 'website')
eq(await page.locator('.tm-fb-schritt').getAttribute('data-schritt'), 'website', 'Schritt 8 geoeffnet')
eq(await standText(page), 'Schritt 8 von 8', 'Fortschritt Schritt 8')
eq(await feldIn(page, 'betrieb.impressumUrl').inputValue(), '', 'Impressum-Feld ist leer')
// nachtragen: danach ist der Punkt weg, der Knopf nach den Haken aktiv
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
await weiterKnopf(page).click()
await warteSchritt(page, 'bestellen')
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'Punkt erledigt')
await hakenSetzen(page)
assert(await bestellKnopf(page).isEnabled(), 'Knopf aktiv, wenn nichts mehr offen ist')
// (b) Bundesland nie gewaehlt, obwohl der Server RP gespeichert hat
await srv.entwurf({ fragebogen: vollstaendig({ bundeslandGewaehlt: false }) })
eq(srv.einstellung().bundesland, 'RP', 'der Serverstandard RP ist gespeichert')
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
const offenLand = page.locator('.tm-bestellen-offen')
await offenLand.waitFor()
eq(await offenLand.locator('li').count(), 1, 'nur ein Punkt offen')
const landPunkt = offenLand.locator('li', { hasText: 'Bundesland' })
eq(await landPunkt.count(), 1, 'Punkt zum Bundesland')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt ohne Bundesland')
await shot(page, 'offen-bundesland')
await landPunkt.getByRole('button', { name: 'Zum Schritt „Zeiten“' }).click()
await warteSchritt(page, 'zeiten')
eq(await standText(page), 'Schritt 4 von 8', 'Punkt fuehrt in den Schritt Zeiten')
eq(await feldIn(page, 'bundesland').inputValue(), '', 'Bundesland ist nicht vorgewaehlt (RP ist keine Wahl)')
})
// ============================================================ Rueckkehr und Zahlung fortsetzen
await check('rueckkehr_und_zahlung_fortsetzen', async (page, t) => {
await srv.entwurf({ fragebogen: vollstaendig() })
const sitzung = await srv.bestellt()
const sitzungsUrl = `https://checkout.stripe.test/c/pay/${sitzung}`
// ---- Rueckkehr nach der Zahlung: Statuskarte mit „Danke!“, Adresse bereinigt, Einstellungen gesperrt
await oeffne(page, '#termine?bestellt=1&projekt=p1')
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await karte.getAttribute('data-state'), 'ordered', 'Statuskarte im Zustand ordered')
assert((await karte.innerText()).includes('Zahlung offen'), `Karte: ${await karte.innerText()}`)
assert((await karte.locator('[data-danke]').innerText()).startsWith('Danke!'), 'Danke! nach der Rueckkehr')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
const reiter = await page.locator('[data-reiter]').evaluateAll((els) => els.map((el) => el.dataset.reiter))
eq(reiter, ['leistungen', 'team', 'einstellungen'], 'Reiter ohne „Termine“')
await page.locator('[data-reiter="einstellungen"]').click()
await page.locator('#tm-panel .tm-abschnitt').first().waitFor()
const felder = await page.locator('#tm-panel').evaluate((panel) => {
const alle = [...panel.querySelectorAll('input, select, textarea')]
return { alle: alle.length, offen: alle.filter((el) => !el.disabled && !el.matches(':disabled')).length }
})
assert(felder.alle > 0, 'Einstellungen zeigen Felder')
eq(felder.offen, 0, 'alle Felder der Einstellungen sind gesperrt')
await shot(page, 'rueckkehr-zahlung-offen')
// ---- Zahlung fortsetzen: dieselbe Sitzung, kein zweiter Nachweis
const fortsetzen = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
eq((await fortsetzen).postDataJSON(), { projectId: PROJEKT, fortsetzen: true }, 'Koerper von „Zahlung fortsetzen“')
await page.waitForURL(sitzungsUrl)
eq(t.stripe.map((s) => s.url), [sitzungsUrl], 'Navigation zur selben Sitzung')
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine neue Sitzung angelegt')
eq(srv.aw.docs('featureConsents').size, 1, 'weiterhin genau ein featureConsents-Dokument')
// ---- Abbruch bei Stripe: Rueckkehr ohne „Danke!“
await oeffne(page, '#termine?projekt=p1')
await karte.waitFor()
assert((await karte.innerText()).includes('Zahlung offen'), 'Abbruch: Karte Zahlung offen')
eq(await page.locator('[data-danke]').count(), 0, 'Abbruch: kein „Danke!“')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Abbruch: Adresse bereinigt')
// ---- ein PATCH direkt am Router ist gesperrt
const patch = await page.evaluate(async (version) => {
const res = await fetch('/api/termine/einstellungen', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', version, einstellung: {} }) })
return { status: res.status, code: (await res.json()).code }
}, srv.version())
eq(patch, { status: 409, code: 'gesperrt' }, 'PATCH bei ordered ist 409 gesperrt')
// ---- ohne Nachweis gibt es keine Weiterleitung: das Bestellformular kommt wieder
await srv.loescheZustimmungen()
await oeffne(page, '#termine')
await karte.waitFor()
const antwort = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
const naviVorher = t.stripe.length
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
const res = await antwort
eq(res.status(), 409, 'ohne Nachweis: 409')
eq((await res.json()).code, 'zustimmung_fehlt', 'ohne Nachweis: zustimmung_fehlt')
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'die Karte ist durch das Bestellformular ersetzt')
assert((await page.locator('#view-termine').innerText()).includes('Bitte stimme der Erklärung zu'), 'Meldung zur fehlenden Zustimmung')
assert(page.url().startsWith(srv.url), `keine Navigation: ${page.url()}`)
eq(t.stripe.length, naviVorher, 'die Zahlungsseite wurde nicht aufgerufen')
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis ohne Zustimmung des Kunden')
await shot(page, 'zahlung-fortsetzen-ohne-nachweis')
// ---- Zustand setup: Karte mit Ticketnummer und Rechnungslink
await srv.einrichtung()
await oeffne(page, '#termine')
await karte.waitFor()
eq(await karte.getAttribute('data-state'), 'setup', 'Karte im Zustand setup')
const text = await karte.innerText()
assert(text.includes('In Einrichtung') && text.includes('#100342'), `Karte mit Ticketnummer: ${text}`)
const rechnung = karte.getByRole('link', { name: 'Rechnung ansehen' })
eq(await rechnung.getAttribute('href'), 'https://invoice.stripe.test/i/in_ui1', 'Rechnungslink')
eq(await rechnung.getAttribute('rel'), 'noopener noreferrer', 'Rechnungslink mit rel')
eq(await page.getByRole('button', { name: 'Zahlung fortsetzen' }).count(), 0, 'in der Einrichtung gibt es kein „Zahlung fortsetzen“')
await shot(page, 'in-einrichtung')
})
// ============================================================ Entwurf bleibt beim Neuladen
await check('entwurf_bleibt_beim_neuladen', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await brancheStarten(page, t)
await schritteAusfuellen(page, t, 'zeiten') // Schritte 2 bis 4
await warteSchritt(page, 'regeln')
// ---- neu laden: Start bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'regeln')
eq(await standText(page), 'Schritt 5 von 8', 'nach dem Neuladen Schritt 5')
assert((await page.locator('.tm-schritt[data-erledigt="true"]').count()) === 4, 'vier Schritte als erledigt markiert')
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Schritt 1: Name')
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Schritt 1: Anschrift')
assert((await page.locator('.tm-fb-branche').innerText()).includes('Friseur und Salon'), 'Schritt 1: Branche')
await schrittKnopf(page, 'wer').click()
await warteSchritt(page, 'wer')
assert(await page.locator('input[name="termine-wer-art"][value="team"]').isChecked(), 'Schritt 2: Team gewaehlt')
eq(await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').inputValue(), 'Anna\nBen', 'Schritt 2: Namen')
await schrittKnopf(page, 'leistungen').click()
await warteSchritt(page, 'leistungen')
assert(await feldIn(page, 'preiseGeprueft').isChecked(), 'Schritt 3: Preise geprueft')
await schrittKnopf(page, 'zeiten').click()
await warteSchritt(page, 'zeiten')
eq(await feldIn(page, 'bundesland').inputValue(), 'RP', 'Schritt 4: Bundesland')
await shot(page, 'nach-neuladen')
// ---- Schritt 8 vorab ausfuellen und bestaetigen: der Einbauwunsch muss den Wechsel der Branche ueberstehen
await schrittKnopf(page, 'website').click()
await warteSchritt(page, 'website')
await EINGABE.website(page)
await feldIn(page, 'einbau.stelle').fill('Auf der Seite Kontakt unter dem Text')
await weiter(page, t, 'website', 'bestellen')
NACH_SCHRITT.website()
eq(srv.fragebogen().bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten', 'website'], 'fuenf Schritte bestaetigt')
const einbau = srv.fragebogen().einbau
eq(einbau.stelle, 'Auf der Seite Kontakt unter dem Text', 'Einbauwunsch mit Stelle gespeichert')
// ---- Branche wechseln mit Rueckfrage
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
await page.locator('[data-aktion="branche-wechseln"]').click()
const rueckfrage = page.locator('.tm-frage-box')
await rueckfrage.waitFor()
assert((await rueckfrage.innerText()).includes('Alle Bestätigungen gehen zurück'), 'Rueckfrage warnt vor dem Zuruecksetzen')
eq(srv.fragebogen().branche, 'salon', 'vor der Bestaetigung ist nichts geaendert')
await rueckfrage.locator('label.tm-vorlage', { hasText: 'Kosmetik und Nagelstudio' }).locator('input').check()
await shot(page, 'branche-wechseln-rueckfrage')
const v0 = srv.version()
await rueckfrage.getByRole('button', { name: 'Ja, Branche wechseln', exact: true }).click()
await page.locator('.tm-fb-branche', { hasText: 'Kosmetik und Nagelstudio' }).waitFor()
eq(srv.fragebogen().branche, 'kosmetik', 'neue Branche gespeichert')
eq(srv.fragebogen().bestaetigt, [], 'Bestaetigungen zurueckgesetzt')
eq(srv.fragebogen().bundeslandGewaehlt, false, 'Bundesland muss neu gewaehlt werden')
eq(srv.fragebogen().preiseGeprueft, false, 'Preise muessen neu geprueft werden')
eq(srv.fragebogen().einbau, einbau, 'der Einbauwunsch bleibt')
eq(srv.einstellung().vorlage, 'kosmetik', 'Einstellung aus der neuen Vorlage')
assert(srv.version() > v0, 'Version der Einstellung gestiegen (Ersetzen ist ein Schreiben der Einstellung)')
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Betriebsname bleibt')
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum bleibt')
eq(await page.locator('.tm-schritt[data-erledigt="true"]').count(), 0, 'kein Schritt mehr als erledigt markiert')
await shot(page, 'nach-branchenwechsel')
})
// ============================================================ Kundenansicht
await check('kundenansicht', async (page, t) => {
await srv.entwurf({ fragebogen: vollstaendig({ bestaetigt: ['branche'] }) })
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'wer')
const hinweis = page.locator('.tm-fragebogen .tm-box-info')
assert((await hinweis.innerText()).includes('Kundenansicht'), 'Hinweis auf die Kundenansicht')
// alle Schritte lassen sich lesen und mit „Weiter“ durchklicken, bestaetigt wird nichts
for (const [i, key] of FOLGE.slice(1).entries()) {
await warteSchritt(page, key)
eq(await standText(page), `Schritt ${i + 2} von 8`, `Fortschritt in ${key}`)
await weiterKnopf(page).click()
await warteSchritt(page, FOLGE[i + 2] ?? 'bestellen')
eq(srv.fragebogen().bestaetigt, ['branche'], `nach „Weiter“ in ${key} bleibt bestaetigt unveraendert`)
}
eq(zaehlePuts(t), 0, 'die Seite sendet in der Kundenansicht keinen PUT')
// Bestellseite: gesperrt mit Hinweis (hier zusaetzlich offene Punkte, weil nichts bestaetigt wurde)
assert((await page.locator('.tm-bestellen-offen li').count()) >= 7, 'sieben unbestaetigte Schritte stehen als offene Punkte da')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Bestellknopf gesperrt')
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf')
await shot(page, 'kundenansicht-bestellseite')
// ---- nichts offen, trotzdem gesperrt: nur die Kundenansicht haelt den Knopf zu
await srv.entwurf({ fragebogen: vollstaendig() })
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'nichts offen')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt, obwohl nichts offen ist und alle Haken gesetzt sind')
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf (nichts offen)')
// ---- die Router selbst: PUT /fragebogen und der Checkout sind 403
const vorher = srv.fragebogen()
const direkt = await page.evaluate(async ({ fragebogen }) => {
const sende = async (url, method, body) => {
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
return { status: res.status, body: await res.json() }
}
return {
put: await sende('/api/termine/fragebogen', 'PUT', { projectId: 'p1', fragebogen: { ...fragebogen, bestaetigt: [] } }),
checkout: await sende('/api/funktionen/termine/checkout', 'POST', { projectId: 'p1', zustimmung: { version: 'x', haken: [] } }),
}
}, { fragebogen: vorher })
eq(direkt.put.status, 403, 'PUT /fragebogen in der Kundenansicht')
eq(direkt.checkout.status, 403, 'Checkout in der Kundenansicht')
eq(srv.fragebogen(), vorher, 'der Fragebogen blieb unveraendert')
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine Stripe-Sitzung')
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis')
}, { cookie: 'kundenansicht' })
// ============================================================ Handy 390 px
await check('handy_390', async (page) => {
const pruefe = async (name, soll) => {
assert(await passtInBreite(page), `${name}: seitliche Rollleiste (scrollWidth ${await page.evaluate(() => document.documentElement.scrollWidth)})`)
const stand = page.locator('.tm-schritt-stand')
assert(await stand.isVisible(), `${name}: Fortschritt ist am Handy sichtbar`)
eq((await stand.innerText()).trim(), soll, `${name}: Fortschritt`)
}
// Schritt 1 ohne Entwurf
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'branche')
await pruefe('Schritt 1 (Start)', 'Schritt 1 von 8')
await shot(page, '390-1-start')
// mit Entwurf: Bestellseite, Schritt 4, Schritt 8, Schritt 1
await srv.entwurf({ fragebogen: vollstaendig() })
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
assert(await passtInBreite(page), 'Bestellseite: seitliche Rollleiste')
await hakenSetzen(page)
assert(await passtInBreite(page), 'Bestellseite mit Haken: seitliche Rollleiste')
await shot(page, '390-2-bestellseite')
await schrittKnopf(page, 'zeiten').click()
await warteSchritt(page, 'zeiten')
await pruefe('Schritt 4', 'Schritt 4 von 8')
await shot(page, '390-3-zeiten')
await schrittKnopf(page, 'website').click()
await warteSchritt(page, 'website')
await pruefe('Schritt 8', 'Schritt 8 von 8')
await shot(page, '390-4-website')
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
await pruefe('Schritt 1 (Entwurf)', 'Schritt 1 von 8')
await shot(page, '390-5-branche-entwurf')
}, { 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) => !ERLAUBTE_HOSTS.has(h))
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)
+223 -9
View File
@@ -68,6 +68,21 @@ function portalHilfe(p, json) {
return json({})
}
// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
const FX_BRANCHEN = [
{ key: 'salon', name: 'Friseur und Salon', gesundheit: false },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
{ key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
]
const FX_BESTELLUNG = {
preis: '149 €',
preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
einrichtung: 'meist innerhalb von 1–2 Wochen',
consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
dokumente: [],
belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
}
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
@@ -82,14 +97,25 @@ function neueFixture() {
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
offen: [],
checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
// GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
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,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
branchen: FX_BRANCHEN,
fragebogen: fx.fragebogen,
kundenansicht: false,
...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
offen: state === 'off' || state === 'ordered' ? fx.offen : null,
bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
})
return fx
}
@@ -125,6 +151,15 @@ function alleStatus() {
]
}
// Der Abo-Text kommt in der Antwort von /api/funktionen vom Server (termine.hinweis, aboMeldung in featureOrder.js); die Fixture
// liefert feste Texte, die Kachel zeigt sie nur an
const FX_ABO_HINWEISE = {
kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.',
gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
unbekannt: 'Das Abo konnte gerade nicht geprüft werden.',
}
/** 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
@@ -132,14 +167,25 @@ async function fixtureAntwort(fx, route, req, url, json) {
const m = req.method()
if (p === '/api/funktionen') {
const termine = fx.verfuegbar
? { verfuegbar: true, bestellbar: fx.bestellbar, grund: fx.aboGrund, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' }
? {
verfuegbar: true,
bestellbar: fx.bestellbar,
grund: fx.aboGrund,
preis: '149 €',
einrichtung: 'meist innerhalb von 1–2 Wochen',
hinweis: fx.bestellbar === true ? null : FX_ABO_HINWEISE[fx.aboGrund ?? 'unbekannt'],
}
: { verfuegbar: false }
return json({ projects: fx.projekte, termine })
}
if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
fx.aufrufe.push(['checkout', req.postDataJSON()])
return json(fx.checkout.body, fx.checkout.status)
}
if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
const daten = fx.daten()
const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
@@ -252,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
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()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
@@ -324,7 +370,7 @@ async function pruefungen() {
await knopf(kachel, 'Fragebogen fortsetzen').waitFor()
// Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende
const faelle = [
['kein_abo', 'Im Wartungs-Abo enthalten.'],
['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'],
['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'],
]
@@ -432,11 +478,12 @@ async function pruefungen() {
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
// andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
await page.locator('#termine-project').selectOption('p1')
await page.getByText('noch nicht freigeschaltet').waitFor()
await page.locator('#termine-body .tm-fragebogen').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
@@ -457,6 +504,171 @@ async function pruefungen() {
})(),
})
// ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
/** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
const imZustand = (state, extra = {}) => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state, ...extra }
return fx
}
const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
const fx = page.fx
// das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('#termine-body nav.tm-fortschritt').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
await shot(page, 'ansicht-fragebogen')
// ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
fx.bestellbar = false
for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
fx.aboGrund = grund === 'unbekannt' ? null : grund
fx.bestellbar = grund === 'unbekannt' ? null : false
await page.reload()
await page.locator('[data-hinweis="abo"]').waitFor()
const hinweis = page.locator('[data-hinweis="abo"]')
assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
}
// der Link führt zu den Verträgen
fx.bestellbar = false
fx.aboGrund = 'kein_abo'
await page.reload()
await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
await page.locator('#view-vertraege.active').waitFor()
}, { fixture: imZustand('off') })
for (const [state, pille, extra] of [
['ordered', 'Zahlung offen', GEORDERT],
['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
]) {
await check(`fx_ansicht_${state}`, async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
await shot(page, `ansicht-${state}`)
// schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
await page.locator('#tm-tab-leistungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
await page.locator('#tm-tab-einstellungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
}, { fixture: imZustand(state, extra) })
}
await check('fx_ansicht_bestellt_390', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await passtInBreite(page), true, 'kein Streifen rechts')
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
.filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
await shot(page, '390-ansicht-bestellt')
}, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
await check('fx_rueckkehr_zahlung', async (page) => {
await oeffne(page, '#termine?bestellt=1&projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte [data-danke]').waitFor()
eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
await shot(page, 'rueckkehr-zahlung')
// neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
await page.reload()
await warteAnsicht(page)
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_rueckkehr_abbruch', async (page) => {
await oeffne(page, '#termine?projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
// die Karte weicht dem Bestellformular (R110), die Reiter bleiben
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
const bestellen = page.locator('[data-aktion="bestellen"]')
eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
await page.locator('.tm-bestellen input[type="checkbox"]').check()
eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
await shot(page, 'zahlung-fortsetzen-bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
// inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')
@@ -1862,9 +2074,11 @@ async function pruefungen() {
// Hinweise der Spezifikation
const text = await textVon(page.locator('#tm-panel'))
// Ruling R52: Wortlaut der rechtlichen Hinweise
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
eq(await textVon(page.locator('#tm-panel [data-hinweis="storno-muster"]')), 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.', 'Hinweis „Muster“ beim Stornofeld')
assert(text.includes('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'), 'Storno nur bei Preis')
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
const online = form.getByRole('radio', { name: /Online bezahlen/ })
+4 -2
View File
@@ -182,7 +182,7 @@ async function pruefungen() {
// Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig')
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung')
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.', 'Haken Erinnerung')
assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein')
assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken')
assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig')
@@ -286,7 +286,8 @@ async function pruefungen() {
await dlg.locator('.wkt-zusammen').waitFor()
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
await dlg.locator('input[name=storno]').check()
eq(await dlg.locator('input[name=storno]').count(), 0, 'kein Storno-Haken ohne Preis (F11)')
eq(await dlg.locator('.wkt-box h3', { hasText: 'Stornobedingungen' }).count(), 0, 'keine Stornobox ohne Preis (F11)')
await dlg.getByRole('button', { name: 'Termin buchen' }).click()
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand()
@@ -871,6 +872,7 @@ async function pruefungen() {
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
await wd.locator('details.wkt-belehrung').waitFor()
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
eq(await wd.locator('input[name=storno]').count(), 0, 'Anfrage ohne Preis: kein Storno-Haken (F11)')
await hakenUndBuchen(wd, 'Termin anfragen')
await srv.warteAufVersand()
const buchungen = srv.buchungen()
+137 -5
View File
@@ -21,6 +21,14 @@
// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
//
// Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl:
// kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift
// (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'),
// srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks),
// srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung),
// srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die
// Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand.
//
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
@@ -118,6 +126,8 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
customerViewLog: schemaKeys('customerViewLog'),
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
@@ -128,14 +138,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
const { config } = await import('../../server/config.js')
const { startTestApp } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js')
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js')
const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
const { hostingDoc, wartungDoc } = await import('../support/fixtures.js')
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
@@ -233,15 +248,123 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
},
/**
* Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo),
* 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv).
*/
async abo(zustand = 'wartung') {
for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id)
const docs = {
wartung: [wartungDoc],
hosting: [hostingDoc],
keins: [],
gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }],
nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }],
}[zustand]
if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`)
for (const doc of docs) aw.seed('subscriptions', doc)
},
/** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */
async kundenansichtCookie({ customerId = KUNDE } = {}) {
const { kundenansichtCookie } = await import('../support/fixtures.js')
const roh = kundenansichtCookie({ customerId })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
},
/**
* Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe,
* gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen
* ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }).
*/
async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) {
const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true })
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
resetKontextForTests()
aw.seed('portalFeatures', srv.featureDoc('off'))
const doc = aw.docs('termineConfig').get(FEATURE)
await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() })
},
/** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */
fragebogen() {
const doc = aw.docs('termineConfig').get(FEATURE)
return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null
},
version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 },
SCHRITTE,
/**
* Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered
* (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit).
* Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung.
*/
async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) {
const register = featureConfig('termine')
st.checkoutSessions.set(sitzungId, {
id: sitzungId,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${sitzungId}`,
mode: 'payment',
customer: null,
client_reference_id: KUNDE,
subscription: null,
metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' },
status: 'open',
payment_status: 'unpaid',
invoice: null,
})
const klickAt = uhr().toISOString()
await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } })
if (mitZustimmung) {
await speichereZustimmung({
customerId: KUNDE,
projectId: PROJEKT,
featureKey: 'termine',
stripeSessionId: sitzungId,
fassungen: zustimmungsfassungen(register),
texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }),
ip: '127.0.0.1',
userAgent: 'ui-test',
konto: 'Silke Müller <silke@salon-mueller.example>',
at: klickAt,
})
}
resetKontextForTests()
return sitzungId
},
/**
* Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht
* geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt).
*/
async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) {
aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' })
await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl })
resetKontextForTests()
},
/** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */
async loescheZustimmungen() {
for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id)
},
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
* ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
* entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein
* Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer.
*/
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) {
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) {
await warteAufVersand()
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders']
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
}
st.checkoutSessions.clear()
st.calls.length = 0
resetKontextForTests()
resetOutboxForTests()
resetTermineLimitsForTests()
@@ -259,7 +382,16 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
aw.seed('customers', {
$id: KUNDE,
name: 'Silke Müller',
email: 'silke@salon-mueller.example',
...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}),
})
if (entwurf) {
await srv.abo('wartung')
return
}
aw.seed('portalFeatures', srv.featureDoc())
if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
},