Webshop: Playwright-Durchlauf (Anfrage bis Widerruf) mit Nachbauten von Appwrite, Stripe Connect und Mail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 10:23:45 +00:00
1 parent 72d79ddba7
commit 4ab84e31ac
2 files changed
+1084

No files matched your search

+795
View File
@@ -0,0 +1,795 @@
// End-to-end-Pruefung des Webshops im Browser (Playwright, Chromium), gegen die echten Router hinter Nachbauten von Appwrite, Stripe (Connect)
// und Mail (test/ui/shopTestServer.mjs). Nicht Teil von npm test.
// Aufruf: nice node --max-old-space-size=1024 test/ui/shop.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/webshop-2026-10-05/ui/ (Desktop 1280x900 und Telefon 390x844). Exit-Code 1, wenn ein Schritt scheitert.
//
// Ein durchgehendes Szenario in acht Schritten (jeder Schritt baut auf dem vorigen auf):
// 1 Kunde (Pilot): Kachel Webshop, Anfrage stellen 5 Mails G09 und K25
// 2 Team nimmt an, Bestellseite, Zahlung, Freischaltung 6 Haendler: Bestellung, versendet (G10), Erstattung (G13)
// 3 Reiter Einstellungen, Rechtliches, Artikel, Uebersicht 7 Widerruf (G12, K26)
// 4 Kaeufer: Seite /shop/<siteKey>, Demo-Seite, Kasse, Zahlung 8 Shop schliessen, Widerruf bleibt erreichbar
// Stripe-Adressen (https://*.stripe.test) werden per page.route abgefangen; Mitarbeiter-Schritte rufen die Dienste direkt auf.
// NUR=<Teil des Namens> fuehrt nur passende Schritte aus (setzt voraus, dass die vorigen Zustaende schon bestehen: meist sinnlos).
import { mkdir, readdir, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { startShopUiServer, ANFRAGE, KUNDEN_EMAIL } from './shopTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core'
const { chromium } = createRequire(import.meta.url)(PW)
const sharp = createRequire(import.meta.url)('sharp')
const CHROME = process.env.CHROME_BIN || path.join(process.env.HOME || '/root', '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome')
const SHOTS = process.env.SHOTS || '/root/reports/webshop-2026-10-05/ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startShopUiServer()
const DESKTOP = { width: 1280, height: 900 }
const PHONE = { width: 390, height: 844 }
// ---------------------------------------------------------------- Helfer
const ergebnisse = []
let schritt = null
/** Weiche Pruefung: merkt den Fehler und laeuft weiter (Schritt scheitert am Ende). */
function ok(bedingung, text) {
if (!bedingung) {
schritt.fehler.push(text)
console.log(` x ${text}`)
} else if (process.env.VERBOSE) console.log(` + ${text}`)
return Boolean(bedingung)
}
const norm = (t) => String(t ?? '').replace(/ | /g, ' ').replace(/\s+/g, ' ').trim()
const enthaelt = (text, teil) => norm(text).includes(norm(teil))
/** Screenshot der ganzen Seite in Desktop- und Telefonbreite (Fenster auf Seitenhoehe, hoechstens 3600 px). */
async function shot(page, name, { telefon = true } = {}) {
for (const [suffix, groesse] of [['desktop', DESKTOP], ['phone', PHONE]]) {
if (suffix === 'phone' && !telefon) continue
await page.setViewportSize(groesse)
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
await page.waitForTimeout(150)
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
await page.setViewportSize({ width: groesse.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/${name}-${suffix}.png` })
}
await page.setViewportSize(DESKTOP)
}
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
async function warteBis(bedingung, text, ms = 8000) {
const ende = Date.now() + ms
for (;;) {
if (await bedingung()) return
if (Date.now() > ende) throw new Error(`Zeit abgelaufen: ${text}`)
await new Promise((fertig) => { setTimeout(fertig, 50) })
}
}
/** 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: 'Hanna Brandt', email: KUNDEN_EMAIL } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Töpferei Brandt', 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({})
}
const ECHT = [/^\/api\/funktionen/, /^\/api\/shop/, /^\/api\/public\/shop/, /^\/api\/abo\/webhook/]
// Aufzeichnung der abgefangenen Stripe-Navigationen: { art: 'connect' | 'checkout', url }
const stripeNavigationen = []
const offeneSeiten = new Set() // fuer Fehler-Screenshots
const fremdeHosts = new Set()
/** Seite der Haendler-Website (https://www.toepferei-brandt.example/...): Platzhalter fuer den Einbau des Shops mit data-api (Cross-Origin). */
const kundenSeite = (key) => `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Töpferei Brandt</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#2b2b2b}header,footer{padding:16px 24px;background:#e9e2d6}main{max-width:900px;margin:0 auto;padding:24px 16px}.fallback{padding:16px;border:2px dashed #999}</style></head>
<body><header>Töpferei Brandt – Website des Händlers (Testseite)</header><main><h1>Unser Online-Shop</h1>
<div data-webklar-shop="${key}" data-api="${srv.portalUrl}"><p class="fallback">Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06302 1234567.</p></div></main>
<footer>Fußbereich</footer><script src="${srv.portalUrl}/embed/shop.js" defer></script></body></html>`
async function neuerKontext(browser, { cookie = true, viewport = DESKTOP } = {}) {
// Service Worker des Portals blockieren: ihre Anfragen umgingen page.route
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' })
if (cookie) await ctx.addCookies([await srv.portalCookie()])
ctx.setDefaultTimeout(10000)
// Portal (https://project.webklar.test): echte Router des Testservers; die uebrigen Portal-Schnittstellen beantwortet portalHilfe
await ctx.route(`${srv.portalUrl}/**`, async (route) => {
const req = route.request()
const url = new URL(req.url())
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname.startsWith('/api/') && !ECHT.some((re) => re.test(url.pathname))) return portalHilfe(url.pathname, json)
const headers = await req.allHeaders()
delete headers.host
const antwort = await route.fetch({ url: `${srv.url}${url.pathname}${url.search}`, headers })
return route.fulfill({ response: antwort })
})
// Website des Haendlers: ihre Seiten liefert das Skript (jede Adresse dieselbe Seite, mit oder ohne Rueckkehr-Parameter)
await ctx.route(`${srv.kundenUrl}/**`, async (route) => {
if (new URL(route.request().url()).pathname === '/favicon.ico') return route.fulfill({ status: 404, body: '' })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: kundenSeite(srv.siteKey()) })
})
// Stripe: Navigation abfangen (Haendler-Onboarding -> zurueck ins Portal; Kasse -> Platzhalterseite, der Test navigiert selbst zurueck)
await ctx.route(/^https:\/\/[a-z]+\.stripe\.test\//, async (route) => {
const url = route.request().url()
const host = new URL(url).host
if (host === 'connect.stripe.test') {
stripeNavigationen.push({ art: 'connect', url })
const zurueck = `${srv.portalUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${srv.PROJEKT}`
return route.fulfill({ status: 200, contentType: 'text/html', body: `<!doctype html><title>Stripe (Nachbau)</title><p>Onboarding</p><script>location.replace(${JSON.stringify(zurueck)})</script>` })
}
stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url })
return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Stripe-Kasse (Nachbau)</title><h1>Stripe-Kasse (Nachbau)</h1>' })
})
ctx.on('page', (seite) => {
offeneSeiten.add(seite)
seite.on('close', () => offeneSeiten.delete(seite))
seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } })
seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) })
})
return ctx
}
let browser = null
async function lauf(name, fn) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
schritt = { name, fehler: [], hinweise: [] }
console.log(`--- ${name}`)
try {
await fn()
} catch (e) {
schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
}
if (schritt.fehler.length) {
let i = 0
for (const seite of offeneSeiten) {
i += 1
await seite.evaluate(() => window.scrollTo(0, 0)).catch(() => {})
const hoehe = await seite.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600)).catch(() => 900)
await seite.setViewportSize({ width: 1280, height: hoehe }).catch(() => {})
await seite.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png` }).catch(() => {})
await seite.setViewportSize(DESKTOP).catch(() => {})
}
}
const bestanden = schritt.fehler.length === 0
ergebnisse.push([name, bestanden, schritt.fehler, schritt.hinweise])
console.log(`${bestanden ? 'ok ' : 'FAIL'} ${name}${bestanden ? '' : `: ${schritt.fehler.join(' | ')}`}`)
for (const h of schritt.hinweise) console.log(` ! ${h}`)
}
const oeffne = (page, hash) => page.goto(`${srv.portalUrl}/dashboard.html${hash}`)
const webshopBereit = async (page) => {
await page.locator('#view-webshop.active').waitFor()
await page.locator('#webshop-body :is(.sh-reiterleiste, .sh-anfrage, .sh-anfrage-karte, .tm-bestellen, .sh-info)').first().waitFor()
}
const reiter = async (page, key) => {
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#sh-tab-${key}[aria-selected="true"]`).waitFor()
}
const meldung = (page) => page.locator('.sh-meldung')
const warteMeldung = async (page, teil) => {
await warteBis(async () => enthaelt(await meldung(page).innerText().catch(() => ''), teil), `Meldung „${teil}“`)
}
/** Artikel speichern und warten, bis der Server n Artikel hat; sonst die Feldfehler des Editors melden. */
async function speichereArtikel(page, form, n) {
await form.locator('[data-aktion="speichern"]').click()
const ende = Date.now() + 10000
while (srv.produkte().length < n && Date.now() < ende) {
const fehler = await page.locator('[data-fehler]:not(:empty)').allInnerTexts()
if (fehler.length) throw new Error(`Artikel ${n} nicht gespeichert, Feldfehler: ${fehler.join(' / ')}`)
await new Promise((fertig) => { setTimeout(fertig, 100) })
}
ok(srv.produkte().length === n, `${n}. Artikel gespeichert (${srv.produkte().length})`)
}
const echteMeldungen = async (page) => norm(await meldung(page).innerText().catch(() => ''))
// ---------------------------------------------------------------- Szenario
let portalPage = null // Haendler (mit Cookie)
let ctxHaendler = null
const merk = {} // Zahlen und IDs zwischen den Schritten
async function szenario() {
ctxHaendler = await neuerKontext(browser)
portalPage = await ctxHaendler.newPage()
const page = portalPage
// ------------------------------------------------------------ 1
await lauf('1_kunde_kachel_und_anfrage', async () => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="shop"]')
await kachel.waitFor()
ok(enthaelt(await kachel.innerText(), 'Webshop'), 'Kachel Webshop sichtbar')
ok(enthaelt(await kachel.innerText(), '299 €'), 'Kachel nennt den Preis 299 €')
ok(await page.locator('[data-fn-tile="shop"] .fn-tile-cta').innerText().then((t) => enthaelt(t, 'Webshop anfragen')), 'Knopf „Webshop anfragen“')
await shot(page, '01-kachel-webshop')
await kachel.locator('.fn-tile-cta').click()
await webshopBereit(page)
const form = page.getByRole('form', { name: 'Webshop anfragen' })
await form.waitFor()
await shot(page, '02-anfrage-formular')
await form.locator('[data-feld="warenart"]').selectOption(ANFRAGE.warenart)
await form.locator('[data-feld="beschreibung"]').fill(ANFRAGE.beschreibung)
await form.locator('[data-feld="artikelzahl"]').selectOption(ANFRAGE.artikelzahl)
await form.locator('[data-feld="versand"]').check()
await form.locator('[data-feld="abholung"]').check()
await form.locator('[data-feld="unternehmer"]').check()
await form.locator('[data-feld="keineAusgeschlossenen"]').check()
await form.locator('[data-feld="nachricht"]').fill(ANFRAGE.nachricht)
await form.locator('[data-aktion="anfrage-senden"]').click()
const karte = page.locator('[data-anfrage="offen"]')
await karte.waitFor()
ok(enthaelt(await karte.innerText(), 'Anfrage wird geprüft'), 'Karte „Deine Anfrage wird geprüft“')
ok(enthaelt(await karte.innerText(), 'Anfrage zurückziehen'), 'Knopf „Anfrage zurückziehen“')
await shot(page, '03-anfrage-wird-geprueft')
ok(srv.shopDoc()?.anfrageStatus === 'offen', `shopConfig.anfrageStatus offen (ist ${srv.shopDoc()?.anfrageStatus})`)
await srv.warteAufVersand()
ok(srv.mails('Webshop').length >= 1, `Team-Mail zur Anfrage (T21) verschickt (${srv.mails().map((m) => m.subject).join(' / ')})`)
})
// ------------------------------------------------------------ 2
await lauf('2_annahme_bestellseite_zahlung_freischaltung', async () => {
const r = await srv.entscheide('annehmen')
ok(r.anfrage.status === 'angenommen', `Anfrage angenommen (${r.anfrage.status}), mailFehler ${r.mailFehler}`)
ok(srv.mails('Webshop').some((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)), 'K24 an den Kunden')
await page.reload()
await webshopBereit(page)
const seite = page.locator('.tm-bestellen')
await seite.waitFor()
const haken = await seite.locator('input[data-haken]').count()
ok(haken === 5, `fünf Haken auf der Bestellseite (${haken})`)
const knopf = seite.locator('[data-aktion="bestellen"]')
ok(norm(await knopf.innerText()) === 'Zahlungspflichtig bestellen · 299 €', `Knopf „${norm(await knopf.innerText())}“`)
ok(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
await shot(page, '04-bestellseite')
for (const h of await seite.locator('input[data-haken]').all()) await h.check()
ok(await knopf.isEnabled(), 'Knopf frei nach allen Haken')
await knopf.click()
await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Navigation zur Stripe-Kasse')
const nav = stripeNavigationen.filter((n) => n.art === 'checkout').at(-1)
ok(/^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/.test(nav.url), `Weiterleitung zur Kasse (${nav.url})`)
ok(srv.featureDoc()?.state === 'ordered', `Funktion ordered (${srv.featureDoc()?.state})`)
ok(srv.aw.docs('featureConsents').size === 1, `Zustimmungsnachweis gespeichert (${srv.aw.docs('featureConsents').size})`)
// Zahlung: signierter Plattform-Webhook
const w = await srv.bezahleEinrichtung()
ok(w.status === 200, `Plattform-Webhook ${w.status} ${JSON.stringify(w.body)}`)
ok(srv.featureDoc()?.state === 'setup', `Funktion setup nach Zahlung (${srv.featureDoc()?.state})`)
ok(Boolean(srv.featureDoc()?.ticketId), 'Einrichtungs-Ticket angelegt')
ok(srv.siteKey() && /^[a-z2-7]{16}$/.test(srv.siteKey()), `siteKey ${srv.siteKey()}`)
// zurueck im Portal: „Danke!“ und Hinweis Einrichtung
await oeffne(page, `#webshop?bestellt=1&projekt=${srv.PROJEKT}`)
await page.reload()
await webshopBereit(page)
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 1, 'Hinweis „Wir bauen deinen Shop gerade ein“')
await srv.schliesseTicket()
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
await warteBis(() => srv.featureDoc()?.state === 'active', 'Funktion active nach geschlossenem Ticket')
ok(srv.featureDoc()?.state === 'active', 'Funktion active')
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 0, 'Hinweis Einrichtung weg')
await shot(page, '05-shop-aktiv-uebersicht')
})
// ------------------------------------------------------------ 3a Einstellungen
await lauf('3a_einstellungen', async () => {
await reiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen des Webshops' })
await form.waitFor()
await shot(page, '06-einstellungen-leer')
const f = (n) => form.locator(`[data-feld="${n}"]`)
await f('shopname').fill('Töpferei Brandt')
await f('akzent').fill('#0f766e')
await f('anbieter.name').fill('Töpferei Brandt, Inh. Hanna Brandt')
await f('anbieter.strasse').fill('Hauptstraße 12')
await f('anbieter.plz').fill('67722')
await f('anbieter.ort').fill('Winnweiler')
await f('anbieter.email').fill(KUNDEN_EMAIL)
await f('anbieter.telefon').fill('06302 1234567')
await f('anbieter.ustId').fill('DE123456789')
await form.locator('input[name="sh-kleinunternehmer"][value="nein"]').check()
await f('versand.laender').selectOption('DE,AT')
await f('versand.kostenCent.DE').fill('4,90')
await f('versand.kostenCent.AT').fill('7,90')
await f('versand.freiAbCent').fill('80,00')
await f('abholung.aktiv').check()
await f('abholung.adresse').fill('Hauptstraße 12, 67722 Winnweiler')
await f('abholung.hinweis').fill('Di bis Fr 10 bis 17 Uhr')
await f('lieferzeit').fill('2–4 Werktage')
await shot(page, '08-einstellungen-ausgefuellt')
await form.locator('[data-aktion="einstellungen-speichern"]').click()
await warteMeldung(page, 'Die Einstellungen sind gespeichert')
const e = srv.einstellungen()
ok(e.kleinunternehmer === false, `Steuerart gespeichert: kleinunternehmer ${e.kleinunternehmer}`)
ok(e.anbieter?.ort === 'Winnweiler' && e.shopname === 'Töpferei Brandt', 'Einstellungen im Dokument')
ok(e.versand?.kostenCent?.AT === 790 && e.versand?.freiAbCent === 8000, `Versandkosten ${JSON.stringify(e.versand?.kostenCent)} frei ab ${e.versand?.freiAbCent}`)
ok(e.zahlungsarten?.includes('card'), 'Karte als Zahlungsart')
})
// ------------------------------------------------------------ 3b Rechtliches
await lauf('3b_rechtliches', async () => {
await reiter(page, 'rechtliches')
const form = page.getByRole('form', { name: 'Rechtstexte des Webshops' })
await form.waitFor()
await shot(page, '09-rechtliches-leer')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) await form.locator(`[data-aktion="muster-${key}"]`).click()
const platzhalter = []
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
const wert = await feld.inputValue()
ok(wert.length > 200, `Muster ${key} eingesetzt (${wert.length} Zeichen)`)
const treffer = wert.match(/\[[^\]]+\]/g) || []
for (const t of treffer) platzhalter.push(`${key}: ${t}`)
}
merk.platzhalter = platzhalter
// Platzhalter ersetzen und die Muster-Kopfzeile des AGB entfernen
const ersatz = (t) => (/datum|ort/i.test(t) ? 'Winnweiler' : /name|unterschrift/i.test(t) ? 'Hanna Brandt' : /anschrift|adresse/i.test(t) ? 'Hauptstraße 12, 67722 Winnweiler' : /mail/i.test(t) ? KUNDEN_EMAIL : /waren|dienstleistung|bestellt/i.test(t) ? 'Tasse Seladon' : 'Töpferei Brandt')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
let wert = await feld.inputValue()
wert = wert.replace(/\[[^\]]+\]/g, ersatz).replace(/^Muster – bitte prüfen und anpassen\n*/m, '')
await feld.fill(wert)
}
await form.locator('[data-text="impressumUrl"]').fill('https://www.toepferei-brandt.example/impressum')
await form.locator('[data-text="datenschutzUrl"]').fill('https://www.toepferei-brandt.example/datenschutz')
// ungespeichert: „Erst speichern“
const bestaetigen = page.locator('[data-aktion="bestaetigen"]')
ok(norm(await bestaetigen.innerText()) === 'Erst speichern', `Knopf vor dem Speichern „${norm(await bestaetigen.innerText())}“`)
await form.locator('[data-aktion="entwurf-speichern"]').click()
await warteMeldung(page, 'Der Entwurf ist gespeichert')
ok(Boolean(srv.rechtstexte().entwurf?.agb), 'Entwurf im Dokument')
const probleme = await page.locator('[data-probleme] li').allInnerTexts()
ok(probleme.length === 0, `keine Probleme vor dem Bestaetigen (${probleme.join(' / ')})`)
ok(await page.locator('[data-lesetext="gewaehrleistung-bild"]').count() === 1, 'Gewährleistungs-Bild im Reiter')
await shot(page, '10-rechtliches-entwurf')
await page.locator('[data-feld="bestaetigung"]').check()
ok(norm(await bestaetigen.innerText()) === 'Texte bestätigen' && await bestaetigen.isEnabled(), 'Knopf „Texte bestätigen“ frei')
await bestaetigen.click()
await warteMeldung(page, 'Die Texte sind bestätigt')
ok(Boolean(srv.rechtstexte().bestaetigt?.hash), 'Bestätigung gespeichert')
ok(srv.aw.docs('shopRechtstexteArchiv').size === 1, `Archiv der bestätigten Fassung (${srv.aw.docs('shopRechtstexteArchiv').size})`)
ok(await page.locator('[data-status="bestaetigt"]').count() === 1, 'Status „Bestätigt“ sichtbar')
await shot(page, '11-rechtliches-bestaetigt')
})
// ------------------------------------------------------------ 3c Artikel
await lauf('3c_artikel', async () => {
await reiter(page, 'artikel')
await page.locator('[data-aktion="artikel-neu"]').click()
const form = page.getByRole('form', { name: 'Artikel anlegen' })
await form.waitFor()
const f = (n) => form.locator(`[data-feld="${n}"]`)
// Artikel 1: Tasse mit Bild
await f('name').fill('Tasse Seladon')
await f('kategorie').fill('Geschirr')
await f('kurz').fill('Handgedrehte Tasse mit seladongrüner Glasur.')
await f('beschreibung').fill('Fasst etwa 300 ml. Spülmaschinengeeignet. Jedes Stück ist ein Unikat.')
await f('preisCent').fill('18,50')
await f('bestand').fill('12')
const png = await sharp({ create: { width: 800, height: 800, channels: 3, background: { r: 120, g: 170, b: 150 } } })
.composite([{ input: Buffer.from('<svg width="800" height="800"><circle cx="400" cy="400" r="260" fill="#f4efe6"/><circle cx="400" cy="400" r="150" fill="#2f6f5e"/></svg>'), top: 0, left: 0 }])
.png().toBuffer()
await form.locator('input[type="file"]').setInputFiles({ name: 'tasse.png', mimeType: 'image/png', buffer: png })
await page.locator('li[data-bild]').waitFor()
ok(await page.locator('li[data-bild] img').evaluate((img) => img.complete && img.naturalWidth > 0), 'Bild hochgeladen, Vorschau geladen')
await shot(page, '12-artikel-editor-tasse', { telefon: false })
await speichereArtikel(page, form, 1)
// Artikel 2: Vase mit Varianten
await page.locator('[data-aktion="artikel-neu"]').click()
const form2 = page.getByRole('form', { name: 'Artikel anlegen' })
await form2.waitFor()
const g = (n) => form2.locator(`[data-feld="${n}"]`)
await g('name').fill('Vase Wabi')
await g('kategorie').fill('Vasen')
await g('kurz').fill('Bauchige Vase in drei Größen.')
await g('beschreibung').fill('Aus Steinzeug, innen glasiert, wasserdicht.')
await g('variantenAn').check()
await g('varianten.name').fill('Größe')
const werte = [['Klein', '24,00', '5'], ['Mittel', '36,00', '3'], ['Groß', '52,00', '2']]
for (let i = 0; i < werte.length; i += 1) {
if (i > 0) await form2.locator('[data-aktion="wert-neu"]').click()
await g(`varianten.werte.${i}.name`).fill(werte[i][0])
await g(`varianten.werte.${i}.preisCent`).fill(werte[i][1])
await g(`varianten.werte.${i}.bestand`).fill(werte[i][2])
}
await shot(page, '13-artikel-editor-vase', { telefon: false })
await speichereArtikel(page, form2, 2)
const ps = srv.produkte()
ok(ps.length === 2, `zwei Artikel gespeichert (${ps.length})`)
const tasse = ps.find((p) => p.name === 'Tasse Seladon')
const vase = ps.find((p) => p.name === 'Vase Wabi')
ok(tasse?.produkt.preisCent === 1850 && tasse?.produkt.bilder?.length === 1, `Tasse: ${tasse?.produkt.preisCent} Cent, ${tasse?.produkt.bilder?.length} Bild`)
ok(vase?.produkt.varianten?.werte?.length === 3, `Vase: ${vase?.produkt.varianten?.werte?.length} Varianten`)
ok(await page.locator('li[data-artikel]').count() === 2, 'zwei Artikel in der Liste')
await shot(page, '14-artikel-liste')
})
// ------------------------------------------------------------ 3d Uebersicht, Stripe, Shop oeffnen
await lauf('3d_uebersicht_stripe_oeffnen', async () => {
await reiter(page, 'uebersicht')
await page.locator('[data-liste="bereitschaft"]').waitFor()
ok(await page.locator('li[data-ok="0"]').count() > 0, 'Checkliste: noch offene Punkte vor Stripe')
const oeffnen = page.locator('[data-aktion="oeffnen"]')
ok(await oeffnen.isDisabled(), '„Shop öffnen“ gesperrt')
await shot(page, '15-uebersicht-vor-stripe')
const vorher = stripeNavigationen.length
await page.locator('[data-aktion="stripe-einrichten"]').click()
await warteBis(() => stripeNavigationen.length > vorher && stripeNavigationen.at(-1).art === 'connect', 'Weiterleitung zum Stripe-Onboarding')
ok(/^https:\/\/connect\.stripe\.test\/setup\/acct_/.test(stripeNavigationen.at(-1).url), `Onboarding-Link ${stripeNavigationen.at(-1).url}`)
// zurueck ins Portal (Platzhalterseite leitet zu #webshop?stripe=zurueck&projekt=…)
await page.waitForURL(/dashboard\.html/)
await page.locator('.sh-reiterleiste').waitFor()
await page.locator('[data-liste="bereitschaft"]').waitFor()
await warteMeldung(page, 'Willkommen zurück von Stripe')
ok(!page.url().includes('stripe=zurueck'), `Rückkehr-Parameter aus der Adresse entfernt (${page.url()})`)
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'noch nicht freigeschaltet')), 'Stripe: Zahlungen noch nicht freigeschaltet')
ok(srv.kontoId()?.startsWith('acct_'), `Konto angelegt (${srv.kontoId()})`)
await shot(page, '16-uebersicht-nach-rueckkehr-stripe')
srv.kontoFreigeben()
await page.locator('[data-aktion="stripe-aktualisieren"]').click()
await warteMeldung(page, 'aktuell')
await warteBis(async () => (await page.locator('li[data-ok="0"]').count()) === 0, 'alle Punkte der Checkliste erledigt')
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'Zahlungen: aktiv')), 'Stripe: Zahlungen aktiv')
ok(await oeffnen.isEnabled(), '„Shop öffnen“ frei')
await shot(page, '17-uebersicht-bereit')
await oeffnen.click()
await warteBis(async () => (await page.locator('[data-zustand="offen"]').count()) === 1, 'Zustand „Dein Shop ist geöffnet“')
ok(srv.shopDoc()?.geoeffnet === true, 'geoeffnet im Dokument')
ok(await page.locator('[data-aktion="schliessen"]').count() === 1, 'Knopf „Shop schließen“')
await shot(page, '18-uebersicht-geoeffnet')
})
}
// ---------------------------------------------------------------- Kaeufer, Mails, Haendler, Widerruf
async function kaeuferSchritte() {
const key = srv.siteKey()
const ctxK = await neuerKontext(browser, { cookie: false })
const kaeufer = await ctxK.newPage()
// ------------------------------------------------------------ 4a Standalone
await lauf('4a_kaeufer_seite_raster_detail_korb', async () => {
await kaeufer.goto(`${srv.portalUrl}/shop/${key}`)
await kaeufer.locator('.wks-raster li').first().waitFor()
ok(await kaeufer.locator('.wks-raster li').count() === 2, 'zwei Artikel im Raster')
ok(enthaelt(await kaeufer.locator('.wks-shopname').innerText(), 'Töpferei Brandt'), 'Shopname im Kopf')
ok(await kaeufer.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Artikelbild der Tasse geladen')
ok(enthaelt(await kaeufer.locator('.wks-raster').innerText(), 'ab 24,00 €'), 'Vase „ab 24,00 €“')
ok(enthaelt(await kaeufer.locator('.wks-hinweiszeile').first().innerText(), 'Alle Preise inkl. MwSt.'), 'Steuerhinweis')
ok(await kaeufer.locator('.wks-preis-versand button').count() === 2, 'Versandhinweis „zzgl. Versand“ bei jedem Preis')
ok(await passtInBreite(kaeufer), 'Seite passt in die Breite (Desktop)')
await shot(kaeufer, '20-shop-raster')
await kaeufer.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), '18,50 €'), 'Detail: Preis 18,50 €')
await shot(kaeufer, '21-shop-detail')
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Weiter einkaufen' }).click()
await kaeufer.getByRole('button', { name: 'Vase Wabi' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), 'Bitte wählen Sie eine Variante'), 'Ohne Variante: Hinweis „Bitte wählen Sie eine Variante“')
await kaeufer.locator('select.wks-eingabe').selectOption({ label: 'Mittel – 36,00 €' })
ok(enthaelt(await kaeufer.locator('.wks-info .wks-preis').innerText(), '36,00 €'), 'Variantenpreis 36,00 €')
await shot(kaeufer, '22-shop-detail-variante', { telefon: false })
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Zum Warenkorb' }).click()
await kaeufer.locator('.wks-zeilen li').first().waitFor()
ok(await kaeufer.locator('.wks-zeilen li').count() === 2, 'zwei Positionen im Warenkorb')
await shot(kaeufer, '23-shop-warenkorb')
})
// ------------------------------------------------------------ 4b Kasse
await lauf('4b_kasse_pflichtangaben_und_bestellung', async () => {
await kaeufer.getByRole('button', { name: 'Zur Kasse' }).click()
await kaeufer.locator('.wks-pflicht .wks-summen').waitFor()
const pflicht = await kaeufer.locator('.wks-pflicht').innerText()
// § 312j BGB: wesentliche Merkmale, Gesamtpreis, Lieferung, Widerruf direkt vor dem Knopf
for (const teil of ['Ihre Bestellung', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Zwischensumme', 'Versandkosten', 'Gesamtsumme', 'Lieferung und Zahlung', 'Lieferzeit', '2–4 Werktage', 'Zahlungsarten', 'Anbieter', 'Töpferei Brandt', 'Widerrufsrecht', 'Sie haben ein gesetzliches Widerrufsrecht', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein']) {
if (!ok(enthaelt(pflicht, teil), `§ 312j-Block enthält „${teil}“`) && !merk.pflichtGezeigt) { merk.pflichtGezeigt = true; console.log(` Pflichtblock: ${norm(pflicht)}`) }
}
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '54,50 €'), `Zwischensumme 54,50 € (${norm(await kaeufer.locator('.wks-summen').innerText())})`)
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '59,40 €'), 'Gesamtsumme 59,40 € (inkl. 4,90 € Versand)')
// Gewaehrleistungshinweis mit der amtlichen Grafik
const gk = kaeufer.locator('.wks-gewaehr')
ok(await gk.count() === 1, 'Gewährleistungskasten vorhanden')
const img = gk.locator('img')
ok((await img.getAttribute('src'))?.endsWith('/img/gewaehrleistung-eu.webp'), `Gewährleistungsbild ${await img.getAttribute('src')}`)
ok(await img.evaluate((i) => i.complete && i.naturalWidth > 0), 'Gewährleistungsbild geladen')
ok(enthaelt((await img.getAttribute('alt')) || '', 'Gewährleistung'), 'Alternativtext des Bildes enthält den Hinweistext')
ok(enthaelt(await gk.innerText(), 'Ihre gesetzlichen Gewährleistungsrechte'), 'Überschrift des Kastens')
// AGB-Satz
const agb = norm(await kaeufer.locator('.wks-agb-hinweis').innerText())
ok(agb === 'Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der Widerrufsbelehrung.', `AGB-Satz „${agb}“`)
// Knopf
const senden = kaeufer.locator('form.wks-form button[type="submit"]')
ok(norm(await senden.innerText()) === 'Zahlungspflichtig bestellen', `Knopf „${norm(await senden.innerText())}“`)
ok(await senden.isEnabled(), 'Knopf frei')
// Reihenfolge: Gewaehrleistung vor Zusammenfassung, AGB-Satz und Knopf am Ende
const reihenfolge = await kaeufer.locator('form.wks-form').evaluate((f) => {
const pos = (sel) => { const e = f.querySelector(sel); return e ? [...f.querySelectorAll('*')].indexOf(e) : -1 }
return [pos('.wks-gewaehr'), pos('.wks-pflicht'), pos('.wks-agb-hinweis'), pos('button[type="submit"]')]
})
ok(reihenfolge.every((p, i, a) => p > 0 && (i === 0 || p > a[i - 1])), `Reihenfolge Gewährleistung < Pflichtangaben < AGB-Satz < Knopf (${reihenfolge})`)
await shot(kaeufer, '24-shop-kasse')
// AGB-Dialog
await kaeufer.locator('.wks-agb-hinweis').getByRole('button', { name: 'AGB' }).click()
await kaeufer.locator('dialog[open] .wks-dialog-text').waitFor()
ok(enthaelt(await kaeufer.locator('dialog[open] .wks-dialog-text').innerText(), 'Töpferei Brandt'), 'AGB im Dialog (ersetzte Platzhalter)')
await shot(kaeufer, '25-shop-agb-dialog', { telefon: false })
await kaeufer.locator('dialog[open] .wks-nav').getByRole('button', { name: 'Schließen' }).click()
// bestellen -> Stripe
const vorher = stripeNavigationen.length
await senden.click()
await warteBis(() => stripeNavigationen.length > vorher, 'Weiterleitung zur Stripe-Kasse des Händlers', 15000)
const nav = stripeNavigationen.at(-1)
ok(nav.art === 'checkout' && /\/c\/pay\/cs_/.test(nav.url), `Weiterleitung ${nav.url}`)
const bestellung = srv.bestellungen().at(-1)
ok(bestellung?.status === 'offen', `Bestellung offen (${bestellung?.status})`)
ok(bestellung?.summeCent === 5940, `Bestellsumme ${bestellung?.summeCent} Cent`)
merk.sitzung = bestellung?.stripeSessionId
merk.bestellnummer = bestellung?.bestellnummer
const sitzung = srv.kassenSitzungen().find((s) => s.id === merk.sitzung)
ok(Boolean(sitzung?.success_url?.includes('wk_bestellung=')), `success_url trägt wk_bestellung (${sitzung?.success_url})`)
merk.erfolgUrl = sitzung?.success_url
// Zahlung beim Haendlerkonto, signierter Connect-Webhook
const bezahlt = srv.bezahleKasse(merk.sitzung)
const w = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(w.status === 200, `Connect-Webhook ${w.status} ${JSON.stringify(w.body)}`)
const nach = srv.bestellungen().find((o) => o.stripeSessionId === merk.sitzung)
ok(nach?.status === 'bezahlt', `Bestellung bezahlt (${nach?.status})`)
const ptasse = srv.produkte().find((p) => p.name === 'Tasse Seladon')
ok(ptasse?.produkt.bestand === 11, `Bestand Tasse 12 -> ${ptasse?.produkt.bestand}`)
const wd = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(wd.status === 200, 'Wiederholung des Webhooks bleibt 200')
ok(srv.bestellungen().length === 1 && srv.produkte().find((p) => p.name === 'Tasse Seladon').produkt.bestand === 11, 'Wiederholung ändert nichts')
// Rueckkehr
await kaeufer.goto(merk.erfolgUrl)
await kaeufer.locator('.wks-statusbox .wks-ergebnis-text').waitFor()
await warteBis(async () => enthaelt(await kaeufer.locator('.wks-statusbox').innerText(), 'Ihre Zahlung ist eingegangen'), 'Status „Zahlung eingegangen“')
const st = norm(await kaeufer.locator('.wks-statusbox').innerText())
ok(st.includes(`Vielen Dank für Ihre Bestellung ${merk.bestellnummer}!`), `Dank mit Bestellnummer (${st})`)
ok(st.includes('Bestellnummer') && st.includes('59,40 €'), 'Statusbox: Bestellnummer und Gesamtsumme')
ok(st.includes('m***@example.com'), 'E-Mail verdeckt angezeigt')
ok(!kaeufer.url().includes('wk_t='), `Token aus der Adresse entfernt (${kaeufer.url()})`)
await shot(kaeufer, '26-shop-danke')
})
// ------------------------------------------------------------ 4c Demo-Seite (data-api, Cross-Origin)
await lauf('4c_demo_seite_cross_origin', async () => {
const demo = await ctxK.newPage()
await demo.goto(`${srv.kundenUrl}/shop`)
await demo.locator('.wks-raster li').first().waitFor()
ok(new URL(demo.url()).origin === srv.kundenUrl, `Seite kommt von ${new URL(demo.url()).origin}, API von ${srv.portalUrl}`)
ok(await demo.locator('.wks-raster li').count() === 2, 'Demo: zwei Artikel (GET Cross-Origin)')
ok(await demo.locator('.fallback').count() === 0, 'Ausweichtext ersetzt')
ok(await demo.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Demo: Artikelbild über data-api geladen')
await shot(demo, '27-demo-seite-raster')
await demo.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await demo.getByRole('button', { name: 'In den Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zum Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zur Kasse' }).click()
await demo.locator('.wks-pflicht .wks-summen').waitFor() // POST /pruefen Cross-Origin (Preflight)
ok(enthaelt(await demo.locator('.wks-summen').innerText(), '23,40 €'), 'Demo: Zusammenfassung vom Server (18,50 € + 4,90 € Versand)')
ok(await demo.locator('.wks-gewaehr img').evaluate((i) => i.complete && i.naturalWidth > 0), 'Demo: Gewährleistungsbild geladen')
const vorher = stripeNavigationen.length
await demo.locator('form.wks-form button[type="submit"]').click()
await warteBis(() => stripeNavigationen.length > vorher, 'Demo: Weiterleitung zur Kasse (POST Cross-Origin)', 15000)
ok(stripeNavigationen.at(-1).art === 'checkout', 'Demo: Bestellung angelegt (Cross-Origin-POST erlaubt)')
const letzte = srv.bestellungen().find((o) => o.status === 'offen')
ok(Boolean(letzte), 'Demo: zweite Bestellung offen (bleibt unbezahlt)')
const s = srv.kassenSitzungen().find((x) => x.id === letzte?.stripeSessionId)
ok(Boolean(s?.success_url?.startsWith(`${srv.kundenUrl}/shop`)), `Demo: Rücksprung auf die Seite des Händlers (${s?.success_url})`)
await demo.close()
})
// ------------------------------------------------------------ 5 Mails
await lauf('5_mails_g09_und_k25', async () => {
await srv.warteAufVersand()
const alle = srv.mails()
console.log(` Mails: ${alle.map((m) => `${m.to.map((t) => t.address).join(',')}: ${m.subject}`).join(' | ')}`)
const g09 = alle.find((m) => m.to.some((t) => t.address === 'max@example.com') && enthaelt(m.text || '', 'Mit dieser E-Mail nehmen wir Ihre Bestellung an'))
ok(Boolean(g09), 'G09 an den Käufer mit „Mit dieser E-Mail nehmen wir Ihre Bestellung an“')
if (g09) {
ok(enthaelt(g09.subject, merk.bestellnummer), `G09-Betreff nennt die Bestellnummer (${g09.subject})`)
ok(enthaelt(g09.text, 'Töpferei Brandt'), 'G09 enthält Shopname/AGB des Händlers')
ok(enthaelt(g09.text, 'Widerruf'), 'G09 enthält die Widerrufsbelehrung')
ok(enthaelt(g09.text, '1 × Tasse Seladon'), 'G09 listet die Positionen')
ok(!(g09.bcc?.length), `G09 ohne BCC (${JSON.stringify(g09.bcc)})`)
ok(g09.replyTo?.some?.((r) => r.address === KUNDEN_EMAIL) || g09.replyTo?.address === KUNDEN_EMAIL || /toepferei|Töpferei/i.test(g09.from?.name || ''), `Absendername/Antwort an den Händler (${JSON.stringify(g09.from)} ${JSON.stringify(g09.replyTo)})`)
ok((g09.attachments || []).length >= 0, 'Anhänge geprüft')
}
const k25 = alle.find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /^Neue Bestellung \d\d-/.test(m.subject))
ok(Boolean(k25), `K25 an den Händler (${alle.filter((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)).map((m) => m.subject).join(' / ')})`)
if (k25) {
ok(!(k25.bcc?.length), `K25 ohne BCC (${JSON.stringify(k25.bcc)})`)
ok(enthaelt(`${k25.subject} ${k25.text}`, merk.bestellnummer), 'K25 nennt die Bestellnummer')
ok(enthaelt(k25.text, 'max@example.com') || enthaelt(k25.text, 'Max Mustermann'), 'K25 nennt den Käufer')
}
// Die Warteschlange ist abgearbeitet
const offen = srv.outbox().filter((e) => e.status && !['sent', 'gesendet', 'done'].includes(e.status))
ok(offen.length === 0, `Outbox ohne offene Einträge (${offen.map((e) => `${e.art}:${e.status}`).join(',')})`)
})
// ------------------------------------------------------------ 6 Haendler: versenden, erstatten
await lauf('6_haendler_bestellung_versendet_erstattung', async () => {
const page = portalPage
await oeffne(page, '#webshop')
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
const zeile = page.locator(`[data-bestellung]`).first()
await zeile.waitFor()
ok(enthaelt(await zeile.innerText(), merk.bestellnummer), `Bestellung ${merk.bestellnummer} in der Liste`)
ok(enthaelt(await zeile.innerText(), '59,40 €') && enthaelt(await zeile.innerText(), 'Bezahlt'), 'Zeile: 59,40 €, Status Bezahlt')
await shot(page, '30-bestellungen-liste')
await zeile.click()
await page.locator('[data-aktion="versendet"]').waitFor()
const detail = norm(await page.locator('.sh-bestellungen-inhalt').innerText())
for (const teil of ['Max Mustermann', 'max@example.com', 'Lieferweg 7', '67655 Kaiserslautern', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Karte']) ok(detail.includes(teil), `Detail zeigt „${teil}“`)
await shot(page, '31-bestellung-detail')
await page.locator('[data-aktion="versendet"]').click()
const f = page.getByRole('form', { name: 'Als versendet markieren' })
await f.locator('[data-feld="versanddienst"]').fill('DHL')
await f.locator('[data-feld="sendungsnummer"]').fill('00340434161094042557')
await f.locator('[data-aktion="versendet-speichern"]').click()
await warteMeldung(page, 'Als versendet gespeichert')
ok(srv.bestellungen()[0].status === 'versendet', `Status versendet (${srv.bestellungen()[0].status})`)
ok(/DHL, 00340434161094042557/.test(await page.locator('.sh-bestellungen-inhalt').innerText()), 'Detail zeigt Versanddienst und Sendungsnummer')
await srv.warteAufVersand()
const g10 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && m.text.includes('00340434161094042557'))
ok(Boolean(g10), 'G10 an den Käufer mit der Sendungsnummer')
if (g10) ok(!(g10.bcc?.length), 'G10 ohne BCC')
await shot(page, '32-bestellung-versendet')
// Erstatten (teilweise)
const betrag = page.locator('#sh-erstatten-betrag')
ok(await betrag.inputValue() === '59,40', `Vorbelegung des Erstattungsbetrags ${await betrag.inputValue()}`)
await betrag.fill('1,00')
await page.locator('[data-aktion="erstatten"]').click()
await page.locator('[data-aktion="erstatten-bestaetigen"]').waitFor()
await shot(page, '33-erstattung-rueckfrage', { telefon: false })
await page.locator('[data-aktion="erstatten-bestaetigen"]').click()
await warteMeldung(page, 'erstattet')
ok(enthaelt(await echteMeldungen(page), '1,00 € erstattet'), `Meldung „${await echteMeldungen(page)}“`)
const acct = srv.kontoId()
const refundCall = st().calls.find((c) => c.method === 'POST' && c.path === '/v1/refunds')
ok(Boolean(refundCall), 'Stripe-Aufruf POST /v1/refunds')
ok(refundCall?.account === acct, `Erstattung mit Stripe-Account-Kopfzeile ${refundCall?.account} (Konto ${acct})`)
ok(refundCall?.body?.amount === '100' && refundCall?.body?.payment_intent === 'pi_ui_1', `Betrag ${refundCall?.body?.amount}, Zahlung ${refundCall?.body?.payment_intent}`)
const o = srv.bestellungen()[0]
ok(o.status === 'versendet' && /teilerstattet/.test(JSON.stringify(o.daten.verlauf)), 'Teilerstattung im Verlauf, Status bleibt versendet')
await srv.warteAufVersand()
const g13 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Erstattung|erstattet/i.test(`${m.subject} ${m.text}`) && /1,00/.test(m.text))
ok(Boolean(g13), 'G13 an den Käufer (1,00 € erstattet)')
await shot(page, '34-erstattung-erledigt')
})
// ------------------------------------------------------------ 7 Widerruf
await lauf('7_widerruf', async () => {
const w = await ctxK.newPage()
await w.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await w.locator('form.wks-form').waitFor()
ok(enthaelt(await w.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Titel „Vertrag widerrufen“')
await shot(w, '40-widerruf-formular')
// Pflichtfelder
await w.getByRole('button', { name: 'Weiter' }).click()
await w.locator('.wks-feldfehler:not([hidden])').first().waitFor()
ok(await w.locator('.wks-feldfehler:not([hidden])').count() >= 2, 'Leeres Formular: Feldfehler')
await w.getByLabel('Ihr Name').fill('Max Mustermann')
await w.getByLabel('E-Mail-Adresse der Bestellung').fill('max@example.com')
await w.getByLabel('Bestellnummer (falls zur Hand)').fill(merk.bestellnummer)
await w.getByLabel('Erklärung').fill('Ich widerrufe die Vase.')
await w.getByRole('button', { name: 'Weiter' }).click()
await w.getByRole('button', { name: 'Widerruf bestätigen' }).waitFor()
ok(enthaelt(await w.locator('.wks-zusammen').innerText(), merk.bestellnummer), 'Prüfseite nennt die Bestellnummer')
await shot(w, '41-widerruf-pruefen')
await w.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await w.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
const quittung = norm(await w.locator('.wks-ergebnis-text').innerText())
ok(/Ihr Widerruf ist am \d{1,2}\.\d{1,2}\.\d{4}.*\d{1,2}:\d{2}/.test(quittung), `Quittung mit Datum und Uhrzeit („${quittung}“)`)
await shot(w, '42-widerruf-quittung')
ok(srv.widerrufe().length === 1, `Widerruf gespeichert (${srv.widerrufe().length})`)
ok(Boolean(srv.widerrufe()[0]?.orderId), 'Widerruf der Bestellung zugeordnet')
await srv.warteAufVersand()
const g12 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Widerruf/.test(m.subject))
ok(Boolean(g12), 'G12 (Eingangsbestätigung) an den Käufer')
if (g12) ok(/\d{2}:\d{2}/.test(g12.text) && /\d{4}/.test(g12.text), 'G12 nennt Datum und Uhrzeit')
const k26 = srv.mails().find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /Widerruf/.test(m.subject))
ok(Boolean(k26), `K26 an den Händler (${srv.mails().map((m) => m.subject).filter((s) => /Widerruf/.test(s)).join(' / ')})`)
if (k26) ok(!(k26.bcc?.length), 'K26 ohne BCC')
// Haendler sieht den Widerruf
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
await page.locator('[data-ansicht="widerrufe"]').click()
await page.locator('[data-widerruf]').waitFor()
ok(enthaelt(await page.locator('[data-widerruf]').innerText(), 'Max Mustermann'), 'Widerruf im Reiter Bestellungen')
await shot(page, '43-haendler-widerrufe')
await page.locator('[data-aktion="erledigt"]').click()
await warteMeldung(page, 'als erledigt markiert')
ok(Boolean(srv.widerrufe()[0]?.bearbeitetAt), 'Widerruf als erledigt gespeichert')
await w.close()
})
// ------------------------------------------------------------ 8 Shop schliessen
await lauf('8_shop_schliessen_widerruf_bleibt', async () => {
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'uebersicht')
await page.locator('[data-aktion="schliessen"]').click()
await warteMeldung(page, 'Dein Shop ist jetzt geschlossen')
ok(srv.shopDoc()?.geoeffnet === false, 'geoeffnet false im Dokument')
ok(await page.locator('[data-zustand="zu"]').count() === 1, 'Zustand „Dein Shop ist geschlossen“')
await shot(page, '50-uebersicht-geschlossen')
const k = await ctxK.newPage()
await k.goto(`${srv.portalUrl}/shop/${key}`)
await k.locator('.wks-titel').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Shop geschlossen'), 'Widget: „Shop geschlossen“')
ok(enthaelt(await k.locator('.wks-body').innerText(), 'Der Shop ist gerade geschlossen.'), 'Widget: Text „Der Shop ist gerade geschlossen.“')
ok(await k.locator('.wks-raster').count() === 0 && await k.getByRole('button', { name: /Warenkorb/ }).count() === 0, 'kein Raster, kein Warenkorb')
const widerrufKnopf = k.locator('.wks-fuss').getByRole('button', { name: 'Vertrag widerrufen' })
ok(await widerrufKnopf.count() === 1, 'Fußzeile: „Vertrag widerrufen“ bleibt')
ok(await k.locator('.wks-fuss').getByRole('button', { name: 'AGB' }).count() === 1, 'Fußzeile: AGB bleibt')
await shot(k, '51-widget-geschlossen')
await widerrufKnopf.click()
await k.locator('form.wks-form').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Widerrufsformular im geschlossenen Shop erreichbar')
// direkte Adresse
await k.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await k.locator('form.wks-form').waitFor()
ok(true, '/widerruf-Adresse erreichbar')
// Widerruf im geschlossenen Shop wird weiter angenommen
await k.getByLabel('Ihr Name').fill('Erika Muster')
await k.getByLabel('E-Mail-Adresse der Bestellung').fill('erika@example.com')
await k.getByLabel('Erklärung').fill('Bestellt am 05.10.2026: Tasse Seladon.')
await k.getByRole('button', { name: 'Weiter' }).click()
await k.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await k.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
ok(srv.widerrufe().length === 2, `zweiter Widerruf im geschlossenen Shop gespeichert (${srv.widerrufe().length})`)
// POST /pruefen und /bestellung im geschlossenen Shop: 409
const key2 = srv.siteKey()
const r = await fetch(`${srv.url}/api/public/shop/v1/${key2}/pruefen`, { method: 'POST', headers: { 'content-type': 'application/json', origin: srv.portalUrl }, body: JSON.stringify({ positionen: [{ produktId: srv.produkte()[0].$id, menge: 1 }], lieferart: 'versand', land: 'DE' }) })
ok(r.status === 409, `POST /pruefen im geschlossenen Shop: ${r.status}`)
await k.close()
})
await ctxK.close()
}
const st = () => srv.st
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch({ executablePath: CHROME })
await szenario()
if (srv.siteKey()) await kaeuferSchritte()
const eigene = new Set([srv.url, srv.portalUrl, srv.kundenUrl].map((u) => new URL(u).host))
const fremd = [...fremdeHosts].filter((h) => !eigene.has(h))
ergebnisse.push(['nur_eigene_herkunft', fremd.length === 0, fremd.length ? [`Anfragen an fremde Hosts: ${fremd.join(', ')}`] : [], []])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: ${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 gescheitert = ergebnisse.filter(([, bestanden]) => !bestanden)
console.log(`\n${ergebnisse.length - gescheitert.length}/${ergebnisse.length} Schritte bestanden`)
for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`)
process.exit(abbruch || gescheitert.length ? 1 : 0)
+289
View File
@@ -0,0 +1,289 @@
// UI-Testserver des Webshops (fuer test/ui/shop.ui.mjs): die echten Express-Router (/api/public/shop/v1, /api/shop/stripe-webhook,
// /api/shop, /api/funktionen, /api/abo), die Seiten /shop/:siteKey und /rechtliches/ sowie public/ als statische Dateien, dahinter der
// Nachbau von Appwrite und Stripe (mit Connect) aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur
// 127.0.0.1). Muster: test/ui/termineTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startShopUiServer } from './shopTestServer.mjs'
// const srv = await startShopUiServer()
// ... srv.url, srv.portalUrl, srv.kundenUrl, srv.sink.sent, srv.st (Stripe-Nachbau), srv.aw (Appwrite-Nachbau) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Uhr: echte Uhr (Server und Browser). Zeitabhaengige Pruefungen lesen die Zeit aus der Antwort.
//
// Mitarbeiter-Schritte rufen die Dienste direkt auf (die Team-Routen brauchen ein Mitarbeiter-JWT):
// srv.entscheide('annehmen' | 'ablehnen', begruendung) entscheideAnfrage
// srv.bezahleEinrichtung() Plattform-Webhook checkout.session.completed fuer die Bestellung der Einrichtung (signiert, /api/abo/webhook)
// srv.schliesseTicket() Einrichtungs-Ticket schliessen (workorders.status 'Closed'); GET /api/funktionen schaltet danach frei
// Haendler-Konto bei Stripe: srv.kontoFreigeben() (charges_enabled, payouts_enabled), Kasse des Kaeufers: srv.bezahleKasse(sessionId) +
// srv.sendeConnect(type, objekt) (signierter Connect-Webhook an /api/shop/stripe-webhook).
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT, fuer Webhooks und Direktaufrufe). Das Portal heisst fuer Server und Browser
// srv.portalUrl (https://project.webklar.test = config.publicBaseUrl, wie in Produktion https); das Skript leitet diese Adresse im Browser per
// page.route an srv.url um (route.fetch). Die Website des Haendlers heisst srv.kundenUrl (https://www.toepferei-brandt.example = liveDomain des
// Projekts); ihre Seiten liefert das Skript per page.route. So sind Stripe-Rueckspruenge, Bild-Adressen (Widget verlangt https) und
// Cross-Origin-Anfragen (data-api) so wie im Betrieb.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { startFakeStripe } from '../support/fakeStripe.js'
import { mailSink } from '../support/mailSink.js'
import { schemaKeys } from '../support/schema.js'
import { useTestEnv } from '../support/testEnv.js'
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
export const PORTAL_URL = 'https://project.webklar.test'
export const KUNDEN_URL = 'https://www.toepferei-brandt.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_shop'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'hanna@toepferei-brandt.example'
export const KUNDEN_NAME = 'Hanna Brandt'
export const MITARBEITER = { name: 'Kenso', email: 'kenso@webklar.com' }
/** Anfrage, wie sie das Formular im Portal schickt. */
export const ANFRAGE = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik, Tassen und Vasen aus eigener Werkstatt in Winnweiler.',
artikelzahl: 'bis20',
versand: true,
abholung: true,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: 'Wir würden gern vor Weihnachten starten.',
}
export async function startShopUiServer() {
const { SHOP_DATETIMES } = await import('../../server/services/shop/schema.js')
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
featureConsents: schemaKeys('featureConsents'),
subscriptions: schemaKeys('subscriptions'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp, postWebhook } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicShopRoutes, resetPublicShopForTests, setzeUhrFuerTests: setzeUhrPublic } = await import('../../server/routes/publicShop.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../../server/routes/shop.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../../server/routes/abo.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../../server/routes/shopSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { entscheideAnfrage } = await import('../../server/services/shop/anfrage.js')
const { leereCache } = await import('../../server/services/shop/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { getFeatureDoc } = await import('../../server/services/siteFeatures.js')
// Reihenfolge wie server/index.js: /embed (CORS, Cache), /rechtliches, Shop-Seiten, public/
const statisch = express.Router()
statisch.use('/embed/', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*')
next()
})
statisch.use('/rechtliches', legalRouter())
statisch.use(shopSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/shop/v1': publicShopRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/shop': shopRoutes,
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'shop-ui-'))
const vorher = {
mode: config.shop.mode,
pilotEmails: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
formMinSekunden: config.shop.formMinSekunden,
minAntwortMs: config.shop.minAntwortMs,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE,
aw, st, config,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. */
async portalCookie() {
const { sessionCookie } = await import('../support/testApp.js')
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: PORTAL_URL }
},
// ---------------------------------------------------------------- Dokumente lesen
featureDoc() { return [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'shop') ?? null },
shopDoc() { return [...aw.docs('shopConfig').values()][0] ?? null },
siteKey() { return srv.featureDoc()?.siteKey ?? srv.shopDoc()?.siteKey ?? null },
einstellungen() { return JSON.parse(srv.shopDoc().einstellungen) },
rechtstexte() { return JSON.parse(srv.shopDoc().rechtstexte) },
produkte() { return [...aw.docs('shopProducts').values()].map((d) => ({ ...d, produkt: JSON.parse(d.produkt) })) },
bestellungen() { return [...aw.docs('shopOrders').values()].map((d) => ({ ...d, daten: JSON.parse(d.bestellung) })) },
widerrufe() { return [...aw.docs('shopWiderrufe').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mails(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
outbox() { return [...aw.docs('termineOutbox').values()] },
// ---------------------------------------------------------------- Mitarbeiter-Schritte (Dienste direkt)
async entscheide(entscheidung = 'annehmen', begruendung = '') {
return entscheideAnfrage({ customerId: KUNDE, projectId: PROJEKT, entscheidung, begruendung, person: MITARBEITER })
},
/** Bestellung der Einrichtung (Zustand ordered) bezahlen: Sitzung abschliessen, signierter Plattform-Webhook. Liefert { status } der Antwort. */
async bezahleEinrichtung() {
const doc = srv.featureDoc()
if (!doc?.stripeSessionId) throw new Error(`keine Bestellung der Einrichtung (Zustand ${doc?.state})`)
const sessionId = doc.stripeSessionId
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
const sitzung = { ...st.checkoutSessions.get(sessionId), status: 'complete', payment_status: 'paid', invoice }
st.checkoutSessions.set(sessionId, sitzung)
const event = { id: `evt_${Math.random()}`, object: 'event', type: 'checkout.session.completed', data: { object: sitzung } }
return postWebhook(event)
},
/** Einrichtungs-Ticket der Funktion schliessen. */
async schliesseTicket() {
const doc = srv.featureDoc()
if (!doc?.ticketId) throw new Error(`kein Ticket (Zustand ${doc?.state})`)
await updateDocument(col('workorders'), doc.ticketId, { status: 'Closed' })
},
// ---------------------------------------------------------------- Stripe (Haendlerkonto)
/** Das Haendlerkonto des Shops nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.shopDoc()?.stripeAccountId
if (!id) throw new Error('kein Stripe-Konto angelegt')
return st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null }, ...patch })
},
kontoId() { return srv.shopDoc()?.stripeAccountId ?? null },
/** Offene Checkout-Sitzungen des Haendlerkontos. */
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
/** Sitzung des Kaeufers als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung = 'pi_ui_1', kunde, versand, betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const order = srv.bestellungen().find((o) => o.stripeSessionId === sessionId)
const summe = betrag ?? order?.summeCent ?? null
st.setPaymentIntent(acct, zahlung, { amount: summe })
return st.completeSession(acct, sessionId, {
payment_status: 'paid',
amount_total: summe,
customer_details: kunde ?? {
name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567',
address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' },
},
shipping: versand ?? { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } },
payment_intent: zahlung,
payment_method_types: ['card'],
})
},
/** Signiertes Connect-Ereignis an POST /api/shop/stripe-webhook -> { status, body }. */
sendeConnect(type, objekt, { account } = {}) {
const event = { id: `evt_${Math.random().toString(36).slice(2)}`, object: 'event', type, livemode: false, account: account ?? srv.kontoId(), data: { object: objekt } }
return postWebhook(event, { path: '/api/shop/stripe-webhook', secret: CONNECT_SECRET })
},
leereCache() { leereCache() },
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke; Kunde c1 mit Projekt p1 und Wartungs-Abo-freier
* Bestellung (der Webshop verlangt kein Abo). Schalter: Pilotmodus fuer alle Kunden ('*').
*/
async zuruecksetzen() {
await warteAufVersand()
const sammlungen = [
'portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox',
'featureConsents', 'customers', 'websiteProjects', 'workorders', 'subscriptions',
]
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(col(collection) ?? collection, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicShopForTests()
resetShopRoutesForTests()
resetShopSeiteForTests()
setzeUhrPublic(() => new Date())
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
config.shop.formMinSekunden = 0
config.shop.minAntwortMs = 0
config.shop.uploadDir = uploadDir
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = PORTAL_URL
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', {
$id: PROJEKT, customerId: KUNDE, projectName: 'Töpferei Brandt', subdomain: 'toepferei', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
resetPublicShopForTests()
resetShopRoutesForTests()
Object.assign(config.shop, {
mode: vorher.mode, pilotEmails: vorher.pilotEmails, connectWebhookSecret: vorher.connectWebhookSecret, formMinSekunden: vorher.formMinSekunden,
minAntwortMs: vorher.minAntwortMs, uploadDir: vorher.uploadDir,
})
config.publicBaseUrl = vorher.publicBaseUrl
config.smtp.from = vorher.smtpFrom
let frist = null
const geschlossen = app.close()
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
clearTimeout(frist)
await Promise.all([aw.close(), st.close()])
await rm(uploadDir, { recursive: true, force: true })
},
}
// getFeatureDoc ist nur fuer Skripte gedacht, die den Zustand pruefen wollen
srv.getFeatureDoc = () => getFeatureDoc(KUNDE, PROJEKT, 'shop')
await srv.zuruecksetzen()
return srv
}