796 lines
52 KiB
JavaScript
796 lines
52 KiB
JavaScript
// 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)
|