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