740 lines
44 KiB
JavaScript
740 lines
44 KiB
JavaScript
// 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)
|