diff --git a/test/ui/shop.ui.mjs b/test/ui/shop.ui.mjs new file mode 100644 index 0000000..3e6de99 --- /dev/null +++ b/test/ui/shop.ui.mjs @@ -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/, 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= 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) => `Töpferei Brandt + +
Töpferei Brandt – Website des Händlers (Testseite)

Unser Online-Shop

+

Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06302 1234567.

+
Fußbereich
` + +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: `Stripe (Nachbau)

Onboarding

` }) + } + stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url }) + return route.fulfill({ status: 200, contentType: 'text/html', body: 'Stripe-Kasse (Nachbau)

Stripe-Kasse (Nachbau)

' }) + }) + 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(''), 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) diff --git a/test/ui/shopTestServer.mjs b/test/ui/shopTestServer.mjs new file mode 100644 index 0000000..eee1be6 --- /dev/null +++ b/test/ui/shopTestServer.mjs @@ -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 +}