From 1ac142b92fbac1e51b00526bbf60b7f1b66d7e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:34:38 +0000 Subject: [PATCH] Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick Co-Authored-By: Claude Sonnet 5.5 --- test/ui/termine-fragebogen.ui.mjs | 739 ++++++++++++++++++++++++++++++ test/ui/termineTestServer.mjs | 142 +++++- 2 files changed, 876 insertions(+), 5 deletions(-) create mode 100644 test/ui/termine-fragebogen.ui.mjs diff --git a/test/ui/termine-fragebogen.ui.mjs b/test/ui/termine-fragebogen.ui.mjs new file mode 100644 index 0000000..914246a --- /dev/null +++ b/test/ui/termine-fragebogen.ui.mjs @@ -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-.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= 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: 'Zahlungsseite (Attrappe)

Zahlungsseite

' }) + }) + 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) diff --git a/test/ui/termineTestServer.mjs b/test/ui/termineTestServer.mjs index db125e3..d56ac41 100644 --- a/test/ui/termineTestServer.mjs +++ b/test/ui/termineTestServer.mjs @@ -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 ', + 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())) },