Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87d267e51a
commit
1ac142b92f
2 files changed
+876
-5
No files matched your search
@@ -0,0 +1,739 @@
|
||||
// Playwright-Checks Terminplaner TP2, Verkauf: vom Fragebogen bis zum Bestellklick (public/funktionen.js Kachel, termine.js Huelle,
|
||||
// termine-fragebogen.js Schritte, funktion-bestellen.js Bestellseite und Statuskarte). Nicht Teil von npm test.
|
||||
// Aufruf: NODE_OPTIONS=--max-old-space-size=1024 nice node test/ui/termine-fragebogen.ui.mjs (vorher free -m; es laeuft nur ein Browser)
|
||||
// Screenshots: /root/reports/terminplaner-ui/fragebogen-*.png (bei Fehlern fehler-fragebogen-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
|
||||
//
|
||||
// Alles laeuft gegen die ECHTEN Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite und Stripe (test/ui/termineTestServer.mjs);
|
||||
// nur die uebrigen Portal-Schnittstellen (/api/auth/me usw.) mockt page.route, und die Zahlungsseite (https://checkout.stripe.test/...) faengt
|
||||
// page.route ab: dorthin geht kein Netzverkehr. Hosts ausser 127.0.0.1, localhost und checkout.stripe.test sind Fehler, ebenso Seitenfehler und
|
||||
// Browser-Dialoge. Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
|
||||
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { startTermineUiServer, salonEingabe, FEATURE, KUNDE, PROJEKT } from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const SHOTS = '/root/reports/terminplaner-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(fragebogen|fehler-fragebogen)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
|
||||
|
||||
const srv = await startTermineUiServer()
|
||||
// erst nach dem Start des Testservers importieren (config.js liest die Umgebung beim ersten Import)
|
||||
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
|
||||
const { aboMeldung } = await import('../../server/services/featureOrder.js')
|
||||
const { leseZustimmung, nachweisTexte, texteHash, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
|
||||
const REGISTER = featureConfig('termine')
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
||||
const ERLAUBTE_HOSTS = new Set(['127.0.0.1', 'localhost', 'checkout.stripe.test'])
|
||||
const STRIPE_SEITE = /^https:\/\/checkout\.stripe\.test\/c\/pay\/(cs_test_\w+)$/
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
const pause = (ms) => new Promise((fertig) => { setTimeout(fertig, ms) })
|
||||
|
||||
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
|
||||
async function shot(page, name) {
|
||||
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
|
||||
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
|
||||
const fenster = page.viewportSize()
|
||||
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
|
||||
await page.setViewportSize({ width: fenster.width, height: hoehe })
|
||||
await page.screenshot({ path: `${SHOTS}/fragebogen-${name}.png` })
|
||||
await page.setViewportSize(fenster)
|
||||
}
|
||||
/** Kein Streifen rechts: die Seite passt in die Breite. */
|
||||
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */
|
||||
function portalHilfe(p, json) {
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
|
||||
if (p === '/api/chat/unread-count') return json({ unread: 0 })
|
||||
return json({})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf der Pruefungen
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
/**
|
||||
* Eine Pruefung in eigenem Browser-Kontext, ausgehend von einem Kunden vor der ersten Branchenwahl (srv.zuruecksetzen({ entwurf: true })).
|
||||
* optionen: context (Playwright), abo (srv.abo: 'wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), cookie ('kunde' | 'kundenansicht').
|
||||
* fn(page, t, ctx): t.anfragen sind alle Aufrufe der Seite an /api/ (Methode, Pfad, Koerper), t.stripe die Aufrufe der Zahlungsseite.
|
||||
*/
|
||||
async function check(name, fn, { context = {}, abo = 'wartung', cookie = 'kunde' } = {}) {
|
||||
if (process.env.NUR && !name.includes(process.env.NUR)) return
|
||||
await srv.zuruecksetzen({ entwurf: true })
|
||||
if (abo !== 'wartung') await srv.abo(abo)
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
|
||||
await ctx.clock.setFixedTime(JETZT)
|
||||
await ctx.addCookies([cookie === 'kundenansicht' ? await srv.kundenansichtCookie() : await srv.portalCookie()])
|
||||
ctx.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
const t = { anfragen: [], stripe: [] }
|
||||
// jede Seite des Kontexts wird beobachtet
|
||||
ctx.on('page', (seite) => {
|
||||
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
seite.on('request', (r) => {
|
||||
let u
|
||||
try { u = new URL(r.url()) } catch { return } // data: u. a.
|
||||
if (!u.protocol.startsWith('http')) return
|
||||
hosts.add(u.hostname)
|
||||
if (!ERLAUBTE_HOSTS.has(u.hostname)) fehler.push(`Anfrage an fremden Host: ${u.host}`)
|
||||
if (u.pathname.startsWith('/api/')) {
|
||||
let body = null
|
||||
try { body = r.postDataJSON() } catch { body = r.postData() }
|
||||
t.anfragen.push({ methode: r.method(), pfad: u.pathname, body })
|
||||
}
|
||||
})
|
||||
})
|
||||
await ctx.route('**/api/**', async (route) => {
|
||||
const p = new URL(route.request().url()).pathname
|
||||
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return route.continue()
|
||||
return portalHilfe(p, json)
|
||||
})
|
||||
// die Zahlungsseite: nur eine Attrappe; festgehalten wird, wann sie aufgerufen wurde und wie viele Nachweise zu diesem Zeitpunkt schon lagen
|
||||
await ctx.route('https://checkout.stripe.test/**', async (route) => {
|
||||
t.stripe.push({ url: route.request().url(), at: Date.now(), nachweise: srv.aw.docs('featureConsents').size })
|
||||
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><meta charset="utf-8"><title>Zahlungsseite (Attrappe)</title><p>Zahlungsseite</p>' })
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await fn(page, t, ctx)
|
||||
assert(fehler.length === 0, fehler.join(' | '))
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
if (process.env.STACK) console.log(e.stack)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-fragebogen-${name}.png` }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Seite und Fragebogen
|
||||
/** Das Portal frisch laden: ein Wechsel nur der Adresse nach # laedt die Daten nicht neu, darum erst eine leere Seite. */
|
||||
async function oeffne(page, hash) {
|
||||
if (page.url().startsWith(srv.url)) await page.goto('about:blank')
|
||||
await page.goto(`${srv.url}/dashboard.html${hash}`)
|
||||
}
|
||||
const warteFragebogen = (page) => page.locator('#view-termine.active .tm-fragebogen').waitFor()
|
||||
const markierung = (page, key) => page.locator(`.tm-schritt[aria-current="step"][data-schritt="${key}"]`)
|
||||
const warteSchritt = (page, key) => markierung(page, key).waitFor()
|
||||
/** „Schritt n von 8“ (Text steht immer im DOM, sichtbar ist er nur am Handy). */
|
||||
const standText = (page) => page.locator('.tm-schritt-stand').textContent()
|
||||
const schrittKnopf = (page, key) => page.locator(`.tm-schritt[data-schritt="${key}"] button`)
|
||||
const weiterKnopf = (page) => page.locator('.tm-fb-form [data-aktion="weiter"]')
|
||||
const feldIn = (page, name) => page.locator(`[data-feld="${name}"]`)
|
||||
/** Der Kunde mit Entwurf kommt ueber die Kachel in Funktionen in die Ansicht (die Kachel merkt sich das Projekt). */
|
||||
async function ueberKachelStarten(page, knopfText) {
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
await kachel.getByRole('button', { name: knopfText, exact: true }).click()
|
||||
await warteFragebogen(page)
|
||||
}
|
||||
const GEBUCHT = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Auf der Seite Kontakt unter dem Text', gestaltung: '', bemerkung: '' }
|
||||
/** Alle acht Schritte bestaetigt und alles ausgefuellt: der Entwurf, der direkt auf der Bestellseite landet. */
|
||||
const vollstaendig = (extra = {}) => ({ bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true, preiseGeprueft: true, einbau: GEBUCHT, ...extra })
|
||||
|
||||
// Zaehler fuer "die Version steigt nur durch PATCH": diese Aufrufe der Seite schreiben die Einstellung (und erhoehen deren Version)
|
||||
const schreibt = (a) => (a.methode === 'PATCH' && a.pfad === '/api/termine/einstellungen') || (a.methode === 'POST' && a.pfad === '/api/termine/einstellungen/vorlage')
|
||||
const zaehleSchreiber = (t) => t.anfragen.filter(schreibt).length
|
||||
const zaehlePuts = (t) => t.anfragen.filter((a) => a.methode === 'PUT' && a.pfad === '/api/termine/fragebogen').length
|
||||
const schreibaufrufe = (t) => t.anfragen.filter((a) => a.methode !== 'GET')
|
||||
|
||||
// Eingaben der acht Schritte (ohne „Weiter“) und was danach gespeichert sein muss
|
||||
const FELD_NAME = 'Salon Müller'
|
||||
const FELD_ADRESSE = 'Hauptstr. 1, 67722 Winnweiler'
|
||||
const FELD_TELEFON = '06301 1234'
|
||||
const IMPRESSUM = 'https://salon-mueller.example/impressum'
|
||||
const DATENSCHUTZ = 'https://salon-mueller.example/datenschutz'
|
||||
const EINGABE = {
|
||||
async wer(page) {
|
||||
await page.locator('input[name="termine-wer-art"][value="team"]').check()
|
||||
await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').fill('Anna\nBen')
|
||||
},
|
||||
async leistungen(page) {
|
||||
await feldIn(page, 'preiseGeprueft').check()
|
||||
},
|
||||
async zeiten(page) {
|
||||
await feldIn(page, 'bundesland').selectOption({ label: 'Rheinland-Pfalz' })
|
||||
},
|
||||
async regeln(page) {
|
||||
await page.getByRole('button', { name: 'Alle sofort buchbar', exact: true }).click()
|
||||
},
|
||||
async gastfelder() {},
|
||||
async benachrichtigung() {},
|
||||
async website(page) {
|
||||
await feldIn(page, 'betrieb.telefon').fill(FELD_TELEFON)
|
||||
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
|
||||
await feldIn(page, 'betrieb.datenschutzUrl').fill(DATENSCHUTZ)
|
||||
await page.locator('input[name="termine-einbau-modus"][value="knopf"]').check()
|
||||
await feldIn(page, 'einbau.knopfText').fill('Termin buchen')
|
||||
},
|
||||
}
|
||||
const NACH_SCHRITT = {
|
||||
wer: () => eq(srv.einstellung().ressourcen.map((r) => r.name), ['Anna', 'Ben'], 'Team mit zwei Namen gespeichert'),
|
||||
leistungen: () => eq(srv.fragebogen().preiseGeprueft, true, 'Preise geprueft gespeichert'),
|
||||
zeiten: () => {
|
||||
eq(srv.fragebogen().bundeslandGewaehlt, true, 'Bundesland gewaehlt gespeichert')
|
||||
eq(srv.einstellung().bundesland, 'RP', 'Bundesland Rheinland-Pfalz')
|
||||
},
|
||||
regeln: () => assert(srv.einstellung().leistungen.every((l) => l.buchungsart === 'sofort'), 'alle Leistungen sofort buchbar'),
|
||||
gastfelder: () => {},
|
||||
benachrichtigung: () => {},
|
||||
website: () => {
|
||||
eq(srv.einstellung().betrieb.telefon, FELD_TELEFON, 'Telefon gespeichert')
|
||||
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum gespeichert')
|
||||
eq(srv.einstellung().betrieb.datenschutzUrl, DATENSCHUTZ, 'Datenschutz gespeichert')
|
||||
const e = srv.fragebogen().einbau
|
||||
eq([e.modus, e.knopfText], ['knopf', 'Termin buchen'], 'Einbauwunsch gespeichert')
|
||||
},
|
||||
}
|
||||
const FOLGE = srv.SCHRITTE
|
||||
|
||||
/**
|
||||
* Schritt 1 ohne Entwurf: Branche „Friseur und Salon“ waehlen, Name und Anschrift stehen aus dem Konto da, „Mit dieser Branche starten“.
|
||||
* Danach gibt es Funktionsdokument (off) und Einstellung, und der Schritt ist bestaetigt.
|
||||
*/
|
||||
async function brancheStarten(page, t) {
|
||||
await warteSchritt(page, 'branche')
|
||||
eq(await standText(page), 'Schritt 1 von 8', 'Fortschritt vor dem Start')
|
||||
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Name aus dem Konto')
|
||||
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Anschrift aus dem Konto')
|
||||
eq(srv.aw.docs('portalFeatures').size, 0, 'vor dem Start kein Funktionsdokument')
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
|
||||
const s0 = zaehleSchreiber(t)
|
||||
await page.getByRole('button', { name: 'Mit dieser Branche starten', exact: true }).click()
|
||||
await warteSchritt(page, 'wer')
|
||||
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
|
||||
assert(feature, 'Funktionsdokument angelegt')
|
||||
eq([feature.state, feature.featureKey, feature.customerId, feature.projectId], ['off', 'termine', KUNDE, PROJEKT], 'Funktionsdokument im Zustand off')
|
||||
assert(/^[a-z2-7]{16}$/.test(feature.siteKey), 'Funktionsdokument hat einen siteKey')
|
||||
assert(srv.einstellung(), 'Einstellung angelegt')
|
||||
eq(srv.einstellung().vorlage, 'salon', 'Vorlage der Branche')
|
||||
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Name in der Einstellung')
|
||||
eq(srv.einstellung().betrieb.adresse, FELD_ADRESSE, 'Anschrift in der Einstellung')
|
||||
eq(srv.fragebogen().branche, 'salon', 'Branche im Fragebogen')
|
||||
eq(srv.fragebogen().bestaetigt, ['branche'], 'Schritt 1 bestaetigt')
|
||||
// die Version steigt durch die Vorlage (POST) und hoechstens einen Nachtrag (PATCH), nie durch den PUT
|
||||
eq(srv.version(), zaehleSchreiber(t) - s0, 'Version = Zahl der schreibenden Aufrufe (POST vorlage, PATCH)')
|
||||
eq(zaehlePuts(t), 1, 'ein PUT bestaetigt Schritt 1')
|
||||
}
|
||||
|
||||
/**
|
||||
* „Weiter“ in einem Schritt mit Feldern: danach steht der Schritt in fragebogen.bestaetigt (aus termineConfig gelesen), der naechste Schritt
|
||||
* ist da, es gab genau einen PUT und die Version der Einstellung ist nur um die PATCH-Aufrufe gestiegen.
|
||||
*/
|
||||
async function weiter(page, t, key, naechster) {
|
||||
const v0 = srv.version()
|
||||
const s0 = zaehleSchreiber(t)
|
||||
const p0 = zaehlePuts(t)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, naechster)
|
||||
assert(srv.fragebogen().bestaetigt.includes(key), `${key} steht in bestaetigt: ${JSON.stringify(srv.fragebogen().bestaetigt)}`)
|
||||
eq(zaehlePuts(t) - p0, 1, `${key}: genau ein PUT`)
|
||||
const patches = zaehleSchreiber(t) - s0
|
||||
assert(patches <= 1, `${key}: hoechstens ein PATCH (${patches})`)
|
||||
eq(srv.version() - v0, patches, `${key}: die Version steigt nur durch PATCH`)
|
||||
}
|
||||
|
||||
/** Schritte 2 bis bis ausfuellen und bestaetigen (Schritt 1 ist schon gestartet). */
|
||||
async function schritteAusfuellen(page, t, bis) {
|
||||
const ende = FOLGE.indexOf(bis)
|
||||
for (let i = 1; i <= ende; i += 1) {
|
||||
const key = FOLGE[i]
|
||||
await warteSchritt(page, key)
|
||||
eq(await standText(page), `Schritt ${i + 1} von 8`, `Fortschritt in ${key}`)
|
||||
await EINGABE[key](page)
|
||||
await weiter(page, t, key, FOLGE[i + 1] ?? 'bestellen')
|
||||
NACH_SCHRITT[key]()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestellseite: alle Haken. */
|
||||
const hakenSetzen = async (page) => {
|
||||
const haken = page.locator('.tm-bestellen [data-haken]')
|
||||
const n = await haken.count()
|
||||
assert(n > 0, 'die Bestellseite hat Haken')
|
||||
for (let i = 0; i < n; i += 1) await haken.nth(i).check()
|
||||
return n
|
||||
}
|
||||
const bestellKnopf = (page) => page.locator('.tm-bestellen [data-aktion="bestellen"]')
|
||||
|
||||
async function pruefungen() {
|
||||
// ============================================================ ohne Abo
|
||||
await check('ohne_abo', async (page, t) => {
|
||||
const aus = [['hosting', 'kein_abo'], ['keins', 'kein_abo'], ['gekuendigt', 'gekuendigt'], ['nicht_aktiv', 'nicht_aktiv']]
|
||||
for (const [i, [abo, grund]] of aus.entries()) {
|
||||
if (i > 0) await srv.abo(abo)
|
||||
const soll = aboMeldung(REGISTER, grund)
|
||||
assert(soll.includes('Wartungs-Abo'), `Hinweis des Servers nennt das Wartungs-Abo: ${soll}`)
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
const kachelText = await kachel.innerText()
|
||||
assert(kachelText.includes(soll), `Kachel zeigt den Hinweis (${abo}): ${kachelText}`)
|
||||
eq(await kachel.locator('.fn-tile-cta').count(), 0, `keine Knoepfe in der Kachel (${abo})`)
|
||||
eq(await kachel.locator('.fn-pill').innerText(), 'Aus', `Pille Aus (${abo})`)
|
||||
if (grund === 'kein_abo') assert(kachelText.includes('Zu Verträge'), 'Link zu den Vertraegen bei kein_abo')
|
||||
// die Ansicht zeigt denselben Hinweis, keinen Fragebogen und keine Vorlagenauswahl
|
||||
await oeffne(page, '#termine')
|
||||
const hinweis = page.locator('#view-termine.active [data-hinweis="abo"]')
|
||||
await hinweis.waitFor()
|
||||
assert((await hinweis.innerText()).includes(soll), `Ansicht zeigt den Hinweis (${abo}): ${await hinweis.innerText()}`)
|
||||
eq(await page.locator('.tm-fragebogen, .tm-vorlagen, .tm-bestellen, .tm-reiterleiste').count(), 0, `kein Fragebogen (${abo})`)
|
||||
if (i === 0) await shot(page, 'ohne-abo')
|
||||
}
|
||||
eq(srv.aw.docs('portalFeatures').size, 0, 'portalFeatures bleibt leer')
|
||||
eq(srv.aw.docs('termineConfig').size, 0, 'termineConfig bleibt leer')
|
||||
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
|
||||
}, { abo: 'hosting' })
|
||||
|
||||
// ============================================================ Gesundheitsbereich blockt
|
||||
await check('gesundheit_blockt', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await warteSchritt(page, 'branche')
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Gesundheitsbereich' }).locator('input').check()
|
||||
const hinweis = page.locator('[data-hinweis="gesundheit"]')
|
||||
await hinweis.waitFor()
|
||||
const text = await hinweis.innerText()
|
||||
assert(text.includes('Gesundheitsbereich') && text.includes('nicht verfügbar'), `Erklaertext: ${text}`)
|
||||
eq(await page.getByRole('button', { name: /Weiter/ }).count(), 0, 'kein „Weiter“')
|
||||
eq(await page.locator('[data-aktion="branche-starten"], .tm-fb-form [type="submit"]').count(), 0, 'kein Knopf zum Starten')
|
||||
await shot(page, 'gesundheit')
|
||||
// auch ein erzwungenes Absenden (Enter im Feld, requestSubmit) sendet nichts
|
||||
await feldIn(page, 'betrieb.name').press('Enter')
|
||||
await page.evaluate(() => document.querySelector('.tm-fb-form').requestSubmit())
|
||||
await pause(500)
|
||||
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
|
||||
for (const sammlung of ['portalFeatures', 'termineConfig', 'featureConsents']) eq(srv.aw.docs(sammlung).size, 0, `kein Dokument in ${sammlung}`)
|
||||
// ein anderer Branchenwunsch hebt die Sperre wieder auf: der Knopf kommt zurueck (nichts gesendet)
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
|
||||
await page.locator('[data-aktion="branche-starten"]').waitFor()
|
||||
eq(await hinweis.count(), 0, 'Erklaertext weg bei anderer Branche')
|
||||
eq(schreibaufrufe(t), [], 'weiterhin kein Schreibaufruf')
|
||||
})
|
||||
|
||||
// ============================================================ komplett bis zum Bestellklick
|
||||
await check('fragebogen_komplett_bis_bestellklick', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await shot(page, 'schritt-1-start')
|
||||
await brancheStarten(page, t)
|
||||
await schritteAusfuellen(page, t, 'website') // 2 bis 8; nach dem letzten „Weiter“ steht die Bestellseite da
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(srv.fragebogen().bestaetigt, FOLGE, 'alle acht Schritte bestaetigt')
|
||||
eq(await standText(page), 'Prüfen und bestellen', 'Kopfzeile der Bestellseite')
|
||||
|
||||
// ---- Bestellseite
|
||||
const seite = page.locator('.tm-bestellen')
|
||||
await seite.waitFor()
|
||||
eq(await seite.locator('.tm-bestellen-offen').count(), 0, 'keine offenen Punkte')
|
||||
const knopf = bestellKnopf(page)
|
||||
assert(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
|
||||
const knopfText = (await knopf.innerText()).trim()
|
||||
eq(knopfText, REGISTER.consent.knopf, 'Knopf traegt die Beschriftung des Registers')
|
||||
const belehrung = await featureHaken('termine').belehrung()
|
||||
const region = seite.locator('.tm-bestellen-belehrung .tm-rechtstext')
|
||||
assert(await region.isVisible(), 'Widerrufsbelehrung ist sichtbar (nicht eingeklappt)')
|
||||
const belehrungImDom = (await region.textContent()).trim()
|
||||
eq(belehrungImDom, belehrung.trim(), 'Widerrufsbelehrung mit Muster-Formular wie im Register (bis F7: Platzhalter)')
|
||||
const reihenfolge = await page.evaluate(() => {
|
||||
const b = document.querySelector('.tm-bestellen-belehrung .tm-rechtstext')
|
||||
const k = document.querySelector('.tm-bestellen [data-aktion="bestellen"]')
|
||||
return { vorKnopf: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_FOLLOWING), umgekehrt: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_PRECEDING) }
|
||||
})
|
||||
eq(reihenfolge, { vorKnopf: true, umgekehrt: false }, 'Belehrung steht im DOM vor dem Knopf')
|
||||
// die gezeigten Texte der Zustimmungen: genau sie muessen im Nachweis stehen
|
||||
const gezeigt = await page.evaluate(() => [...document.querySelectorAll('.tm-bestellen-haken > *')].map((el) => ({
|
||||
haken: el.matches('label') ? el.querySelector('input').getAttribute('data-haken') : null,
|
||||
text: el.textContent.trim(),
|
||||
})))
|
||||
assert(gezeigt.length > 0, 'Zustimmungen sind zu sehen')
|
||||
await shot(page, 'bestellseite-gesperrt')
|
||||
const hakenZahl = await hakenSetzen(page)
|
||||
assert(hakenZahl > 0 && (await knopf.isEnabled()), 'Knopf aktiv nach allen Haken')
|
||||
await shot(page, 'bestellseite-bereit')
|
||||
// ein einzelner Haken weniger sperrt wieder
|
||||
await page.locator('.tm-bestellen [data-haken]').first().uncheck()
|
||||
assert(await knopf.isDisabled(), 'Knopf wieder gesperrt bei fehlendem Haken')
|
||||
await page.locator('.tm-bestellen [data-haken]').first().check()
|
||||
|
||||
// ---- Klick
|
||||
const vorKlick = Date.now()
|
||||
const checkoutAnfrage = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
await knopf.click()
|
||||
const body = (await checkoutAnfrage).postDataJSON()
|
||||
await page.waitForURL(STRIPE_SEITE)
|
||||
const sitzungId = STRIPE_SEITE.exec(page.url())[1]
|
||||
eq(t.stripe.length, 1, 'die Zahlungsseite wurde genau einmal aufgerufen')
|
||||
const aufruf = t.stripe[0]
|
||||
|
||||
// der Koerper der Bestellung: Projekt, Fassung und die IDs der Haken (nie Texte)
|
||||
eq(Object.keys(body).sort(), ['projectId', 'zustimmung'], 'Koerper der Bestellung')
|
||||
eq(body.projectId, PROJEKT, 'Projekt der Bestellung')
|
||||
eq(body.zustimmung.version, REGISTER.consent.version, 'Fassung der Zustimmung')
|
||||
eq(body.zustimmung.haken, REGISTER.consent.items.filter((i) => i.haken).map((i) => i.id), 'IDs der Haken in Registerreihenfolge')
|
||||
|
||||
// Funktionsdokument ordered mit der Sitzung
|
||||
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
|
||||
eq([feature.state, feature.stripeSessionId], ['ordered', sitzungId], 'Funktionsdokument ordered mit stripeSessionId')
|
||||
// genau eine Checkout-Sitzung bei Stripe, fuer den Terminplaner und das Projekt
|
||||
const sitzungen = srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
|
||||
eq(sitzungen.length, 1, 'genau ein POST /v1/checkout/sessions')
|
||||
eq([sitzungen[0].body['metadata[featureKey]'], sitzungen[0].body['metadata[projectId]']], ['termine', PROJEKT], 'Metadaten der Sitzung')
|
||||
|
||||
// genau ein Nachweis mit den gezeigten Texten
|
||||
const nachweise = [...srv.aw.docs('featureConsents').values()]
|
||||
eq(nachweise.length, 1, 'genau ein featureConsents-Dokument')
|
||||
const n = leseZustimmung(nachweise[0])
|
||||
eq([n.customerId, n.projectId, n.featureKey, n.stripeSessionId], [KUNDE, PROJEKT, 'termine', sitzungId], 'Nachweis gehoert zu dieser Sitzung')
|
||||
eq(n.fassungen, zustimmungsfassungen(REGISTER), 'Fassungen der Unterlagen im Nachweis')
|
||||
for (const eintrag of n.texte.filter((x) => x.haken !== undefined && x.id !== 'belehrung' && x.id !== 'knopf')) {
|
||||
const sichtbar = gezeigt.find((g) => (eintrag.haken ? g.haken === eintrag.id : g.haken === null) && g.text === eintrag.text)
|
||||
assert(sichtbar, `Text im Nachweis wurde so gezeigt: ${eintrag.id}`)
|
||||
}
|
||||
eq(n.texte, nachweisTexte(REGISTER, { belehrung }), 'Texte im Nachweis = Texte des Registers samt Belehrung und Knopf, in Anzeigereihenfolge')
|
||||
eq(n.texte.find((x) => x.id === 'belehrung').text.trim(), belehrungImDom, 'Belehrung im Nachweis = die gezeigte Belehrung')
|
||||
const gespeicherterKnopf = n.texte.find((x) => x.id === 'knopf').text
|
||||
eq([gespeicherterKnopf, REGISTER.consent.knopf, knopfText], [knopfText, knopfText, knopfText], 'Knopf: gespeichert = Register = Beschriftung des geklickten Knopfes')
|
||||
eq(nachweise[0].textHash, texteHash(n.texte), 'Pruefsumme der Texte')
|
||||
// Zeit des Klicks: nach dem Mausklick des Skripts, und gespeichert, BEVOR die Zahlungsseite aufgerufen wurde
|
||||
const zeit = Date.parse(n.acceptedAt)
|
||||
assert(Number.isFinite(zeit) && zeit >= vorKlick - 1000, `Zeit des Klicks liegt nach dem Start des Skripts: ${n.acceptedAt}`)
|
||||
assert(zeit < aufruf.at, `Nachweis (${n.acceptedAt}) kleiner als der Aufruf der Zahlungsseite (${new Date(aufruf.at).toISOString()})`)
|
||||
assert(Date.parse(nachweise[0].$createdAt) <= aufruf.at, 'Nachweis war angelegt, bevor die Zahlungsseite aufgerufen wurde')
|
||||
eq(aufruf.nachweise, 1, 'beim Aufruf der Zahlungsseite lag der Nachweis schon in der Datenbank')
|
||||
})
|
||||
|
||||
// ============================================================ offene Punkte sperren
|
||||
await check('offene_punkte_sperren', async (page) => {
|
||||
// (a) Impressum fehlt
|
||||
await srv.entwurf({
|
||||
eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, impressumUrl: '' } }),
|
||||
fragebogen: vollstaendig(),
|
||||
})
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
const offen = page.locator('.tm-bestellen-offen')
|
||||
await offen.waitFor()
|
||||
const punkt = offen.locator('li', { hasText: 'Impressum' })
|
||||
eq(await punkt.count(), 1, 'genau ein Punkt zum Impressum')
|
||||
eq(await offen.locator('li').count(), 1, 'sonst nichts offen')
|
||||
eq(await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).innerText(), 'Zum Schritt', 'Knopf „Zum Schritt“')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf bleibt trotz aller Haken gesperrt')
|
||||
assert((await page.locator('[data-hinweis="bestellen"]').innerText()).includes('offenen Punkte'), 'Hinweis am Knopf nennt die offenen Punkte')
|
||||
await shot(page, 'offen-impressum')
|
||||
await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).click()
|
||||
await warteSchritt(page, 'website')
|
||||
eq(await page.locator('.tm-fb-schritt').getAttribute('data-schritt'), 'website', 'Schritt 8 geoeffnet')
|
||||
eq(await standText(page), 'Schritt 8 von 8', 'Fortschritt Schritt 8')
|
||||
eq(await feldIn(page, 'betrieb.impressumUrl').inputValue(), '', 'Impressum-Feld ist leer')
|
||||
// nachtragen: danach ist der Punkt weg, der Knopf nach den Haken aktiv
|
||||
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'Punkt erledigt')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isEnabled(), 'Knopf aktiv, wenn nichts mehr offen ist')
|
||||
|
||||
// (b) Bundesland nie gewaehlt, obwohl der Server RP gespeichert hat
|
||||
await srv.entwurf({ fragebogen: vollstaendig({ bundeslandGewaehlt: false }) })
|
||||
eq(srv.einstellung().bundesland, 'RP', 'der Serverstandard RP ist gespeichert')
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
const offenLand = page.locator('.tm-bestellen-offen')
|
||||
await offenLand.waitFor()
|
||||
eq(await offenLand.locator('li').count(), 1, 'nur ein Punkt offen')
|
||||
const landPunkt = offenLand.locator('li', { hasText: 'Bundesland' })
|
||||
eq(await landPunkt.count(), 1, 'Punkt zum Bundesland')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt ohne Bundesland')
|
||||
await shot(page, 'offen-bundesland')
|
||||
await landPunkt.getByRole('button', { name: 'Zum Schritt „Zeiten“' }).click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
eq(await standText(page), 'Schritt 4 von 8', 'Punkt fuehrt in den Schritt Zeiten')
|
||||
eq(await feldIn(page, 'bundesland').inputValue(), '', 'Bundesland ist nicht vorgewaehlt (RP ist keine Wahl)')
|
||||
})
|
||||
|
||||
// ============================================================ Rueckkehr und Zahlung fortsetzen
|
||||
await check('rueckkehr_und_zahlung_fortsetzen', async (page, t) => {
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
const sitzung = await srv.bestellt()
|
||||
const sitzungsUrl = `https://checkout.stripe.test/c/pay/${sitzung}`
|
||||
|
||||
// ---- Rueckkehr nach der Zahlung: Statuskarte mit „Danke!“, Adresse bereinigt, Einstellungen gesperrt
|
||||
await oeffne(page, '#termine?bestellt=1&projekt=p1')
|
||||
const karte = page.locator('.tm-statuskarte')
|
||||
await karte.waitFor()
|
||||
eq(await karte.getAttribute('data-state'), 'ordered', 'Statuskarte im Zustand ordered')
|
||||
assert((await karte.innerText()).includes('Zahlung offen'), `Karte: ${await karte.innerText()}`)
|
||||
assert((await karte.locator('[data-danke]').innerText()).startsWith('Danke!'), 'Danke! nach der Rueckkehr')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
|
||||
const reiter = await page.locator('[data-reiter]').evaluateAll((els) => els.map((el) => el.dataset.reiter))
|
||||
eq(reiter, ['leistungen', 'team', 'einstellungen'], 'Reiter ohne „Termine“')
|
||||
await page.locator('[data-reiter="einstellungen"]').click()
|
||||
await page.locator('#tm-panel .tm-abschnitt').first().waitFor()
|
||||
const felder = await page.locator('#tm-panel').evaluate((panel) => {
|
||||
const alle = [...panel.querySelectorAll('input, select, textarea')]
|
||||
return { alle: alle.length, offen: alle.filter((el) => !el.disabled && !el.matches(':disabled')).length }
|
||||
})
|
||||
assert(felder.alle > 0, 'Einstellungen zeigen Felder')
|
||||
eq(felder.offen, 0, 'alle Felder der Einstellungen sind gesperrt')
|
||||
await shot(page, 'rueckkehr-zahlung-offen')
|
||||
|
||||
// ---- Zahlung fortsetzen: dieselbe Sitzung, kein zweiter Nachweis
|
||||
const fortsetzen = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
|
||||
eq((await fortsetzen).postDataJSON(), { projectId: PROJEKT, fortsetzen: true }, 'Koerper von „Zahlung fortsetzen“')
|
||||
await page.waitForURL(sitzungsUrl)
|
||||
eq(t.stripe.map((s) => s.url), [sitzungsUrl], 'Navigation zur selben Sitzung')
|
||||
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
|
||||
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine neue Sitzung angelegt')
|
||||
eq(srv.aw.docs('featureConsents').size, 1, 'weiterhin genau ein featureConsents-Dokument')
|
||||
|
||||
// ---- Abbruch bei Stripe: Rueckkehr ohne „Danke!“
|
||||
await oeffne(page, '#termine?projekt=p1')
|
||||
await karte.waitFor()
|
||||
assert((await karte.innerText()).includes('Zahlung offen'), 'Abbruch: Karte Zahlung offen')
|
||||
eq(await page.locator('[data-danke]').count(), 0, 'Abbruch: kein „Danke!“')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Abbruch: Adresse bereinigt')
|
||||
|
||||
// ---- ein PATCH direkt am Router ist gesperrt
|
||||
const patch = await page.evaluate(async (version) => {
|
||||
const res = await fetch('/api/termine/einstellungen', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', version, einstellung: {} }) })
|
||||
return { status: res.status, code: (await res.json()).code }
|
||||
}, srv.version())
|
||||
eq(patch, { status: 409, code: 'gesperrt' }, 'PATCH bei ordered ist 409 gesperrt')
|
||||
|
||||
// ---- ohne Nachweis gibt es keine Weiterleitung: das Bestellformular kommt wieder
|
||||
await srv.loescheZustimmungen()
|
||||
await oeffne(page, '#termine')
|
||||
await karte.waitFor()
|
||||
const antwort = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
const naviVorher = t.stripe.length
|
||||
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
|
||||
const res = await antwort
|
||||
eq(res.status(), 409, 'ohne Nachweis: 409')
|
||||
eq((await res.json()).code, 'zustimmung_fehlt', 'ohne Nachweis: zustimmung_fehlt')
|
||||
await page.locator('.tm-bestellen').waitFor()
|
||||
eq(await page.locator('.tm-statuskarte').count(), 0, 'die Karte ist durch das Bestellformular ersetzt')
|
||||
assert((await page.locator('#view-termine').innerText()).includes('Bitte stimme der Erklärung zu'), 'Meldung zur fehlenden Zustimmung')
|
||||
assert(page.url().startsWith(srv.url), `keine Navigation: ${page.url()}`)
|
||||
eq(t.stripe.length, naviVorher, 'die Zahlungsseite wurde nicht aufgerufen')
|
||||
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
|
||||
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis ohne Zustimmung des Kunden')
|
||||
await shot(page, 'zahlung-fortsetzen-ohne-nachweis')
|
||||
|
||||
// ---- Zustand setup: Karte mit Ticketnummer und Rechnungslink
|
||||
await srv.einrichtung()
|
||||
await oeffne(page, '#termine')
|
||||
await karte.waitFor()
|
||||
eq(await karte.getAttribute('data-state'), 'setup', 'Karte im Zustand setup')
|
||||
const text = await karte.innerText()
|
||||
assert(text.includes('In Einrichtung') && text.includes('#100342'), `Karte mit Ticketnummer: ${text}`)
|
||||
const rechnung = karte.getByRole('link', { name: 'Rechnung ansehen' })
|
||||
eq(await rechnung.getAttribute('href'), 'https://invoice.stripe.test/i/in_ui1', 'Rechnungslink')
|
||||
eq(await rechnung.getAttribute('rel'), 'noopener noreferrer', 'Rechnungslink mit rel')
|
||||
eq(await page.getByRole('button', { name: 'Zahlung fortsetzen' }).count(), 0, 'in der Einrichtung gibt es kein „Zahlung fortsetzen“')
|
||||
await shot(page, 'in-einrichtung')
|
||||
})
|
||||
|
||||
// ============================================================ Entwurf bleibt beim Neuladen
|
||||
await check('entwurf_bleibt_beim_neuladen', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await brancheStarten(page, t)
|
||||
await schritteAusfuellen(page, t, 'zeiten') // Schritte 2 bis 4
|
||||
await warteSchritt(page, 'regeln')
|
||||
|
||||
// ---- neu laden: Start bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'regeln')
|
||||
eq(await standText(page), 'Schritt 5 von 8', 'nach dem Neuladen Schritt 5')
|
||||
assert((await page.locator('.tm-schritt[data-erledigt="true"]').count()) === 4, 'vier Schritte als erledigt markiert')
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Schritt 1: Name')
|
||||
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Schritt 1: Anschrift')
|
||||
assert((await page.locator('.tm-fb-branche').innerText()).includes('Friseur und Salon'), 'Schritt 1: Branche')
|
||||
await schrittKnopf(page, 'wer').click()
|
||||
await warteSchritt(page, 'wer')
|
||||
assert(await page.locator('input[name="termine-wer-art"][value="team"]').isChecked(), 'Schritt 2: Team gewaehlt')
|
||||
eq(await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').inputValue(), 'Anna\nBen', 'Schritt 2: Namen')
|
||||
await schrittKnopf(page, 'leistungen').click()
|
||||
await warteSchritt(page, 'leistungen')
|
||||
assert(await feldIn(page, 'preiseGeprueft').isChecked(), 'Schritt 3: Preise geprueft')
|
||||
await schrittKnopf(page, 'zeiten').click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
eq(await feldIn(page, 'bundesland').inputValue(), 'RP', 'Schritt 4: Bundesland')
|
||||
await shot(page, 'nach-neuladen')
|
||||
|
||||
// ---- Schritt 8 vorab ausfuellen und bestaetigen: der Einbauwunsch muss den Wechsel der Branche ueberstehen
|
||||
await schrittKnopf(page, 'website').click()
|
||||
await warteSchritt(page, 'website')
|
||||
await EINGABE.website(page)
|
||||
await feldIn(page, 'einbau.stelle').fill('Auf der Seite Kontakt unter dem Text')
|
||||
await weiter(page, t, 'website', 'bestellen')
|
||||
NACH_SCHRITT.website()
|
||||
eq(srv.fragebogen().bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten', 'website'], 'fuenf Schritte bestaetigt')
|
||||
const einbau = srv.fragebogen().einbau
|
||||
eq(einbau.stelle, 'Auf der Seite Kontakt unter dem Text', 'Einbauwunsch mit Stelle gespeichert')
|
||||
|
||||
// ---- Branche wechseln mit Rueckfrage
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
await page.locator('[data-aktion="branche-wechseln"]').click()
|
||||
const rueckfrage = page.locator('.tm-frage-box')
|
||||
await rueckfrage.waitFor()
|
||||
assert((await rueckfrage.innerText()).includes('Alle Bestätigungen gehen zurück'), 'Rueckfrage warnt vor dem Zuruecksetzen')
|
||||
eq(srv.fragebogen().branche, 'salon', 'vor der Bestaetigung ist nichts geaendert')
|
||||
await rueckfrage.locator('label.tm-vorlage', { hasText: 'Kosmetik und Nagelstudio' }).locator('input').check()
|
||||
await shot(page, 'branche-wechseln-rueckfrage')
|
||||
const v0 = srv.version()
|
||||
await rueckfrage.getByRole('button', { name: 'Ja, Branche wechseln', exact: true }).click()
|
||||
await page.locator('.tm-fb-branche', { hasText: 'Kosmetik und Nagelstudio' }).waitFor()
|
||||
eq(srv.fragebogen().branche, 'kosmetik', 'neue Branche gespeichert')
|
||||
eq(srv.fragebogen().bestaetigt, [], 'Bestaetigungen zurueckgesetzt')
|
||||
eq(srv.fragebogen().bundeslandGewaehlt, false, 'Bundesland muss neu gewaehlt werden')
|
||||
eq(srv.fragebogen().preiseGeprueft, false, 'Preise muessen neu geprueft werden')
|
||||
eq(srv.fragebogen().einbau, einbau, 'der Einbauwunsch bleibt')
|
||||
eq(srv.einstellung().vorlage, 'kosmetik', 'Einstellung aus der neuen Vorlage')
|
||||
assert(srv.version() > v0, 'Version der Einstellung gestiegen (Ersetzen ist ein Schreiben der Einstellung)')
|
||||
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Betriebsname bleibt')
|
||||
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum bleibt')
|
||||
eq(await page.locator('.tm-schritt[data-erledigt="true"]').count(), 0, 'kein Schritt mehr als erledigt markiert')
|
||||
await shot(page, 'nach-branchenwechsel')
|
||||
})
|
||||
|
||||
// ============================================================ Kundenansicht
|
||||
await check('kundenansicht', async (page, t) => {
|
||||
await srv.entwurf({ fragebogen: vollstaendig({ bestaetigt: ['branche'] }) })
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'wer')
|
||||
const hinweis = page.locator('.tm-fragebogen .tm-box-info')
|
||||
assert((await hinweis.innerText()).includes('Kundenansicht'), 'Hinweis auf die Kundenansicht')
|
||||
// alle Schritte lassen sich lesen und mit „Weiter“ durchklicken, bestaetigt wird nichts
|
||||
for (const [i, key] of FOLGE.slice(1).entries()) {
|
||||
await warteSchritt(page, key)
|
||||
eq(await standText(page), `Schritt ${i + 2} von 8`, `Fortschritt in ${key}`)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, FOLGE[i + 2] ?? 'bestellen')
|
||||
eq(srv.fragebogen().bestaetigt, ['branche'], `nach „Weiter“ in ${key} bleibt bestaetigt unveraendert`)
|
||||
}
|
||||
eq(zaehlePuts(t), 0, 'die Seite sendet in der Kundenansicht keinen PUT')
|
||||
// Bestellseite: gesperrt mit Hinweis (hier zusaetzlich offene Punkte, weil nichts bestaetigt wurde)
|
||||
assert((await page.locator('.tm-bestellen-offen li').count()) >= 7, 'sieben unbestaetigte Schritte stehen als offene Punkte da')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Bestellknopf gesperrt')
|
||||
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf')
|
||||
await shot(page, 'kundenansicht-bestellseite')
|
||||
|
||||
// ---- nichts offen, trotzdem gesperrt: nur die Kundenansicht haelt den Knopf zu
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'nichts offen')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt, obwohl nichts offen ist und alle Haken gesetzt sind')
|
||||
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf (nichts offen)')
|
||||
|
||||
// ---- die Router selbst: PUT /fragebogen und der Checkout sind 403
|
||||
const vorher = srv.fragebogen()
|
||||
const direkt = await page.evaluate(async ({ fragebogen }) => {
|
||||
const sende = async (url, method, body) => {
|
||||
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
return {
|
||||
put: await sende('/api/termine/fragebogen', 'PUT', { projectId: 'p1', fragebogen: { ...fragebogen, bestaetigt: [] } }),
|
||||
checkout: await sende('/api/funktionen/termine/checkout', 'POST', { projectId: 'p1', zustimmung: { version: 'x', haken: [] } }),
|
||||
}
|
||||
}, { fragebogen: vorher })
|
||||
eq(direkt.put.status, 403, 'PUT /fragebogen in der Kundenansicht')
|
||||
eq(direkt.checkout.status, 403, 'Checkout in der Kundenansicht')
|
||||
eq(srv.fragebogen(), vorher, 'der Fragebogen blieb unveraendert')
|
||||
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine Stripe-Sitzung')
|
||||
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis')
|
||||
}, { cookie: 'kundenansicht' })
|
||||
|
||||
// ============================================================ Handy 390 px
|
||||
await check('handy_390', async (page) => {
|
||||
const pruefe = async (name, soll) => {
|
||||
assert(await passtInBreite(page), `${name}: seitliche Rollleiste (scrollWidth ${await page.evaluate(() => document.documentElement.scrollWidth)})`)
|
||||
const stand = page.locator('.tm-schritt-stand')
|
||||
assert(await stand.isVisible(), `${name}: Fortschritt ist am Handy sichtbar`)
|
||||
eq((await stand.innerText()).trim(), soll, `${name}: Fortschritt`)
|
||||
}
|
||||
// Schritt 1 ohne Entwurf
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'branche')
|
||||
await pruefe('Schritt 1 (Start)', 'Schritt 1 von 8')
|
||||
await shot(page, '390-1-start')
|
||||
// mit Entwurf: Bestellseite, Schritt 4, Schritt 8, Schritt 1
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
assert(await passtInBreite(page), 'Bestellseite: seitliche Rollleiste')
|
||||
await hakenSetzen(page)
|
||||
assert(await passtInBreite(page), 'Bestellseite mit Haken: seitliche Rollleiste')
|
||||
await shot(page, '390-2-bestellseite')
|
||||
await schrittKnopf(page, 'zeiten').click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
await pruefe('Schritt 4', 'Schritt 4 von 8')
|
||||
await shot(page, '390-3-zeiten')
|
||||
await schrittKnopf(page, 'website').click()
|
||||
await warteSchritt(page, 'website')
|
||||
await pruefe('Schritt 8', 'Schritt 8 von 8')
|
||||
await shot(page, '390-4-website')
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
await pruefe('Schritt 1 (Entwurf)', 'Schritt 1 von 8')
|
||||
await shot(page, '390-5-branche-entwurf')
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
let abbruch = null
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
await pruefungen()
|
||||
const fremd = [...hosts].filter((h) => !ERLAUBTE_HOSTS.has(h))
|
||||
results.push(['nur_eigene_herkunft', fremd.length === 0])
|
||||
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
|
||||
} catch (e) {
|
||||
abbruch = e
|
||||
console.log(`ABBRUCH: ${e.stack || e.message}`)
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {})
|
||||
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
|
||||
}
|
||||
|
||||
const fehlgeschlagen = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
|
||||
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
|
||||
@@ -21,6 +21,14 @@
|
||||
// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
|
||||
// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
|
||||
//
|
||||
// Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl:
|
||||
// kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift
|
||||
// (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'),
|
||||
// srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks),
|
||||
// srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung),
|
||||
// srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die
|
||||
// Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand.
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
||||
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
||||
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
||||
@@ -118,6 +126,8 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
@@ -128,14 +138,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
const { config } = await import('../../server/config.js')
|
||||
const { startTestApp } = await import('../support/testApp.js')
|
||||
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
|
||||
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
|
||||
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
|
||||
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
|
||||
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
||||
const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
|
||||
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
|
||||
const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js')
|
||||
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
|
||||
const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js')
|
||||
const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
|
||||
const { hostingDoc, wartungDoc } = await import('../support/fixtures.js')
|
||||
|
||||
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
|
||||
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
|
||||
@@ -233,15 +248,123 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo),
|
||||
* 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv).
|
||||
*/
|
||||
async abo(zustand = 'wartung') {
|
||||
for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id)
|
||||
const docs = {
|
||||
wartung: [wartungDoc],
|
||||
hosting: [hostingDoc],
|
||||
keins: [],
|
||||
gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }],
|
||||
nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }],
|
||||
}[zustand]
|
||||
if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`)
|
||||
for (const doc of docs) aw.seed('subscriptions', doc)
|
||||
},
|
||||
|
||||
/** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */
|
||||
async kundenansichtCookie({ customerId = KUNDE } = {}) {
|
||||
const { kundenansichtCookie } = await import('../support/fixtures.js')
|
||||
const roh = kundenansichtCookie({ customerId })
|
||||
const i = roh.indexOf('=')
|
||||
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe,
|
||||
* gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen
|
||||
* ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }).
|
||||
*/
|
||||
async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) {
|
||||
const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true })
|
||||
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
|
||||
resetKontextForTests()
|
||||
aw.seed('portalFeatures', srv.featureDoc('off'))
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
|
||||
await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() })
|
||||
},
|
||||
|
||||
/** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */
|
||||
fragebogen() {
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null
|
||||
},
|
||||
version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 },
|
||||
SCHRITTE,
|
||||
|
||||
/**
|
||||
* Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered
|
||||
* (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit).
|
||||
* Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung.
|
||||
*/
|
||||
async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) {
|
||||
const register = featureConfig('termine')
|
||||
st.checkoutSessions.set(sitzungId, {
|
||||
id: sitzungId,
|
||||
object: 'checkout.session',
|
||||
url: `https://checkout.stripe.test/c/pay/${sitzungId}`,
|
||||
mode: 'payment',
|
||||
customer: null,
|
||||
client_reference_id: KUNDE,
|
||||
subscription: null,
|
||||
metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' },
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
invoice: null,
|
||||
})
|
||||
const klickAt = uhr().toISOString()
|
||||
await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } })
|
||||
if (mitZustimmung) {
|
||||
await speichereZustimmung({
|
||||
customerId: KUNDE,
|
||||
projectId: PROJEKT,
|
||||
featureKey: 'termine',
|
||||
stripeSessionId: sitzungId,
|
||||
fassungen: zustimmungsfassungen(register),
|
||||
texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }),
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'ui-test',
|
||||
konto: 'Silke Müller <silke@salon-mueller.example>',
|
||||
at: klickAt,
|
||||
})
|
||||
}
|
||||
resetKontextForTests()
|
||||
return sitzungId
|
||||
},
|
||||
|
||||
/**
|
||||
* Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht
|
||||
* geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt).
|
||||
*/
|
||||
async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) {
|
||||
aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' })
|
||||
await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl })
|
||||
resetKontextForTests()
|
||||
},
|
||||
|
||||
/** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */
|
||||
async loescheZustimmungen() {
|
||||
for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
|
||||
* ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
|
||||
* entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein
|
||||
* Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer.
|
||||
*/
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) {
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) {
|
||||
await warteAufVersand()
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
||||
const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders']
|
||||
for (const collection of sammlungen) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
st.checkoutSessions.clear()
|
||||
st.calls.length = 0
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
resetTermineLimitsForTests()
|
||||
@@ -259,7 +382,16 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
srv.sink = mailSink()
|
||||
setMailTransportForTests(srv.sink)
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
|
||||
aw.seed('customers', {
|
||||
$id: KUNDE,
|
||||
name: 'Silke Müller',
|
||||
email: 'silke@salon-mueller.example',
|
||||
...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}),
|
||||
})
|
||||
if (entwurf) {
|
||||
await srv.abo('wartung')
|
||||
return
|
||||
}
|
||||
aw.seed('portalFeatures', srv.featureDoc())
|
||||
if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user