Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+317 -42
View File
@@ -1,4 +1,5 @@
// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API.
// Playwright-Check Analyse (Cockpit): statischer Server fuer public/ + gemockte API.
// Zustaende: Einschalten, aktiv (volle Daten, neu, wenig Daten, Dev-Modus, alter Collector, langer Pfad, 503), Reiter, Zeitraum.
// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner]
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01'
const SHOTS = process.argv[2] || '/root/reports/analyse-ueberarbeitung-2026-10-04/screens'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
@@ -22,83 +23,357 @@ const server = http.createServer(async (req, res) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
/* --- Testdaten: Form wie analytics/src/stats.js, Werte wie die Fixture "Boulderhalle Überhang" --- */
const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' }
const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const baseProject = { projectId: 'p1', projectName: 'Boulderhalle Überhang', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
const analyse = (over) => ({ state: 'active', eligible: true, devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', activatedAt: '2026-08-12T09:00:00.000Z', legal, controller, ...over })
const project = (over = {}) => ({ ...baseProject, analyse: analyse(over) })
const TODAY = '2026-10-04'
const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10)
const daysOf = (from, n, base, spike = -1) => Array.from({ length: n }, (_, i) => ({
day: addDays(from, i), pv: i === spike ? 371 : Math.round(base * (1 + 0.25 * Math.sin(i / 2.3))), visitors: 80 + (i % 7) * 4, contacts: 4 + (i % 5), avgSec: 100 + (i % 9) * 3,
}))
const TOTALS = {
7: { pv: 1640, visitors: 560, contacts: 41, consentRate: 0.61, engaged: 400, avgSec: 101, avgScroll: 62 },
30: { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 },
90: { pv: 19800, visitors: 6700, contacts: 530, consentRate: 0.6, engaged: 5000, avgSec: 111, avgScroll: 63 },
}
const BEFORE = {
7: { pv: 1500, visitors: 520, contacts: 35, consentRate: 0.6, engaged: 380, avgSec: 99, avgScroll: 60 },
30: { pv: 6080, visitors: 2210, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 },
90: { pv: 17900, visitors: 6200, contacts: 470, consentRate: 0.6, engaged: 4500, avgSec: 112, avgScroll: 61 },
}
const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum
const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]]
// Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today`
function makeStats(range, over = {}) {
const from = addDays(TODAY, -range)
const pfrom = addDays(from, -range)
return {
range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)),
today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 },
pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })),
refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })),
countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }],
devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2030 }, { key: 't', count: 310 }],
contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 41 }, { key: 'wa', count: 38 }, { key: 'out', count: 13 }],
banner: { shown: 3950, accept: 2410, reject: 1540 },
previous: {
from: pfrom, to: addDays(from, -1), complete: true, totals: BEFORE[range], series: daysOf(pfrom, range, 200),
contacts: [{ key: 'tel', count: 75 }, { key: 'mail', count: 32 }, { key: 'wa', count: 21 }, { key: 'out', count: 13 }],
},
...over,
}
}
const longPath = `/${'a'.repeat(120)}`
const OLD = (range) => ({
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: daysOf(addDays(TODAY, -13), 14, 40).map(({ day, pv }) => ({ day, pv })),
pages: [{ key: '/', count: 300 }], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 10, accept: 6, reject: 4 }, range,
})
// je Zustand: die Website und die Statistik (nach range)
const SCENARIOS = {
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } },
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
}
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 }))
const stats = {
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days,
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }],
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }],
banner: { shown: 300, accept: 186, reject: 114 },
off: { project: project({ state: 'off' }) },
missing: { project: project({ state: 'off', controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) },
full: { project: project(), stats: (r) => makeStats(r) },
neu: { project: project(), stats: (r) => makeStats(r, { firstDay: addDays(TODAY, -12), previous: { ...makeStats(r).previous, complete: false } }) },
wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) },
dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) },
alt: { project: project(), stats: (r) => OLD(r) },
stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) },
sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) },
langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) },
fehler: { project: project(), stats: null },
zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) },
}
let scenario = 'off'
const browser = await chromium.launch()
let statsRequests = []
let delayFirst30 = 0
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const launch = () => chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
const browser = await launch()
let failed = 0
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
const TIMEOUT = Number(process.env.UI_TIMEOUT) || 120000
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport })
for (const [label, viewport, touch] of [['desktop', { width: 1440, height: 1000 }, false], ['phone', { width: 402, height: 874 }, true]]) {
const ctx = await browser.newContext({ viewport, hasTouch: touch })
ctx.setDefaultTimeout(TIMEOUT)
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) })
const url = new URL(route.request().url())
const p = url.pathname
const json = (b, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(b) }).catch(() => {})
const sc = SCENARIOS[scenario]
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Boulderhalle Überhang', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/activity') return json({ tickets: [] })
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] })
if (p === '/api/funktionen/analyse/stats') return json(stats)
if (p === '/api/funktionen') return json({ projects: sc.projects || [sc.project] })
if (p === '/api/funktionen/analyse/stats') {
const range = Number(url.searchParams.get('range'))
statsRequests.push(range)
if (range === 30 && delayFirst30 > 0) {
delayFirst30 -= 1
await sleep(1500)
}
if (!sc.stats) return json({ error: 'Auswertung nicht erreichbar' }, 503)
return json(sc.stats(range))
}
if (p === '/api/funktionen/analyse/heatmap-link') return json({ url: `${base}/offline.html` })
if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true })
return json({})
})
const open = async (sc) => {
// waitFor: Selektor, der den fertigen Zustand anzeigt
const open = async (sc, waitFor = '.an-ov:not(.an-skeleton)') => {
scenario = sc
statsRequests = []
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html#analyse`)
await page.waitForSelector('#analyse-body .kb-panel')
await page.waitForTimeout(900)
await page.waitForSelector('#analyse-body .an')
await page.waitForSelector(waitFor)
await page.waitForTimeout(300)
}
const shot = async (name) => {
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200))
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 4200))
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
await page.waitForTimeout(150)
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` })
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` })
await page.setViewportSize(viewport)
}
const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
const noScroll = () => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth)
const text = (sel) => page.evaluate((s) => document.querySelector(s)?.textContent ?? '', sel)
const count = (sel) => page.evaluate((s) => document.querySelectorAll(s).length, sel)
const L = (name) => `${label} ${name}`
await open('off')
const text = await page.textContent('#analyse-body')
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example'))
check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`)
check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on'))
/* --- Einschalten (wie bisher) --- */
await open('off', '#an-on')
const body = await text('#analyse-body')
const linkOk = (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`
const disabledFirst = await page.isDisabled('#an-on')
const preview = (await text('#an-preview')).includes('Annehmen') && (await text('#an-preview')).includes('Ablehnen')
await page.check('#an-avv')
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on')))
await noScroll('einschalten')
const enabled = !(await page.isDisabled('#an-on'))
check(L('Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)'), body.includes('Bäckerei Berg GmbH') && body.includes('info@baeckerei-berg.example') && linkOk && preview && disabledFirst && enabled)
check(L('Einschalten: Kopfleiste ohne Zeitraum und Reiter, Status "Analyse ist noch ausgeschaltet"'), (await count('.an-top')) === 1 && (await count('.an-seg')) === 0 && (await count('.an-tabs')) === 0 && (await text('.an-status')).includes('Analyse ist noch ausgeschaltet'))
check(L('kein_horizontaler_scroll einschalten'), await noScroll())
await shot('einschalten')
await open('missing')
await open('missing', '#an-missing')
await page.check('#an-avv')
check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
check(L('gesperrt_ohne_angaben'), (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
await shot('fehlende-angaben')
await open('active')
await page.waitForSelector('.an-kpis')
await noScroll('aktiv')
/* --- aktiv, volle Daten --- */
await open('full')
check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr'))
check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼'))
check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
check(L('kein_horizontaler_scroll aktiv'), await noScroll())
// Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop)
const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } }))
check(L(touch ? 'Kacheln 2 × 2' : 'Kacheln 4 nebeneinander'), touch
? tiles[0].y === tiles[1].y && tiles[2].y === tiles[3].y && tiles[2].y > tiles[0].y && tiles[0].x === tiles[2].x && tiles[1].x > tiles[0].x
: tiles.every((t) => t.y === tiles[0].y))
await page.evaluate(() => document.querySelector('.an-chart').scrollIntoView({ block: 'center' }))
await page.waitForTimeout(200)
const boxes = await page.evaluate(() => {
const r = (s) => { const b = document.querySelector(s).getBoundingClientRect(); return { top: Math.round(b.top), height: Math.round(b.height), left: b.left, right: b.right } }
return { chart: r('.an-chartbox'), contacts: r('.an-contacts'), svg: r('.an-chart') }
})
if (!touch) {
check(L(`Diagramm und Kontaktkarte gleich hoch (${boxes.chart.height} / ${boxes.contacts.height} px)`), boxes.chart.top === boxes.contacts.top && Math.abs(boxes.chart.height - boxes.contacts.height) <= 1)
check(L(`Diagramm füllt seine Karte (${boxes.svg.height} von ${boxes.chart.height} px)`), boxes.svg.height >= boxes.chart.height * 0.6)
} else {
check(L('Handy: Diagramm und Kontaktkarte untereinander'), boxes.contacts.top > boxes.chart.top && boxes.chart.left === boxes.contacts.left)
}
// Blase am Diagramm: nächster Tag, zeigt "…: N Aufrufe (davor M)"; Maus verlässt / Tippen daneben blendet aus
const svg = boxes.svg
const px = svg.left + (svg.right - svg.left) * 0.62
const py = svg.top + svg.height / 2
if (touch) {
await page.touchscreen.tap(px, py)
} else {
await page.mouse.move(px, py)
}
await page.waitForSelector('.an-tip:not([hidden])')
const tip = await text('.an-tip')
check(L(`Diagramm-Blase: "${tip}"`), /^\S+, \d\d\.\d\d\.: [\d.]+ Aufrufe \(davor [\d.]+\)$/.test(tip) && (await count('.an-dot.on')) === 1)
if (touch) {
// neben das Diagramm tippen (nicht in die App-Leiste oben, die führt zurück)
const h = await page.locator('.an-chartbox h3').boundingBox()
await page.touchscreen.tap(h.x + 10, h.y + h.height / 2)
} else {
await page.mouse.move(2, 2)
}
await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true)
check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0)
// Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht
const axis = () => page.evaluate(() => {
const svg = document.querySelector('.an-chart')
const scale = svg.getBoundingClientRect().width / 640
const box = document.querySelector('.an-chartbox').getBoundingClientRect()
const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent }))
const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left)
return {
px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)),
inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1),
gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4),
y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','),
}
})
const ax = await axis()
check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap)
const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color)
check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55)
if (touch) {
const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))])))
check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5))
}
// Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte
check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1)
await page.click('[data-rank="seen"] [data-rank-more]')
check(L('Rangliste: "Alle anzeigen" zeigt alle 9 Seiten'), (await count('[data-rank="seen"] .an-lr')) === 9 && (await count('[data-rank="seen"] [data-rank-more]')) === 0)
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
check(L('Rangliste: Umschalter Herkunft, Seiten-Reiter der anderen Karte unverändert'), (await page.getAttribute('[data-rank="seen"]', 'data-rank-active')) === 'refs' && (await page.getAttribute('[data-rank="who"]', 'data-rank-active')) === 'devices' && (await text('[data-rank="seen"]')).includes('Google'))
await shot('aktiv')
// Reiter per Pfeiltaste (Reihenfolge Übersicht, Heatmap, Einstellungen)
await page.focus('.an-tabbtn[data-tab="uebersicht"]')
await page.keyboard.press('ArrowRight')
await page.waitForSelector('.an-tabbtn[data-tab="heatmap"][aria-selected="true"]')
await page.keyboard.press('ArrowLeft')
await page.waitForSelector('.an-tabbtn[data-tab="uebersicht"][aria-selected="true"]')
check(L('Reiter: Pfeiltasten wechseln die Reiter'), (await count('.an-ov:not(.an-skeleton) .an-kpis')) === 1)
// Zeitraum 7: neue Anfrage, nur die Übersicht lädt neu, Reiter bleibt
const before = statsRequests.length
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
check(L('Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht'), statsRequests.length > before && statsRequests.at(-1) === 7 && (await page.getAttribute('.an-seg .on', 'data-range')) === '7' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht')
// Heatmap: iframes erst nach dem Reiterwechsel (Teaser-Knopf und Reiterknopf wechseln beide)
await open('full')
const iframesBefore = await count('iframe')
await page.click('.an-teaser [data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Teaser "Öffnen" wechselt in den Reiter Heatmap'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'heatmap' && (await count('.an-top .an-seg')) === 0)
await open('full')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes'), iframesBefore === 0 && (await count('.hs-dev iframe')) === 2 && (await count('#an-heat-page')) === 1)
check(L('Heatmap: Seitenauswahl und Computer/Tablet-Schalter in einer Leiste'), await page.evaluate(() => { const bar = document.querySelector('.an-heat-bar'); return Boolean(bar?.querySelector('#an-heat-page') && bar.querySelector('.hs-switch button')) }))
check(L('kein_horizontaler_scroll heatmap'), await noScroll())
await page.waitForTimeout(600)
await shot('heatmap')
// zurück in die Übersicht: die iframes verschwinden, die Zahlen stehen sofort wieder da
await page.click('.an-tabbtn[data-tab="uebersicht"]')
await page.waitForSelector('.an-ov:not(.an-skeleton) .an-kpis')
check(L('Reiterwechsel zurück: Übersicht ohne iframes'), (await count('iframe')) === 0)
// Einstellungen
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
const settings = await text('#analyse-body')
check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0)
check(L('kein_horizontaler_scroll einstellungen'), await noScroll())
const errColor = await page.evaluate(() => {
const p = document.createElement('p')
p.className = 'error'
p.textContent = 'x'
document.querySelector('#an-off').appendChild(p)
const c = getComputedStyle(p).color
p.remove()
return c
})
check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)')
await shot('einstellungen')
await page.click('#an-off-ask')
await page.click('#an-off-yes')
await page.waitForSelector('#an-off-done')
check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
check(L('ausschalten_bestaetigung'), (await text('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
await shot('ausgeschaltet')
check(`${label} keine_js_fehler`, errors.length === 0)
// verzögerte erste Antwort überschreibt die spätere nicht
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-seg [data-range="7"]')
await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen'))
await page.waitForTimeout(1900)
const lateOk = (await text('.an-lead')).includes('7 Tagen') && !(await text('.an-lead')).includes('30 Tagen') && statsRequests.join() === '30,7' && (await page.getAttribute('.an-seg .on', 'data-range')) === '7'
// auch der Zustand dahinter bleibt der 7-Tage-Stand: "Alle anzeigen" und der Umschalter malen mit 7-Tage-Zahlen (Startseite 620, Google 220)
const firstRow = (card) => text(`[data-rank="${card}"] .an-lr`)
const before7 = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-more]')
const more = await firstRow('seen')
await page.click('[data-rank="seen"] [data-rank-tab="refs"]')
const refs = await firstRow('seen')
check(L('verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht'), lateOk && before7.includes('620') && more.includes('620') && !more.includes('2.480') && refs.includes('220') && !refs.includes('880') && (await count('[data-rank="seen"] .an-lr')) === 4)
delayFirst30 = 0
// Heatmap-Reiter während der ersten Übersicht-Anfrage: nutzt sie mit, statt dieselbe Anfrage noch einmal zu stellen
delayFirst30 = 1
await open('full', '.an-seg')
await page.click('.an-tabbtn[data-tab="heatmap"]')
await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2)
check(L('Heatmap-Reiter nutzt die laufende 30-Tage-Anfrage mit'), statsRequests.join() === '30')
delayFirst30 = 0
// Website wechseln: Reiter bleibt
await open('zwei')
await page.click('.an-tabbtn[data-tab="einstellungen"]')
await page.waitForSelector('#an-off-ask')
check(L('mehrere Websites: Auswahlfeld in der Kopfleiste, langer Name bricht nichts'), (await count('#an-project option')) === 2 && (await noScroll()))
await page.selectOption('#an-project', 'p2')
await page.waitForSelector('#an-off-ask')
check(L('Websitewechsel: Reiter Einstellungen bleibt'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'einstellungen' && (await page.inputValue('#an-project')) === 'p2')
await shot('zwei-websites')
/* --- weitere Zustände --- */
await open('neu')
check(L('neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"'), (await count('.an-kpis .an-delta')) === 0 && (await text('.an-kpis')).includes('Vergleich ab') && (await text('.an-sum')).includes('Seit dem Start der Analyse') && (await count('.an-prev')) === 0)
await shot('neu')
await open('wenig')
check(L('wenig: Leerzustand'), (await count('.an-sum-empty')) === 1 && (await text('.an-sum')).includes('Sobald ein paar mehr Besuche da sind'))
await shot('wenig')
await open('dev')
check(L('dev: "Im Dev-Modus wird nicht gemessen."'), (await text('.an-status')).includes('Im Dev-Modus wird nicht gemessen.') && (await page.getAttribute('.an-site', 'data-state')) === 'warn')
await shot('dev')
await open('alt')
check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')))
check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612'))
await shot('alt')
await open('fehler', '.an-error')
const requestsBefore = statsRequests.length
check(L('fehler: "Auswertung gerade nicht erreichbar" + Neu laden'), (await text('.an-error')).includes('Auswertung gerade nicht erreichbar') && (await text('#an-retry')) === 'Neu laden' && (await count('.an-tabs')) === 1)
await page.click('#an-retry')
for (let i = 0; i < 50 && statsRequests.length === requestsBefore; i += 1) await sleep(100)
await page.waitForSelector('.an-error')
check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1)
await shot('fehler')
for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) {
await open(sc)
const big = await axis()
check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13)
await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig')
}
await open('langerPfad')
const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } })
check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100)
await shot('langer-pfad')
check(L('keine pageerror in allen Zuständen'), errors.length === 0)
if (errors.length) console.log(errors.join('\n'))
await ctx.close()
}
} finally {
+795
View File
@@ -0,0 +1,795 @@
// End-to-end-Pruefung des Webshops im Browser (Playwright, Chromium), gegen die echten Router hinter Nachbauten von Appwrite, Stripe (Connect)
// und Mail (test/ui/shopTestServer.mjs). Nicht Teil von npm test.
// Aufruf: nice node --max-old-space-size=1024 test/ui/shop.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/webshop-2026-10-05/ui/ (Desktop 1280x900 und Telefon 390x844). Exit-Code 1, wenn ein Schritt scheitert.
//
// Ein durchgehendes Szenario in acht Schritten (jeder Schritt baut auf dem vorigen auf):
// 1 Kunde (Pilot): Kachel Webshop, Anfrage stellen 5 Mails G09 und K25
// 2 Team nimmt an, Bestellseite, Zahlung, Freischaltung 6 Haendler: Bestellung, versendet (G10), Erstattung (G13)
// 3 Reiter Einstellungen, Rechtliches, Artikel, Uebersicht 7 Widerruf (G12, K26)
// 4 Kaeufer: Seite /shop/<siteKey>, Demo-Seite, Kasse, Zahlung 8 Shop schliessen, Widerruf bleibt erreichbar
// Stripe-Adressen (https://*.stripe.test) werden per page.route abgefangen; Mitarbeiter-Schritte rufen die Dienste direkt auf.
// NUR=<Teil des Namens> fuehrt nur passende Schritte aus (setzt voraus, dass die vorigen Zustaende schon bestehen: meist sinnlos).
import { mkdir, readdir, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { startShopUiServer, ANFRAGE, KUNDEN_EMAIL } from './shopTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core'
const { chromium } = createRequire(import.meta.url)(PW)
const sharp = createRequire(import.meta.url)('sharp')
const CHROME = process.env.CHROME_BIN || path.join(process.env.HOME || '/root', '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome')
const SHOTS = process.env.SHOTS || '/root/reports/webshop-2026-10-05/ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startShopUiServer()
const DESKTOP = { width: 1280, height: 900 }
const PHONE = { width: 390, height: 844 }
// ---------------------------------------------------------------- Helfer
const ergebnisse = []
let schritt = null
/** Weiche Pruefung: merkt den Fehler und laeuft weiter (Schritt scheitert am Ende). */
function ok(bedingung, text) {
if (!bedingung) {
schritt.fehler.push(text)
console.log(` x ${text}`)
} else if (process.env.VERBOSE) console.log(` + ${text}`)
return Boolean(bedingung)
}
const norm = (t) => String(t ?? '').replace(/ | /g, ' ').replace(/\s+/g, ' ').trim()
const enthaelt = (text, teil) => norm(text).includes(norm(teil))
/** Screenshot der ganzen Seite in Desktop- und Telefonbreite (Fenster auf Seitenhoehe, hoechstens 3600 px). */
async function shot(page, name, { telefon = true } = {}) {
for (const [suffix, groesse] of [['desktop', DESKTOP], ['phone', PHONE]]) {
if (suffix === 'phone' && !telefon) continue
await page.setViewportSize(groesse)
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
await page.waitForTimeout(150)
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
await page.setViewportSize({ width: groesse.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/${name}-${suffix}.png` })
}
await page.setViewportSize(DESKTOP)
}
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
async function warteBis(bedingung, text, ms = 8000) {
const ende = Date.now() + ms
for (;;) {
if (await bedingung()) return
if (Date.now() > ende) throw new Error(`Zeit abgelaufen: ${text}`)
await new Promise((fertig) => { setTimeout(fertig, 50) })
}
}
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Hanna Brandt', email: KUNDEN_EMAIL } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Töpferei Brandt', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
const ECHT = [/^\/api\/funktionen/, /^\/api\/shop/, /^\/api\/public\/shop/, /^\/api\/abo\/webhook/]
// Aufzeichnung der abgefangenen Stripe-Navigationen: { art: 'connect' | 'checkout', url }
const stripeNavigationen = []
const offeneSeiten = new Set() // fuer Fehler-Screenshots
const fremdeHosts = new Set()
/** Seite der Haendler-Website (https://www.toepferei-brandt.example/...): Platzhalter fuer den Einbau des Shops mit data-api (Cross-Origin). */
const kundenSeite = (key) => `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Töpferei Brandt</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#2b2b2b}header,footer{padding:16px 24px;background:#e9e2d6}main{max-width:900px;margin:0 auto;padding:24px 16px}.fallback{padding:16px;border:2px dashed #999}</style></head>
<body><header>Töpferei Brandt – Website des Händlers (Testseite)</header><main><h1>Unser Online-Shop</h1>
<div data-webklar-shop="${key}" data-api="${srv.portalUrl}"><p class="fallback">Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06302 1234567.</p></div></main>
<footer>Fußbereich</footer><script src="${srv.portalUrl}/embed/shop.js" defer></script></body></html>`
async function neuerKontext(browser, { cookie = true, viewport = DESKTOP } = {}) {
// Service Worker des Portals blockieren: ihre Anfragen umgingen page.route
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block' })
if (cookie) await ctx.addCookies([await srv.portalCookie()])
ctx.setDefaultTimeout(10000)
// Portal (https://project.webklar.test): echte Router des Testservers; die uebrigen Portal-Schnittstellen beantwortet portalHilfe
await ctx.route(`${srv.portalUrl}/**`, async (route) => {
const req = route.request()
const url = new URL(req.url())
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname.startsWith('/api/') && !ECHT.some((re) => re.test(url.pathname))) return portalHilfe(url.pathname, json)
const headers = await req.allHeaders()
delete headers.host
const antwort = await route.fetch({ url: `${srv.url}${url.pathname}${url.search}`, headers })
return route.fulfill({ response: antwort })
})
// Website des Haendlers: ihre Seiten liefert das Skript (jede Adresse dieselbe Seite, mit oder ohne Rueckkehr-Parameter)
await ctx.route(`${srv.kundenUrl}/**`, async (route) => {
if (new URL(route.request().url()).pathname === '/favicon.ico') return route.fulfill({ status: 404, body: '' })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: kundenSeite(srv.siteKey()) })
})
// Stripe: Navigation abfangen (Haendler-Onboarding -> zurueck ins Portal; Kasse -> Platzhalterseite, der Test navigiert selbst zurueck)
await ctx.route(/^https:\/\/[a-z]+\.stripe\.test\//, async (route) => {
const url = route.request().url()
const host = new URL(url).host
if (host === 'connect.stripe.test') {
stripeNavigationen.push({ art: 'connect', url })
const zurueck = `${srv.portalUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${srv.PROJEKT}`
return route.fulfill({ status: 200, contentType: 'text/html', body: `<!doctype html><title>Stripe (Nachbau)</title><p>Onboarding</p><script>location.replace(${JSON.stringify(zurueck)})</script>` })
}
stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url })
return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Stripe-Kasse (Nachbau)</title><h1>Stripe-Kasse (Nachbau)</h1>' })
})
ctx.on('page', (seite) => {
offeneSeiten.add(seite)
seite.on('close', () => offeneSeiten.delete(seite))
seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } })
seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) })
})
return ctx
}
let browser = null
async function lauf(name, fn) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
schritt = { name, fehler: [], hinweise: [] }
console.log(`--- ${name}`)
try {
await fn()
} catch (e) {
schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`)
if (process.env.STACK) console.log(e.stack)
}
if (schritt.fehler.length) {
let i = 0
for (const seite of offeneSeiten) {
i += 1
await seite.evaluate(() => window.scrollTo(0, 0)).catch(() => {})
const hoehe = await seite.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600)).catch(() => 900)
await seite.setViewportSize({ width: 1280, height: hoehe }).catch(() => {})
await seite.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png` }).catch(() => {})
await seite.setViewportSize(DESKTOP).catch(() => {})
}
}
const bestanden = schritt.fehler.length === 0
ergebnisse.push([name, bestanden, schritt.fehler, schritt.hinweise])
console.log(`${bestanden ? 'ok ' : 'FAIL'} ${name}${bestanden ? '' : `: ${schritt.fehler.join(' | ')}`}`)
for (const h of schritt.hinweise) console.log(` ! ${h}`)
}
const oeffne = (page, hash) => page.goto(`${srv.portalUrl}/dashboard.html${hash}`)
const webshopBereit = async (page) => {
await page.locator('#view-webshop.active').waitFor()
await page.locator('#webshop-body :is(.sh-reiterleiste, .sh-anfrage, .sh-anfrage-karte, .tm-bestellen, .sh-info)').first().waitFor()
}
const reiter = async (page, key) => {
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#sh-tab-${key}[aria-selected="true"]`).waitFor()
}
const meldung = (page) => page.locator('.sh-meldung')
const warteMeldung = async (page, teil) => {
await warteBis(async () => enthaelt(await meldung(page).innerText().catch(() => ''), teil), `Meldung „${teil}“`)
}
/** Artikel speichern und warten, bis der Server n Artikel hat; sonst die Feldfehler des Editors melden. */
async function speichereArtikel(page, form, n) {
await form.locator('[data-aktion="speichern"]').click()
const ende = Date.now() + 10000
while (srv.produkte().length < n && Date.now() < ende) {
const fehler = await page.locator('[data-fehler]:not(:empty)').allInnerTexts()
if (fehler.length) throw new Error(`Artikel ${n} nicht gespeichert, Feldfehler: ${fehler.join(' / ')}`)
await new Promise((fertig) => { setTimeout(fertig, 100) })
}
ok(srv.produkte().length === n, `${n}. Artikel gespeichert (${srv.produkte().length})`)
}
const echteMeldungen = async (page) => norm(await meldung(page).innerText().catch(() => ''))
// ---------------------------------------------------------------- Szenario
let portalPage = null // Haendler (mit Cookie)
let ctxHaendler = null
const merk = {} // Zahlen und IDs zwischen den Schritten
async function szenario() {
ctxHaendler = await neuerKontext(browser)
portalPage = await ctxHaendler.newPage()
const page = portalPage
// ------------------------------------------------------------ 1
await lauf('1_kunde_kachel_und_anfrage', async () => {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="shop"]')
await kachel.waitFor()
ok(enthaelt(await kachel.innerText(), 'Webshop'), 'Kachel Webshop sichtbar')
ok(enthaelt(await kachel.innerText(), '299 €'), 'Kachel nennt den Preis 299 €')
ok(await page.locator('[data-fn-tile="shop"] .fn-tile-cta').innerText().then((t) => enthaelt(t, 'Webshop anfragen')), 'Knopf „Webshop anfragen“')
await shot(page, '01-kachel-webshop')
await kachel.locator('.fn-tile-cta').click()
await webshopBereit(page)
const form = page.getByRole('form', { name: 'Webshop anfragen' })
await form.waitFor()
await shot(page, '02-anfrage-formular')
await form.locator('[data-feld="warenart"]').selectOption(ANFRAGE.warenart)
await form.locator('[data-feld="beschreibung"]').fill(ANFRAGE.beschreibung)
await form.locator('[data-feld="artikelzahl"]').selectOption(ANFRAGE.artikelzahl)
await form.locator('[data-feld="versand"]').check()
await form.locator('[data-feld="abholung"]').check()
await form.locator('[data-feld="unternehmer"]').check()
await form.locator('[data-feld="keineAusgeschlossenen"]').check()
await form.locator('[data-feld="nachricht"]').fill(ANFRAGE.nachricht)
await form.locator('[data-aktion="anfrage-senden"]').click()
const karte = page.locator('[data-anfrage="offen"]')
await karte.waitFor()
ok(enthaelt(await karte.innerText(), 'Anfrage wird geprüft'), 'Karte „Deine Anfrage wird geprüft“')
ok(enthaelt(await karte.innerText(), 'Anfrage zurückziehen'), 'Knopf „Anfrage zurückziehen“')
await shot(page, '03-anfrage-wird-geprueft')
ok(srv.shopDoc()?.anfrageStatus === 'offen', `shopConfig.anfrageStatus offen (ist ${srv.shopDoc()?.anfrageStatus})`)
await srv.warteAufVersand()
ok(srv.mails('Webshop').length >= 1, `Team-Mail zur Anfrage (T21) verschickt (${srv.mails().map((m) => m.subject).join(' / ')})`)
})
// ------------------------------------------------------------ 2
await lauf('2_annahme_bestellseite_zahlung_freischaltung', async () => {
const r = await srv.entscheide('annehmen')
ok(r.anfrage.status === 'angenommen', `Anfrage angenommen (${r.anfrage.status}), mailFehler ${r.mailFehler}`)
ok(srv.mails('Webshop').some((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)), 'K24 an den Kunden')
await page.reload()
await webshopBereit(page)
const seite = page.locator('.tm-bestellen')
await seite.waitFor()
const haken = await seite.locator('input[data-haken]').count()
ok(haken === 5, `fünf Haken auf der Bestellseite (${haken})`)
const knopf = seite.locator('[data-aktion="bestellen"]')
ok(norm(await knopf.innerText()) === 'Zahlungspflichtig bestellen · 299 €', `Knopf „${norm(await knopf.innerText())}“`)
ok(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
await shot(page, '04-bestellseite')
for (const h of await seite.locator('input[data-haken]').all()) await h.check()
ok(await knopf.isEnabled(), 'Knopf frei nach allen Haken')
await knopf.click()
await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Navigation zur Stripe-Kasse')
const nav = stripeNavigationen.filter((n) => n.art === 'checkout').at(-1)
ok(/^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/.test(nav.url), `Weiterleitung zur Kasse (${nav.url})`)
ok(srv.featureDoc()?.state === 'ordered', `Funktion ordered (${srv.featureDoc()?.state})`)
ok(srv.aw.docs('featureConsents').size === 1, `Zustimmungsnachweis gespeichert (${srv.aw.docs('featureConsents').size})`)
// Zahlung: signierter Plattform-Webhook
const w = await srv.bezahleEinrichtung()
ok(w.status === 200, `Plattform-Webhook ${w.status} ${JSON.stringify(w.body)}`)
ok(srv.featureDoc()?.state === 'setup', `Funktion setup nach Zahlung (${srv.featureDoc()?.state})`)
ok(Boolean(srv.featureDoc()?.ticketId), 'Einrichtungs-Ticket angelegt')
ok(srv.siteKey() && /^[a-z2-7]{16}$/.test(srv.siteKey()), `siteKey ${srv.siteKey()}`)
// zurueck im Portal: „Danke!“ und Hinweis Einrichtung
await oeffne(page, `#webshop?bestellt=1&projekt=${srv.PROJEKT}`)
await page.reload()
await webshopBereit(page)
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 1, 'Hinweis „Wir bauen deinen Shop gerade ein“')
await srv.schliesseTicket()
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
await warteBis(() => srv.featureDoc()?.state === 'active', 'Funktion active nach geschlossenem Ticket')
ok(srv.featureDoc()?.state === 'active', 'Funktion active')
await page.reload()
await page.locator('.sh-reiterleiste').waitFor()
ok(await page.locator('[data-hinweis="setup"]').count() === 0, 'Hinweis Einrichtung weg')
await shot(page, '05-shop-aktiv-uebersicht')
})
// ------------------------------------------------------------ 3a Einstellungen
await lauf('3a_einstellungen', async () => {
await reiter(page, 'einstellungen')
const form = page.getByRole('form', { name: 'Einstellungen des Webshops' })
await form.waitFor()
await shot(page, '06-einstellungen-leer')
const f = (n) => form.locator(`[data-feld="${n}"]`)
await f('shopname').fill('Töpferei Brandt')
await f('akzent').fill('#0f766e')
await f('anbieter.name').fill('Töpferei Brandt, Inh. Hanna Brandt')
await f('anbieter.strasse').fill('Hauptstraße 12')
await f('anbieter.plz').fill('67722')
await f('anbieter.ort').fill('Winnweiler')
await f('anbieter.email').fill(KUNDEN_EMAIL)
await f('anbieter.telefon').fill('06302 1234567')
await f('anbieter.ustId').fill('DE123456789')
await form.locator('input[name="sh-kleinunternehmer"][value="nein"]').check()
await f('versand.laender').selectOption('DE,AT')
await f('versand.kostenCent.DE').fill('4,90')
await f('versand.kostenCent.AT').fill('7,90')
await f('versand.freiAbCent').fill('80,00')
await f('abholung.aktiv').check()
await f('abholung.adresse').fill('Hauptstraße 12, 67722 Winnweiler')
await f('abholung.hinweis').fill('Di bis Fr 10 bis 17 Uhr')
await f('lieferzeit').fill('2–4 Werktage')
await shot(page, '08-einstellungen-ausgefuellt')
await form.locator('[data-aktion="einstellungen-speichern"]').click()
await warteMeldung(page, 'Die Einstellungen sind gespeichert')
const e = srv.einstellungen()
ok(e.kleinunternehmer === false, `Steuerart gespeichert: kleinunternehmer ${e.kleinunternehmer}`)
ok(e.anbieter?.ort === 'Winnweiler' && e.shopname === 'Töpferei Brandt', 'Einstellungen im Dokument')
ok(e.versand?.kostenCent?.AT === 790 && e.versand?.freiAbCent === 8000, `Versandkosten ${JSON.stringify(e.versand?.kostenCent)} frei ab ${e.versand?.freiAbCent}`)
ok(e.zahlungsarten?.includes('card'), 'Karte als Zahlungsart')
})
// ------------------------------------------------------------ 3b Rechtliches
await lauf('3b_rechtliches', async () => {
await reiter(page, 'rechtliches')
const form = page.getByRole('form', { name: 'Rechtstexte des Webshops' })
await form.waitFor()
await shot(page, '09-rechtliches-leer')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) await form.locator(`[data-aktion="muster-${key}"]`).click()
const platzhalter = []
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
const wert = await feld.inputValue()
ok(wert.length > 200, `Muster ${key} eingesetzt (${wert.length} Zeichen)`)
const treffer = wert.match(/\[[^\]]+\]/g) || []
for (const t of treffer) platzhalter.push(`${key}: ${t}`)
}
merk.platzhalter = platzhalter
// Platzhalter ersetzen und die Muster-Kopfzeile des AGB entfernen
const ersatz = (t) => (/datum|ort/i.test(t) ? 'Winnweiler' : /name|unterschrift/i.test(t) ? 'Hanna Brandt' : /anschrift|adresse/i.test(t) ? 'Hauptstraße 12, 67722 Winnweiler' : /mail/i.test(t) ? KUNDEN_EMAIL : /waren|dienstleistung|bestellt/i.test(t) ? 'Tasse Seladon' : 'Töpferei Brandt')
for (const key of ['agb', 'widerrufsbelehrung', 'widerrufsformular']) {
const feld = form.locator(`[data-text="${key}"]`)
let wert = await feld.inputValue()
wert = wert.replace(/\[[^\]]+\]/g, ersatz).replace(/^Muster – bitte prüfen und anpassen\n*/m, '')
await feld.fill(wert)
}
await form.locator('[data-text="impressumUrl"]').fill('https://www.toepferei-brandt.example/impressum')
await form.locator('[data-text="datenschutzUrl"]').fill('https://www.toepferei-brandt.example/datenschutz')
// ungespeichert: „Erst speichern“
const bestaetigen = page.locator('[data-aktion="bestaetigen"]')
ok(norm(await bestaetigen.innerText()) === 'Erst speichern', `Knopf vor dem Speichern „${norm(await bestaetigen.innerText())}“`)
await form.locator('[data-aktion="entwurf-speichern"]').click()
await warteMeldung(page, 'Der Entwurf ist gespeichert')
ok(Boolean(srv.rechtstexte().entwurf?.agb), 'Entwurf im Dokument')
const probleme = await page.locator('[data-probleme] li').allInnerTexts()
ok(probleme.length === 0, `keine Probleme vor dem Bestaetigen (${probleme.join(' / ')})`)
ok(await page.locator('[data-lesetext="gewaehrleistung-bild"]').count() === 1, 'Gewährleistungs-Bild im Reiter')
await shot(page, '10-rechtliches-entwurf')
await page.locator('[data-feld="bestaetigung"]').check()
ok(norm(await bestaetigen.innerText()) === 'Texte bestätigen' && await bestaetigen.isEnabled(), 'Knopf „Texte bestätigen“ frei')
await bestaetigen.click()
await warteMeldung(page, 'Die Texte sind bestätigt')
ok(Boolean(srv.rechtstexte().bestaetigt?.hash), 'Bestätigung gespeichert')
ok(srv.aw.docs('shopRechtstexteArchiv').size === 1, `Archiv der bestätigten Fassung (${srv.aw.docs('shopRechtstexteArchiv').size})`)
ok(await page.locator('[data-status="bestaetigt"]').count() === 1, 'Status „Bestätigt“ sichtbar')
await shot(page, '11-rechtliches-bestaetigt')
})
// ------------------------------------------------------------ 3c Artikel
await lauf('3c_artikel', async () => {
await reiter(page, 'artikel')
await page.locator('[data-aktion="artikel-neu"]').click()
const form = page.getByRole('form', { name: 'Artikel anlegen' })
await form.waitFor()
const f = (n) => form.locator(`[data-feld="${n}"]`)
// Artikel 1: Tasse mit Bild
await f('name').fill('Tasse Seladon')
await f('kategorie').fill('Geschirr')
await f('kurz').fill('Handgedrehte Tasse mit seladongrüner Glasur.')
await f('beschreibung').fill('Fasst etwa 300 ml. Spülmaschinengeeignet. Jedes Stück ist ein Unikat.')
await f('preisCent').fill('18,50')
await f('bestand').fill('12')
const png = await sharp({ create: { width: 800, height: 800, channels: 3, background: { r: 120, g: 170, b: 150 } } })
.composite([{ input: Buffer.from('<svg width="800" height="800"><circle cx="400" cy="400" r="260" fill="#f4efe6"/><circle cx="400" cy="400" r="150" fill="#2f6f5e"/></svg>'), top: 0, left: 0 }])
.png().toBuffer()
await form.locator('input[type="file"]').setInputFiles({ name: 'tasse.png', mimeType: 'image/png', buffer: png })
await page.locator('li[data-bild]').waitFor()
ok(await page.locator('li[data-bild] img').evaluate((img) => img.complete && img.naturalWidth > 0), 'Bild hochgeladen, Vorschau geladen')
await shot(page, '12-artikel-editor-tasse', { telefon: false })
await speichereArtikel(page, form, 1)
// Artikel 2: Vase mit Varianten
await page.locator('[data-aktion="artikel-neu"]').click()
const form2 = page.getByRole('form', { name: 'Artikel anlegen' })
await form2.waitFor()
const g = (n) => form2.locator(`[data-feld="${n}"]`)
await g('name').fill('Vase Wabi')
await g('kategorie').fill('Vasen')
await g('kurz').fill('Bauchige Vase in drei Größen.')
await g('beschreibung').fill('Aus Steinzeug, innen glasiert, wasserdicht.')
await g('variantenAn').check()
await g('varianten.name').fill('Größe')
const werte = [['Klein', '24,00', '5'], ['Mittel', '36,00', '3'], ['Groß', '52,00', '2']]
for (let i = 0; i < werte.length; i += 1) {
if (i > 0) await form2.locator('[data-aktion="wert-neu"]').click()
await g(`varianten.werte.${i}.name`).fill(werte[i][0])
await g(`varianten.werte.${i}.preisCent`).fill(werte[i][1])
await g(`varianten.werte.${i}.bestand`).fill(werte[i][2])
}
await shot(page, '13-artikel-editor-vase', { telefon: false })
await speichereArtikel(page, form2, 2)
const ps = srv.produkte()
ok(ps.length === 2, `zwei Artikel gespeichert (${ps.length})`)
const tasse = ps.find((p) => p.name === 'Tasse Seladon')
const vase = ps.find((p) => p.name === 'Vase Wabi')
ok(tasse?.produkt.preisCent === 1850 && tasse?.produkt.bilder?.length === 1, `Tasse: ${tasse?.produkt.preisCent} Cent, ${tasse?.produkt.bilder?.length} Bild`)
ok(vase?.produkt.varianten?.werte?.length === 3, `Vase: ${vase?.produkt.varianten?.werte?.length} Varianten`)
ok(await page.locator('li[data-artikel]').count() === 2, 'zwei Artikel in der Liste')
await shot(page, '14-artikel-liste')
})
// ------------------------------------------------------------ 3d Uebersicht, Stripe, Shop oeffnen
await lauf('3d_uebersicht_stripe_oeffnen', async () => {
await reiter(page, 'uebersicht')
await page.locator('[data-liste="bereitschaft"]').waitFor()
ok(await page.locator('li[data-ok="0"]').count() > 0, 'Checkliste: noch offene Punkte vor Stripe')
const oeffnen = page.locator('[data-aktion="oeffnen"]')
ok(await oeffnen.isDisabled(), '„Shop öffnen“ gesperrt')
await shot(page, '15-uebersicht-vor-stripe')
const vorher = stripeNavigationen.length
await page.locator('[data-aktion="stripe-einrichten"]').click()
await warteBis(() => stripeNavigationen.length > vorher && stripeNavigationen.at(-1).art === 'connect', 'Weiterleitung zum Stripe-Onboarding')
ok(/^https:\/\/connect\.stripe\.test\/setup\/acct_/.test(stripeNavigationen.at(-1).url), `Onboarding-Link ${stripeNavigationen.at(-1).url}`)
// zurueck ins Portal (Platzhalterseite leitet zu #webshop?stripe=zurueck&projekt=…)
await page.waitForURL(/dashboard\.html/)
await page.locator('.sh-reiterleiste').waitFor()
await page.locator('[data-liste="bereitschaft"]').waitFor()
await warteMeldung(page, 'Willkommen zurück von Stripe')
ok(!page.url().includes('stripe=zurueck'), `Rückkehr-Parameter aus der Adresse entfernt (${page.url()})`)
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'noch nicht freigeschaltet')), 'Stripe: Zahlungen noch nicht freigeschaltet')
ok(srv.kontoId()?.startsWith('acct_'), `Konto angelegt (${srv.kontoId()})`)
await shot(page, '16-uebersicht-nach-rueckkehr-stripe')
srv.kontoFreigeben()
await page.locator('[data-aktion="stripe-aktualisieren"]').click()
await warteMeldung(page, 'aktuell')
await warteBis(async () => (await page.locator('li[data-ok="0"]').count()) === 0, 'alle Punkte der Checkliste erledigt')
ok(await page.locator('[data-stripe="zahlungen"]').innerText().then((t) => enthaelt(t, 'Zahlungen: aktiv')), 'Stripe: Zahlungen aktiv')
ok(await oeffnen.isEnabled(), '„Shop öffnen“ frei')
await shot(page, '17-uebersicht-bereit')
await oeffnen.click()
await warteBis(async () => (await page.locator('[data-zustand="offen"]').count()) === 1, 'Zustand „Dein Shop ist geöffnet“')
ok(srv.shopDoc()?.geoeffnet === true, 'geoeffnet im Dokument')
ok(await page.locator('[data-aktion="schliessen"]').count() === 1, 'Knopf „Shop schließen“')
await shot(page, '18-uebersicht-geoeffnet')
})
}
// ---------------------------------------------------------------- Kaeufer, Mails, Haendler, Widerruf
async function kaeuferSchritte() {
const key = srv.siteKey()
const ctxK = await neuerKontext(browser, { cookie: false })
const kaeufer = await ctxK.newPage()
// ------------------------------------------------------------ 4a Standalone
await lauf('4a_kaeufer_seite_raster_detail_korb', async () => {
await kaeufer.goto(`${srv.portalUrl}/shop/${key}`)
await kaeufer.locator('.wks-raster li').first().waitFor()
ok(await kaeufer.locator('.wks-raster li').count() === 2, 'zwei Artikel im Raster')
ok(enthaelt(await kaeufer.locator('.wks-shopname').innerText(), 'Töpferei Brandt'), 'Shopname im Kopf')
ok(await kaeufer.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Artikelbild der Tasse geladen')
ok(enthaelt(await kaeufer.locator('.wks-raster').innerText(), 'ab 24,00 €'), 'Vase „ab 24,00 €“')
ok(enthaelt(await kaeufer.locator('.wks-hinweiszeile').first().innerText(), 'Alle Preise inkl. MwSt.'), 'Steuerhinweis')
ok(await kaeufer.locator('.wks-preis-versand button').count() === 2, 'Versandhinweis „zzgl. Versand“ bei jedem Preis')
ok(await passtInBreite(kaeufer), 'Seite passt in die Breite (Desktop)')
await shot(kaeufer, '20-shop-raster')
await kaeufer.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), '18,50 €'), 'Detail: Preis 18,50 €')
await shot(kaeufer, '21-shop-detail')
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Weiter einkaufen' }).click()
await kaeufer.getByRole('button', { name: 'Vase Wabi' }).first().click()
await kaeufer.locator('.wks-detail').waitFor()
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
ok(enthaelt(await kaeufer.locator('.wks-info').innerText(), 'Bitte wählen Sie eine Variante'), 'Ohne Variante: Hinweis „Bitte wählen Sie eine Variante“')
await kaeufer.locator('select.wks-eingabe').selectOption({ label: 'Mittel – 36,00 €' })
ok(enthaelt(await kaeufer.locator('.wks-info .wks-preis').innerText(), '36,00 €'), 'Variantenpreis 36,00 €')
await shot(kaeufer, '22-shop-detail-variante', { telefon: false })
await kaeufer.getByRole('button', { name: 'In den Warenkorb' }).click()
await kaeufer.locator('.wks-info .wks-meldung:not([hidden])').waitFor()
await kaeufer.getByRole('button', { name: 'Zum Warenkorb' }).click()
await kaeufer.locator('.wks-zeilen li').first().waitFor()
ok(await kaeufer.locator('.wks-zeilen li').count() === 2, 'zwei Positionen im Warenkorb')
await shot(kaeufer, '23-shop-warenkorb')
})
// ------------------------------------------------------------ 4b Kasse
await lauf('4b_kasse_pflichtangaben_und_bestellung', async () => {
await kaeufer.getByRole('button', { name: 'Zur Kasse' }).click()
await kaeufer.locator('.wks-pflicht .wks-summen').waitFor()
const pflicht = await kaeufer.locator('.wks-pflicht').innerText()
// § 312j BGB: wesentliche Merkmale, Gesamtpreis, Lieferung, Widerruf direkt vor dem Knopf
for (const teil of ['Ihre Bestellung', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Zwischensumme', 'Versandkosten', 'Gesamtsumme', 'Lieferung und Zahlung', 'Lieferzeit', '2–4 Werktage', 'Zahlungsarten', 'Anbieter', 'Töpferei Brandt', 'Widerrufsrecht', 'Sie haben ein gesetzliches Widerrufsrecht', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein']) {
if (!ok(enthaelt(pflicht, teil), `§ 312j-Block enthält „${teil}“`) && !merk.pflichtGezeigt) { merk.pflichtGezeigt = true; console.log(` Pflichtblock: ${norm(pflicht)}`) }
}
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '54,50 €'), `Zwischensumme 54,50 € (${norm(await kaeufer.locator('.wks-summen').innerText())})`)
ok(enthaelt(await kaeufer.locator('.wks-summen').innerText(), '59,40 €'), 'Gesamtsumme 59,40 € (inkl. 4,90 € Versand)')
// Gewaehrleistungshinweis mit der amtlichen Grafik
const gk = kaeufer.locator('.wks-gewaehr')
ok(await gk.count() === 1, 'Gewährleistungskasten vorhanden')
const img = gk.locator('img')
ok((await img.getAttribute('src'))?.endsWith('/img/gewaehrleistung-eu.webp'), `Gewährleistungsbild ${await img.getAttribute('src')}`)
ok(await img.evaluate((i) => i.complete && i.naturalWidth > 0), 'Gewährleistungsbild geladen')
ok(enthaelt((await img.getAttribute('alt')) || '', 'Gewährleistung'), 'Alternativtext des Bildes enthält den Hinweistext')
ok(enthaelt(await gk.innerText(), 'Ihre gesetzlichen Gewährleistungsrechte'), 'Überschrift des Kastens')
// AGB-Satz
const agb = norm(await kaeufer.locator('.wks-agb-hinweis').innerText())
ok(agb === 'Es gelten unsere AGB. Informationen zu Ihrem Widerrufsrecht finden Sie in der Widerrufsbelehrung.', `AGB-Satz „${agb}“`)
// Knopf
const senden = kaeufer.locator('form.wks-form button[type="submit"]')
ok(norm(await senden.innerText()) === 'Zahlungspflichtig bestellen', `Knopf „${norm(await senden.innerText())}“`)
ok(await senden.isEnabled(), 'Knopf frei')
// Reihenfolge: Gewaehrleistung vor Zusammenfassung, AGB-Satz und Knopf am Ende
const reihenfolge = await kaeufer.locator('form.wks-form').evaluate((f) => {
const pos = (sel) => { const e = f.querySelector(sel); return e ? [...f.querySelectorAll('*')].indexOf(e) : -1 }
return [pos('.wks-gewaehr'), pos('.wks-pflicht'), pos('.wks-agb-hinweis'), pos('button[type="submit"]')]
})
ok(reihenfolge.every((p, i, a) => p > 0 && (i === 0 || p > a[i - 1])), `Reihenfolge Gewährleistung < Pflichtangaben < AGB-Satz < Knopf (${reihenfolge})`)
await shot(kaeufer, '24-shop-kasse')
// AGB-Dialog
await kaeufer.locator('.wks-agb-hinweis').getByRole('button', { name: 'AGB' }).click()
await kaeufer.locator('dialog[open] .wks-dialog-text').waitFor()
ok(enthaelt(await kaeufer.locator('dialog[open] .wks-dialog-text').innerText(), 'Töpferei Brandt'), 'AGB im Dialog (ersetzte Platzhalter)')
await shot(kaeufer, '25-shop-agb-dialog', { telefon: false })
await kaeufer.locator('dialog[open] .wks-nav').getByRole('button', { name: 'Schließen' }).click()
// bestellen -> Stripe
const vorher = stripeNavigationen.length
await senden.click()
await warteBis(() => stripeNavigationen.length > vorher, 'Weiterleitung zur Stripe-Kasse des Händlers', 15000)
const nav = stripeNavigationen.at(-1)
ok(nav.art === 'checkout' && /\/c\/pay\/cs_/.test(nav.url), `Weiterleitung ${nav.url}`)
const bestellung = srv.bestellungen().at(-1)
ok(bestellung?.status === 'offen', `Bestellung offen (${bestellung?.status})`)
ok(bestellung?.summeCent === 5940, `Bestellsumme ${bestellung?.summeCent} Cent`)
merk.sitzung = bestellung?.stripeSessionId
merk.bestellnummer = bestellung?.bestellnummer
const sitzung = srv.kassenSitzungen().find((s) => s.id === merk.sitzung)
ok(Boolean(sitzung?.success_url?.includes('wk_bestellung=')), `success_url trägt wk_bestellung (${sitzung?.success_url})`)
merk.erfolgUrl = sitzung?.success_url
// Zahlung beim Haendlerkonto, signierter Connect-Webhook
const bezahlt = srv.bezahleKasse(merk.sitzung)
const w = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(w.status === 200, `Connect-Webhook ${w.status} ${JSON.stringify(w.body)}`)
const nach = srv.bestellungen().find((o) => o.stripeSessionId === merk.sitzung)
ok(nach?.status === 'bezahlt', `Bestellung bezahlt (${nach?.status})`)
const ptasse = srv.produkte().find((p) => p.name === 'Tasse Seladon')
ok(ptasse?.produkt.bestand === 11, `Bestand Tasse 12 -> ${ptasse?.produkt.bestand}`)
const wd = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(wd.status === 200, 'Wiederholung des Webhooks bleibt 200')
ok(srv.bestellungen().length === 1 && srv.produkte().find((p) => p.name === 'Tasse Seladon').produkt.bestand === 11, 'Wiederholung ändert nichts')
// Rueckkehr
await kaeufer.goto(merk.erfolgUrl)
await kaeufer.locator('.wks-statusbox .wks-ergebnis-text').waitFor()
await warteBis(async () => enthaelt(await kaeufer.locator('.wks-statusbox').innerText(), 'Ihre Zahlung ist eingegangen'), 'Status „Zahlung eingegangen“')
const st = norm(await kaeufer.locator('.wks-statusbox').innerText())
ok(st.includes(`Vielen Dank für Ihre Bestellung ${merk.bestellnummer}!`), `Dank mit Bestellnummer (${st})`)
ok(st.includes('Bestellnummer') && st.includes('59,40 €'), 'Statusbox: Bestellnummer und Gesamtsumme')
ok(st.includes('m***@example.com'), 'E-Mail verdeckt angezeigt')
ok(!kaeufer.url().includes('wk_t='), `Token aus der Adresse entfernt (${kaeufer.url()})`)
await shot(kaeufer, '26-shop-danke')
})
// ------------------------------------------------------------ 4c Demo-Seite (data-api, Cross-Origin)
await lauf('4c_demo_seite_cross_origin', async () => {
const demo = await ctxK.newPage()
await demo.goto(`${srv.kundenUrl}/shop`)
await demo.locator('.wks-raster li').first().waitFor()
ok(new URL(demo.url()).origin === srv.kundenUrl, `Seite kommt von ${new URL(demo.url()).origin}, API von ${srv.portalUrl}`)
ok(await demo.locator('.wks-raster li').count() === 2, 'Demo: zwei Artikel (GET Cross-Origin)')
ok(await demo.locator('.fallback').count() === 0, 'Ausweichtext ersetzt')
ok(await demo.locator('.wks-karte-bild').first().evaluate((img) => img.complete && img.naturalWidth > 0), 'Demo: Artikelbild über data-api geladen')
await shot(demo, '27-demo-seite-raster')
await demo.getByRole('button', { name: 'Tasse Seladon' }).first().click()
await demo.getByRole('button', { name: 'In den Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zum Warenkorb' }).click()
await demo.getByRole('button', { name: 'Zur Kasse' }).click()
await demo.locator('.wks-pflicht .wks-summen').waitFor() // POST /pruefen Cross-Origin (Preflight)
ok(enthaelt(await demo.locator('.wks-summen').innerText(), '23,40 €'), 'Demo: Zusammenfassung vom Server (18,50 € + 4,90 € Versand)')
ok(await demo.locator('.wks-gewaehr img').evaluate((i) => i.complete && i.naturalWidth > 0), 'Demo: Gewährleistungsbild geladen')
const vorher = stripeNavigationen.length
await demo.locator('form.wks-form button[type="submit"]').click()
await warteBis(() => stripeNavigationen.length > vorher, 'Demo: Weiterleitung zur Kasse (POST Cross-Origin)', 15000)
ok(stripeNavigationen.at(-1).art === 'checkout', 'Demo: Bestellung angelegt (Cross-Origin-POST erlaubt)')
const letzte = srv.bestellungen().find((o) => o.status === 'offen')
ok(Boolean(letzte), 'Demo: zweite Bestellung offen (bleibt unbezahlt)')
const s = srv.kassenSitzungen().find((x) => x.id === letzte?.stripeSessionId)
ok(Boolean(s?.success_url?.startsWith(`${srv.kundenUrl}/shop`)), `Demo: Rücksprung auf die Seite des Händlers (${s?.success_url})`)
await demo.close()
})
// ------------------------------------------------------------ 5 Mails
await lauf('5_mails_g09_und_k25', async () => {
await srv.warteAufVersand()
const alle = srv.mails()
console.log(` Mails: ${alle.map((m) => `${m.to.map((t) => t.address).join(',')}: ${m.subject}`).join(' | ')}`)
const g09 = alle.find((m) => m.to.some((t) => t.address === 'max@example.com') && enthaelt(m.text || '', 'Mit dieser E-Mail nehmen wir Ihre Bestellung an'))
ok(Boolean(g09), 'G09 an den Käufer mit „Mit dieser E-Mail nehmen wir Ihre Bestellung an“')
if (g09) {
ok(enthaelt(g09.subject, merk.bestellnummer), `G09-Betreff nennt die Bestellnummer (${g09.subject})`)
ok(enthaelt(g09.text, 'Töpferei Brandt'), 'G09 enthält Shopname/AGB des Händlers')
ok(enthaelt(g09.text, 'Widerruf'), 'G09 enthält die Widerrufsbelehrung')
ok(enthaelt(g09.text, '1 × Tasse Seladon'), 'G09 listet die Positionen')
ok(!(g09.bcc?.length), `G09 ohne BCC (${JSON.stringify(g09.bcc)})`)
ok(g09.replyTo?.some?.((r) => r.address === KUNDEN_EMAIL) || g09.replyTo?.address === KUNDEN_EMAIL || /toepferei|Töpferei/i.test(g09.from?.name || ''), `Absendername/Antwort an den Händler (${JSON.stringify(g09.from)} ${JSON.stringify(g09.replyTo)})`)
ok((g09.attachments || []).length >= 0, 'Anhänge geprüft')
}
const k25 = alle.find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /^Neue Bestellung \d\d-/.test(m.subject))
ok(Boolean(k25), `K25 an den Händler (${alle.filter((m) => m.to.some((t) => t.address === KUNDEN_EMAIL)).map((m) => m.subject).join(' / ')})`)
if (k25) {
ok(!(k25.bcc?.length), `K25 ohne BCC (${JSON.stringify(k25.bcc)})`)
ok(enthaelt(`${k25.subject} ${k25.text}`, merk.bestellnummer), 'K25 nennt die Bestellnummer')
ok(enthaelt(k25.text, 'max@example.com') || enthaelt(k25.text, 'Max Mustermann'), 'K25 nennt den Käufer')
}
// Die Warteschlange ist abgearbeitet
const offen = srv.outbox().filter((e) => e.status && !['sent', 'gesendet', 'done'].includes(e.status))
ok(offen.length === 0, `Outbox ohne offene Einträge (${offen.map((e) => `${e.art}:${e.status}`).join(',')})`)
})
// ------------------------------------------------------------ 6 Haendler: versenden, erstatten
await lauf('6_haendler_bestellung_versendet_erstattung', async () => {
const page = portalPage
await oeffne(page, '#webshop')
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
const zeile = page.locator(`[data-bestellung]`).first()
await zeile.waitFor()
ok(enthaelt(await zeile.innerText(), merk.bestellnummer), `Bestellung ${merk.bestellnummer} in der Liste`)
ok(enthaelt(await zeile.innerText(), '59,40 €') && enthaelt(await zeile.innerText(), 'Bezahlt'), 'Zeile: 59,40 €, Status Bezahlt')
await shot(page, '30-bestellungen-liste')
await zeile.click()
await page.locator('[data-aktion="versendet"]').waitFor()
const detail = norm(await page.locator('.sh-bestellungen-inhalt').innerText())
for (const teil of ['Max Mustermann', 'max@example.com', 'Lieferweg 7', '67655 Kaiserslautern', '1 × Tasse Seladon', '1 × Vase Wabi (Größe: Mittel)', 'Karte']) ok(detail.includes(teil), `Detail zeigt „${teil}“`)
await shot(page, '31-bestellung-detail')
await page.locator('[data-aktion="versendet"]').click()
const f = page.getByRole('form', { name: 'Als versendet markieren' })
await f.locator('[data-feld="versanddienst"]').fill('DHL')
await f.locator('[data-feld="sendungsnummer"]').fill('00340434161094042557')
await f.locator('[data-aktion="versendet-speichern"]').click()
await warteMeldung(page, 'Als versendet gespeichert')
ok(srv.bestellungen()[0].status === 'versendet', `Status versendet (${srv.bestellungen()[0].status})`)
ok(/DHL, 00340434161094042557/.test(await page.locator('.sh-bestellungen-inhalt').innerText()), 'Detail zeigt Versanddienst und Sendungsnummer')
await srv.warteAufVersand()
const g10 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && m.text.includes('00340434161094042557'))
ok(Boolean(g10), 'G10 an den Käufer mit der Sendungsnummer')
if (g10) ok(!(g10.bcc?.length), 'G10 ohne BCC')
await shot(page, '32-bestellung-versendet')
// Erstatten (teilweise)
const betrag = page.locator('#sh-erstatten-betrag')
ok(await betrag.inputValue() === '59,40', `Vorbelegung des Erstattungsbetrags ${await betrag.inputValue()}`)
await betrag.fill('1,00')
await page.locator('[data-aktion="erstatten"]').click()
await page.locator('[data-aktion="erstatten-bestaetigen"]').waitFor()
await shot(page, '33-erstattung-rueckfrage', { telefon: false })
await page.locator('[data-aktion="erstatten-bestaetigen"]').click()
await warteMeldung(page, 'erstattet')
ok(enthaelt(await echteMeldungen(page), '1,00 € erstattet'), `Meldung „${await echteMeldungen(page)}“`)
const acct = srv.kontoId()
const refundCall = st().calls.find((c) => c.method === 'POST' && c.path === '/v1/refunds')
ok(Boolean(refundCall), 'Stripe-Aufruf POST /v1/refunds')
ok(refundCall?.account === acct, `Erstattung mit Stripe-Account-Kopfzeile ${refundCall?.account} (Konto ${acct})`)
ok(refundCall?.body?.amount === '100' && refundCall?.body?.payment_intent === 'pi_ui_1', `Betrag ${refundCall?.body?.amount}, Zahlung ${refundCall?.body?.payment_intent}`)
const o = srv.bestellungen()[0]
ok(o.status === 'versendet' && /teilerstattet/.test(JSON.stringify(o.daten.verlauf)), 'Teilerstattung im Verlauf, Status bleibt versendet')
await srv.warteAufVersand()
const g13 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Erstattung|erstattet/i.test(`${m.subject} ${m.text}`) && /1,00/.test(m.text))
ok(Boolean(g13), 'G13 an den Käufer (1,00 € erstattet)')
await shot(page, '34-erstattung-erledigt')
})
// ------------------------------------------------------------ 7 Widerruf
await lauf('7_widerruf', async () => {
const w = await ctxK.newPage()
await w.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await w.locator('form.wks-form').waitFor()
ok(enthaelt(await w.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Titel „Vertrag widerrufen“')
await shot(w, '40-widerruf-formular')
// Pflichtfelder
await w.getByRole('button', { name: 'Weiter' }).click()
await w.locator('.wks-feldfehler:not([hidden])').first().waitFor()
ok(await w.locator('.wks-feldfehler:not([hidden])').count() >= 2, 'Leeres Formular: Feldfehler')
await w.getByLabel('Ihr Name').fill('Max Mustermann')
await w.getByLabel('E-Mail-Adresse der Bestellung').fill('max@example.com')
await w.getByLabel('Bestellnummer (falls zur Hand)').fill(merk.bestellnummer)
await w.getByLabel('Erklärung').fill('Ich widerrufe die Vase.')
await w.getByRole('button', { name: 'Weiter' }).click()
await w.getByRole('button', { name: 'Widerruf bestätigen' }).waitFor()
ok(enthaelt(await w.locator('.wks-zusammen').innerText(), merk.bestellnummer), 'Prüfseite nennt die Bestellnummer')
await shot(w, '41-widerruf-pruefen')
await w.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await w.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
const quittung = norm(await w.locator('.wks-ergebnis-text').innerText())
ok(/Ihr Widerruf ist am \d{1,2}\.\d{1,2}\.\d{4}.*\d{1,2}:\d{2}/.test(quittung), `Quittung mit Datum und Uhrzeit („${quittung}“)`)
await shot(w, '42-widerruf-quittung')
ok(srv.widerrufe().length === 1, `Widerruf gespeichert (${srv.widerrufe().length})`)
ok(Boolean(srv.widerrufe()[0]?.orderId), 'Widerruf der Bestellung zugeordnet')
await srv.warteAufVersand()
const g12 = srv.mails().find((m) => m.to.some((t) => t.address === 'max@example.com') && /Widerruf/.test(m.subject))
ok(Boolean(g12), 'G12 (Eingangsbestätigung) an den Käufer')
if (g12) ok(/\d{2}:\d{2}/.test(g12.text) && /\d{4}/.test(g12.text), 'G12 nennt Datum und Uhrzeit')
const k26 = srv.mails().find((m) => m.to.some((t) => t.address === KUNDEN_EMAIL) && /Widerruf/.test(m.subject))
ok(Boolean(k26), `K26 an den Händler (${srv.mails().map((m) => m.subject).filter((s) => /Widerruf/.test(s)).join(' / ')})`)
if (k26) ok(!(k26.bcc?.length), 'K26 ohne BCC')
// Haendler sieht den Widerruf
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'bestellungen')
await page.locator('[data-ansicht="widerrufe"]').click()
await page.locator('[data-widerruf]').waitFor()
ok(enthaelt(await page.locator('[data-widerruf]').innerText(), 'Max Mustermann'), 'Widerruf im Reiter Bestellungen')
await shot(page, '43-haendler-widerrufe')
await page.locator('[data-aktion="erledigt"]').click()
await warteMeldung(page, 'als erledigt markiert')
ok(Boolean(srv.widerrufe()[0]?.bearbeitetAt), 'Widerruf als erledigt gespeichert')
await w.close()
})
// ------------------------------------------------------------ 8 Shop schliessen
await lauf('8_shop_schliessen_widerruf_bleibt', async () => {
const page = portalPage
await page.reload()
await webshopBereit(page)
await reiter(page, 'uebersicht')
await page.locator('[data-aktion="schliessen"]').click()
await warteMeldung(page, 'Dein Shop ist jetzt geschlossen')
ok(srv.shopDoc()?.geoeffnet === false, 'geoeffnet false im Dokument')
ok(await page.locator('[data-zustand="zu"]').count() === 1, 'Zustand „Dein Shop ist geschlossen“')
await shot(page, '50-uebersicht-geschlossen')
const k = await ctxK.newPage()
await k.goto(`${srv.portalUrl}/shop/${key}`)
await k.locator('.wks-titel').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Shop geschlossen'), 'Widget: „Shop geschlossen“')
ok(enthaelt(await k.locator('.wks-body').innerText(), 'Der Shop ist gerade geschlossen.'), 'Widget: Text „Der Shop ist gerade geschlossen.“')
ok(await k.locator('.wks-raster').count() === 0 && await k.getByRole('button', { name: /Warenkorb/ }).count() === 0, 'kein Raster, kein Warenkorb')
const widerrufKnopf = k.locator('.wks-fuss').getByRole('button', { name: 'Vertrag widerrufen' })
ok(await widerrufKnopf.count() === 1, 'Fußzeile: „Vertrag widerrufen“ bleibt')
ok(await k.locator('.wks-fuss').getByRole('button', { name: 'AGB' }).count() === 1, 'Fußzeile: AGB bleibt')
await shot(k, '51-widget-geschlossen')
await widerrufKnopf.click()
await k.locator('form.wks-form').waitFor()
ok(enthaelt(await k.locator('.wks-titel').innerText(), 'Vertrag widerrufen'), 'Widerrufsformular im geschlossenen Shop erreichbar')
// direkte Adresse
await k.goto(`${srv.portalUrl}/shop/${key}/widerruf`)
await k.locator('form.wks-form').waitFor()
ok(true, '/widerruf-Adresse erreichbar')
// Widerruf im geschlossenen Shop wird weiter angenommen
await k.getByLabel('Ihr Name').fill('Erika Muster')
await k.getByLabel('E-Mail-Adresse der Bestellung').fill('erika@example.com')
await k.getByLabel('Erklärung').fill('Bestellt am 05.10.2026: Tasse Seladon.')
await k.getByRole('button', { name: 'Weiter' }).click()
await k.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await k.locator('.wks-ergebnis-text').waitFor({ timeout: 15000 })
ok(srv.widerrufe().length === 2, `zweiter Widerruf im geschlossenen Shop gespeichert (${srv.widerrufe().length})`)
// POST /pruefen und /bestellung im geschlossenen Shop: 409
const key2 = srv.siteKey()
const r = await fetch(`${srv.url}/api/public/shop/v1/${key2}/pruefen`, { method: 'POST', headers: { 'content-type': 'application/json', origin: srv.portalUrl }, body: JSON.stringify({ positionen: [{ produktId: srv.produkte()[0].$id, menge: 1 }], lieferart: 'versand', land: 'DE' }) })
ok(r.status === 409, `POST /pruefen im geschlossenen Shop: ${r.status}`)
await k.close()
})
await ctxK.close()
}
const st = () => srv.st
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch({ executablePath: CHROME })
await szenario()
if (srv.siteKey()) await kaeuferSchritte()
const eigene = new Set([srv.url, srv.portalUrl, srv.kundenUrl].map((u) => new URL(u).host))
const fremd = [...fremdeHosts].filter((h) => !eigene.has(h))
ergebnisse.push(['nur_eigene_herkunft', fremd.length === 0, fremd.length ? [`Anfragen an fremde Hosts: ${fremd.join(', ')}`] : [], []])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const gescheitert = ergebnisse.filter(([, bestanden]) => !bestanden)
console.log(`\n${ergebnisse.length - gescheitert.length}/${ergebnisse.length} Schritte bestanden`)
for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`)
process.exit(abbruch || gescheitert.length ? 1 : 0)
+289
View File
@@ -0,0 +1,289 @@
// UI-Testserver des Webshops (fuer test/ui/shop.ui.mjs): die echten Express-Router (/api/public/shop/v1, /api/shop/stripe-webhook,
// /api/shop, /api/funktionen, /api/abo), die Seiten /shop/:siteKey und /rechtliches/ sowie public/ als statische Dateien, dahinter der
// Nachbau von Appwrite und Stripe (mit Connect) aus test/support/ und eine Mail-Senke. Nichts verlaesst den Rechner (useTestEnv erlaubt nur
// 127.0.0.1). Muster: test/ui/termineTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startShopUiServer } from './shopTestServer.mjs'
// const srv = await startShopUiServer()
// ... srv.url, srv.portalUrl, srv.kundenUrl, srv.sink.sent, srv.st (Stripe-Nachbau), srv.aw (Appwrite-Nachbau) ...
// await srv.stop() // erst den Browser schliessen, dann den Server
//
// Uhr: echte Uhr (Server und Browser). Zeitabhaengige Pruefungen lesen die Zeit aus der Antwort.
//
// Mitarbeiter-Schritte rufen die Dienste direkt auf (die Team-Routen brauchen ein Mitarbeiter-JWT):
// srv.entscheide('annehmen' | 'ablehnen', begruendung) entscheideAnfrage
// srv.bezahleEinrichtung() Plattform-Webhook checkout.session.completed fuer die Bestellung der Einrichtung (signiert, /api/abo/webhook)
// srv.schliesseTicket() Einrichtungs-Ticket schliessen (workorders.status 'Closed'); GET /api/funktionen schaltet danach frei
// Haendler-Konto bei Stripe: srv.kontoFreigeben() (charges_enabled, payouts_enabled), Kasse des Kaeufers: srv.bezahleKasse(sessionId) +
// srv.sendeConnect(type, objekt) (signierter Connect-Webhook an /api/shop/stripe-webhook).
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT, fuer Webhooks und Direktaufrufe). Das Portal heisst fuer Server und Browser
// srv.portalUrl (https://project.webklar.test = config.publicBaseUrl, wie in Produktion https); das Skript leitet diese Adresse im Browser per
// page.route an srv.url um (route.fetch). Die Website des Haendlers heisst srv.kundenUrl (https://www.toepferei-brandt.example = liveDomain des
// Projekts); ihre Seiten liefert das Skript per page.route. So sind Stripe-Rueckspruenge, Bild-Adressen (Widget verlangt https) und
// Cross-Origin-Anfragen (data-api) so wie im Betrieb.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { startFakeStripe } from '../support/fakeStripe.js'
import { mailSink } from '../support/mailSink.js'
import { schemaKeys } from '../support/schema.js'
import { useTestEnv } from '../support/testEnv.js'
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
export const PORTAL_URL = 'https://project.webklar.test'
export const KUNDEN_URL = 'https://www.toepferei-brandt.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_shop'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'hanna@toepferei-brandt.example'
export const KUNDEN_NAME = 'Hanna Brandt'
export const MITARBEITER = { name: 'Kenso', email: 'kenso@webklar.com' }
/** Anfrage, wie sie das Formular im Portal schickt. */
export const ANFRAGE = {
warenart: 'kunst',
beschreibung: 'Handgemachte Keramik, Tassen und Vasen aus eigener Werkstatt in Winnweiler.',
artikelzahl: 'bis20',
versand: true,
abholung: true,
unternehmer: true,
keineAusgeschlossenen: true,
nachricht: 'Wir würden gern vor Weihnachten starten.',
}
export async function startShopUiServer() {
const { SHOP_DATETIMES } = await import('../../server/services/shop/schema.js')
const { TERMINE_DATETIMES } = await import('../../server/services/termine/schema.js')
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
featureConsents: schemaKeys('featureConsents'),
subscriptions: schemaKeys('subscriptions'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp, postWebhook } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicShopRoutes, resetPublicShopForTests, setzeUhrFuerTests: setzeUhrPublic } = await import('../../server/routes/publicShop.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../../server/routes/shop.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../../server/routes/abo.js')
const { shopSeiteRouter, resetShopSeiteForTests } = await import('../../server/routes/shopSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { entscheideAnfrage } = await import('../../server/services/shop/anfrage.js')
const { leereCache } = await import('../../server/services/shop/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
const { getFeatureDoc } = await import('../../server/services/siteFeatures.js')
// Reihenfolge wie server/index.js: /embed (CORS, Cache), /rechtliches, Shop-Seiten, public/
const statisch = express.Router()
statisch.use('/embed/', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*')
next()
})
statisch.use('/rechtliches', legalRouter())
statisch.use(shopSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/shop/v1': publicShopRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/shop': shopRoutes,
'/api/funktionen': funktionenRoutes,
'/api/abo': aboRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'shop-ui-'))
const vorher = {
mode: config.shop.mode,
pilotEmails: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
formMinSekunden: config.shop.formMinSekunden,
minAntwortMs: config.shop.minAntwortMs,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE,
aw, st, config,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer page.context().addCookies([...]): { name, value, url }. */
async portalCookie() {
const { sessionCookie } = await import('../support/testApp.js')
const roh = sessionCookie({ customerId: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL })
const i = roh.indexOf('=')
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: PORTAL_URL }
},
// ---------------------------------------------------------------- Dokumente lesen
featureDoc() { return [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'shop') ?? null },
shopDoc() { return [...aw.docs('shopConfig').values()][0] ?? null },
siteKey() { return srv.featureDoc()?.siteKey ?? srv.shopDoc()?.siteKey ?? null },
einstellungen() { return JSON.parse(srv.shopDoc().einstellungen) },
rechtstexte() { return JSON.parse(srv.shopDoc().rechtstexte) },
produkte() { return [...aw.docs('shopProducts').values()].map((d) => ({ ...d, produkt: JSON.parse(d.produkt) })) },
bestellungen() { return [...aw.docs('shopOrders').values()].map((d) => ({ ...d, daten: JSON.parse(d.bestellung) })) },
widerrufe() { return [...aw.docs('shopWiderrufe').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mails(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
outbox() { return [...aw.docs('termineOutbox').values()] },
// ---------------------------------------------------------------- Mitarbeiter-Schritte (Dienste direkt)
async entscheide(entscheidung = 'annehmen', begruendung = '') {
return entscheideAnfrage({ customerId: KUNDE, projectId: PROJEKT, entscheidung, begruendung, person: MITARBEITER })
},
/** Bestellung der Einrichtung (Zustand ordered) bezahlen: Sitzung abschliessen, signierter Plattform-Webhook. Liefert { status } der Antwort. */
async bezahleEinrichtung() {
const doc = srv.featureDoc()
if (!doc?.stripeSessionId) throw new Error(`keine Bestellung der Einrichtung (Zustand ${doc?.state})`)
const sessionId = doc.stripeSessionId
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
const sitzung = { ...st.checkoutSessions.get(sessionId), status: 'complete', payment_status: 'paid', invoice }
st.checkoutSessions.set(sessionId, sitzung)
const event = { id: `evt_${Math.random()}`, object: 'event', type: 'checkout.session.completed', data: { object: sitzung } }
return postWebhook(event)
},
/** Einrichtungs-Ticket der Funktion schliessen. */
async schliesseTicket() {
const doc = srv.featureDoc()
if (!doc?.ticketId) throw new Error(`kein Ticket (Zustand ${doc?.state})`)
await updateDocument(col('workorders'), doc.ticketId, { status: 'Closed' })
},
// ---------------------------------------------------------------- Stripe (Haendlerkonto)
/** Das Haendlerkonto des Shops nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.shopDoc()?.stripeAccountId
if (!id) throw new Error('kein Stripe-Konto angelegt')
return st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null }, ...patch })
},
kontoId() { return srv.shopDoc()?.stripeAccountId ?? null },
/** Offene Checkout-Sitzungen des Haendlerkontos. */
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
/** Sitzung des Kaeufers als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung = 'pi_ui_1', kunde, versand, betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const order = srv.bestellungen().find((o) => o.stripeSessionId === sessionId)
const summe = betrag ?? order?.summeCent ?? null
st.setPaymentIntent(acct, zahlung, { amount: summe })
return st.completeSession(acct, sessionId, {
payment_status: 'paid',
amount_total: summe,
customer_details: kunde ?? {
name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567',
address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' },
},
shipping: versand ?? { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } },
payment_intent: zahlung,
payment_method_types: ['card'],
})
},
/** Signiertes Connect-Ereignis an POST /api/shop/stripe-webhook -> { status, body }. */
sendeConnect(type, objekt, { account } = {}) {
const event = { id: `evt_${Math.random().toString(36).slice(2)}`, object: 'event', type, livemode: false, account: account ?? srv.kontoId(), data: { object: objekt } }
return postWebhook(event, { path: '/api/shop/stripe-webhook', secret: CONNECT_SECRET })
},
leereCache() { leereCache() },
/**
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke; Kunde c1 mit Projekt p1 und Wartungs-Abo-freier
* Bestellung (der Webshop verlangt kein Abo). Schalter: Pilotmodus fuer alle Kunden ('*').
*/
async zuruecksetzen() {
await warteAufVersand()
const sammlungen = [
'portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox',
'featureConsents', 'customers', 'websiteProjects', 'workorders', 'subscriptions',
]
for (const collection of sammlungen) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(col(collection) ?? collection, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicShopForTests()
resetShopRoutesForTests()
resetShopSeiteForTests()
setzeUhrPublic(() => new Date())
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
config.shop.formMinSekunden = 0
config.shop.minAntwortMs = 0
config.shop.uploadDir = uploadDir
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = PORTAL_URL
srv.sink = mailSink()
setMailTransportForTests(srv.sink)
aw.seed('websiteProjects', {
$id: PROJEKT, customerId: KUNDE, projectName: 'Töpferei Brandt', subdomain: 'toepferei', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
resetPublicShopForTests()
resetShopRoutesForTests()
Object.assign(config.shop, {
mode: vorher.mode, pilotEmails: vorher.pilotEmails, connectWebhookSecret: vorher.connectWebhookSecret, formMinSekunden: vorher.formMinSekunden,
minAntwortMs: vorher.minAntwortMs, uploadDir: vorher.uploadDir,
})
config.publicBaseUrl = vorher.publicBaseUrl
config.smtp.from = vorher.smtpFrom
let frist = null
const geschlossen = app.close()
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
clearTimeout(frist)
await Promise.all([aw.close(), st.close()])
await rm(uploadDir, { recursive: true, force: true })
},
}
// getFeatureDoc ist nur fuer Skripte gedacht, die den Zustand pruefen wollen
srv.getFeatureDoc = () => getFeatureDoc(KUNDE, PROJEKT, 'shop')
await srv.zuruecksetzen()
return srv
}