Files

796 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)