Buchungskalender: UI-Durchlauf (Playwright)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 15:06:33 +00:00
1 parent cb0799a35c
commit 03ae8c77cd
2 files changed
+903

No files matched your search

+673
View File
@@ -0,0 +1,673 @@
// End-to-end-Pruefung des Buchungskalenders im Browser (Playwright, Chromium), gegen die echten Router hinter Nachbauten von Appwrite,
// Stripe (Connect) und Mail (test/ui/unterkunftTestServer.mjs). Nicht Teil von npm test.
// Aufruf: nice node --max-old-space-size=1024 test/ui/unterkunft.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/buchungskalender-2026-10-07/ui/ (fortlaufend nummeriert, Desktop 1280 und Telefon 390). Exit-Code 1, wenn ein Schritt scheitert.
//
// Ein durchgehendes Szenario (jeder Schritt baut auf dem vorigen auf):
// 1 Portal „Buchungen“: Einstellungen (inkl. Rechnungsauftrag), Stripe verbinden + Online-Zahlung, Unterkunft „Donau Home“ mit Preisen
// 2 Widget auf der Seite des Vermieters, 1280 und 390 px: zwei Anfragen (Eva am Desktop, Paul am Telefon)
// 3 Portal „Anfragen“: Vorschlag pruefen, Posten aendern, Freigeben (Buchungslink, G17)
// 4 Buchungsseite: Pflichtangaben, Rechnungsadresse, „vor Ort“, „Zahlungspflichtig buchen“, Rechnung als PDF, G19 mit Anhang
// 5 Portal: „Als bezahlt (vor Ort) markieren“
// 6 Zweite Anfrage freigeben, online bezahlen (Stripe-Nachbau, Webhook, Rueckkehr), G20
// 7 Kalender (Belegungen farbig, Sperre) und iCal-Export
// 8 Stornieren mit Teilerstattung (G22, Erstattung im Stripe-Nachbau), Rechnungen, iCal ohne die stornierte Buchung
// Stripe-Adressen (https://*.stripe.test) werden per page.route abgefangen; die Erstattung und die Zahlung des Gastes macht der Stripe-Nachbau.
import { mkdir, readdir, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import path from 'node:path'
import { startUnterkunftUiServer, KUNDEN_EMAIL, SITE_KEY } from './unterkunftTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core'
const { chromium } = createRequire(import.meta.url)(PW)
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/buchungskalender-2026-10-07/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 startUnterkunftUiServer()
const DESKTOP = { width: 1280, height: 900 }
const PHONE = { width: 390, height: 844 }
// ---------------------------------------------------------------- Daten des Szenarios
const heute = new Date()
const naechsterMonat = new Date(Date.UTC(heute.getFullYear(), heute.getMonth() + 1, 1))
const tag = (n) => `${naechsterMonat.getUTCFullYear()}-${String(naechsterMonat.getUTCMonth() + 1).padStart(2, '0')}-${String(n).padStart(2, '0')}`
const EVA = { name: 'Eva Gast', email: 'eva@example.com', von: tag(10), bis: tag(17), personen: '2' } // 7 Naechte (Staffel 7)
const PAUL = { name: 'Paul Phone', email: 'paul@example.com', von: tag(20), bis: tag(23), personen: '2' } // 3 Naechte (Staffel 1)
const SPERRE = { von: tag(26), bis: tag(27) }
const RESERVIERT = { von: tag(2), bis: tag(4) }
const ohneStriche = (t) => t.replaceAll('-', '')
// ---------------------------------------------------------------- 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))
let nummer = 0
/** Screenshot der ganzen Seite in der aktuellen Fensterbreite (Name: fortlaufende Nummer, Beschreibung, Breite). */
async function shot(page, name) {
nummer += 1
const breite = page.viewportSize().width
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {})))).catch(() => {})
await page.waitForTimeout(150)
const datei = `${SHOTS}/${String(nummer).padStart(2, '0')}-${name}-${breite}.png`
await page.screenshot({ path: datei, fullPage: true })
return datei
}
/** Dieselbe Seite zusaetzlich in der anderen Breite aufnehmen und zurueckstellen. */
async function shotBeide(page, name) {
const vorher = page.viewportSize()
await shot(page, name)
await page.setViewportSize(vorher.width > 600 ? PHONE : DESKTOP)
await shot(page, name)
await page.setViewportSize(vorher)
}
async function warteBis(bedingung, text, ms = 10000) {
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/shop.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Mimi Donau', email: KUNDEN_EMAIL } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Donau Home', 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\/unterkunft/, /^\/api\/public\/unterkunft/]
const stripeNavigationen = [] // { art: 'connect' | 'checkout', url }
const offeneSeiten = new Set() // fuer Fehler-Screenshots
const fremdeHosts = new Set()
/** Seite der Vermieter-Website (https://www.donau-home.example/...): Platzhalter fuer den Einbau des Widgets mit data-api (Cross-Origin). */
const kundenSeite = () => `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Donau Home</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#f6f3ee;color:#2b2b2b}header,footer{padding:16px 24px;background:#e4ded3}main{max-width:1180px;margin:0 auto;padding:24px 16px}</style></head>
<body><header>Donau Home – Monteurwohnung Stetten (Testseite des Vermieters)</header><main><h1>Jetzt anfragen</h1>
<div data-webklar-unterkunft="${SITE_KEY}" data-api="${srv.portalUrl}"></div></main>
<footer>Fußbereich</footer><script src="${srv.portalUrl}/embed/unterkunft.js" defer></script></body></html>`
async function neuerKontext(browser, { cookie = false, viewport = DESKTOP } = {}) {
// Service Worker des Portals blockieren: ihre Anfragen umgingen page.route
const ctx = await browser.newContext({ viewport, serviceWorkers: 'block', locale: 'de-DE', timezoneId: 'Europe/Berlin' })
if (cookie) await ctx.addCookies([await srv.portalCookie()])
ctx.setDefaultTimeout(10000)
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 })
})
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() })
})
// Stripe: Navigation abfangen (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#buchungen?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.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png`, fullPage: true }).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}`)
}
// ---------------------------------------------------------------- Portal-Helfer
const oeffnePortal = async (page) => {
await page.goto(`${srv.portalUrl}/dashboard.html#buchungen`)
await page.locator('#view-buchungen.active').waitFor()
await page.locator('#buchungen-body .sh-reiterleiste').waitFor()
}
const reiter = async (page, key) => {
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#bk-tab-${key}[aria-selected="true"]`).waitFor()
}
const warteMeldung = async (page, teil) => {
await warteBis(async () => enthaelt(await page.locator('#buchungen-body .sh-meldung').innerText().catch(() => ''), teil), `Meldung „${teil}“`)
}
const eurotext = (cent) => `${(cent / 100).toFixed(2).replace('.', ',')} €`
// ---------------------------------------------------------------- Widget-Helfer
/** Sichtbarer Monatsraster des Widgets (aria-label „November 2026“). */
const sichtbareMonate = (page) => page.locator('.wku-raster:visible').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label')))
/** Klickt, bis der Tag sichtbar ist (Weiter-Knopf), und waehlt ihn. */
async function waehleTag(page, t) {
for (let i = 0; i < 4; i += 1) {
if (await page.locator(`[data-tag="${t}"]:visible`).count()) break
await page.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').first().click()
await page.waitForTimeout(250)
}
await page.locator(`[data-tag="${t}"]:visible`).click()
}
async function sendeWidgetAnfrage(page, g) {
await page.goto(`${srv.kundenUrl}/anfragen`)
await page.locator('[data-webklar-unterkunft] [data-tag]').first().waitFor()
await page.waitForTimeout(300)
return sichtbareMonate(page)
}
async function fuelleWidget(page, g) {
await waehleTag(page, g.von)
await waehleTag(page, g.bis)
await page.locator('select[name="personen"]').selectOption(g.personen)
await page.locator('[name="name"]').fill(g.name)
await page.locator('[name="email"]').fill(g.email)
}
// ---------------------------------------------------------------- Szenario
let portalCtx = null
let portal = null // Seite des Vermieters im Portal (Cookie)
const merk = {} // IDs und Texte zwischen den Schritten
async function szenario() {
portalCtx = await neuerKontext(browser, { cookie: true })
portal = await portalCtx.newPage()
// ------------------------------------------------------------ 1
await lauf('1_portal_einstellungen_stripe_unterkunft_preise', async () => {
await oeffnePortal(portal)
await shot(portal, 'portal-anfragen-leer')
ok(await portal.locator('#view-buchungen h1').first().innerText().then((t) => enthaelt(t, 'Buchungskalender')), 'Ansicht „Buchungskalender“ geoeffnet')
// Einstellungen
await reiter(portal, 'einstellungen')
const form = portal.locator('form.sh-einstellungen')
await form.waitFor()
await form.locator('[data-feld="firma"]').fill('Donau Home')
await form.locator('[data-feld="inhaber"]').fill('Mimi Donau')
await form.locator('[data-feld="strasse"]').fill('Donaustraße 5')
await form.locator('[data-feld="plz"]').fill('78576')
await form.locator('[data-feld="ort"]').fill('Stetten')
await form.locator('[data-feld="email"]').fill('buchung@donau-home.example')
await form.locator('[data-feld="telefon"]').fill('07461 12345')
await form.locator('[data-feld="steuernummer"]').fill('12/345/67890')
await form.locator('[data-feld="iban"]').fill('DE02120300000000202051')
await form.locator('[data-feld="bic"]').fill('BYLADEM1001')
await form.locator('[data-feld="bank"]').fill('Beispielbank')
await shot(portal, 'einstellungen-ohne-zustimmung')
// ohne Zustimmung wird gespeichert, der Hinweis bleibt aber sichtbar
ok(await form.locator('[data-hinweis="auftrag"]').isVisible(), 'Hinweis „Ohne diesen Auftrag …“ sichtbar, solange die Zustimmung fehlt')
await form.locator('[data-feld="rechnungAuftrag"]').check()
ok(!(await form.locator('[data-hinweis="auftrag"]').isVisible()), 'Hinweis verschwindet mit der Zustimmung')
await form.locator('[data-aktion="einstellungen-speichern"]').click()
await warteMeldung(portal, 'Die Einstellungen sind gespeichert')
const e = srv.einstellungen()
ok(e?.firma === 'Donau Home' && e.steuernummer === '12/345/67890', `Einstellungen gespeichert (${e?.firma})`)
ok(Boolean(e?.rechnungAuftragAt), 'Zustimmung zur Rechnungsstellung gespeichert (rechnungAuftragAt)')
await shot(portal, 'einstellungen-gespeichert')
// Stripe verbinden -> Onboarding (abgefangen) -> zurueck ins Portal
await portal.locator('[data-aktion="stripe-verbinden"]').click()
await warteBis(() => stripeNavigationen.some((n) => n.art === 'connect'), 'Weiterleitung zum Stripe-Onboarding')
await portal.locator('#buchungen-body .sh-reiterleiste').waitFor()
ok(Boolean(srv.kontoId()), `Stripe-Konto angelegt (${srv.kontoId()})`)
srv.kontoFreigeben()
await reiter(portal, 'einstellungen')
await portal.locator('[data-stripe-status="onboarding"]').waitFor()
await portal.locator('[data-aktion="stripe-aktualisieren"]').click()
await portal.locator('[data-stripe-status="aktiv"]').waitFor()
await portal.locator('form.sh-einstellungen [data-feld="onlineZahlung"]').check()
await portal.locator('[data-aktion="einstellungen-speichern"]').click()
await warteMeldung(portal, 'Die Einstellungen sind gespeichert')
ok(srv.einstellungen()?.onlineZahlung === true, 'Online-Zahlung eingeschaltet')
await shot(portal, 'einstellungen-stripe-aktiv')
// Unterkunft anlegen
await reiter(portal, 'unterkuenfte')
await portal.locator('[data-leer]').waitFor()
await shot(portal, 'unterkuenfte-leer')
await portal.locator('[data-aktion="neu"]').click()
await portal.locator('#bk-u-name').fill('Donau Home')
await portal.locator('#bk-u-beschr').fill('Monteurwohnung mit Küche, 4 Betten, Parkplatz am Haus.')
await portal.locator('#bk-u-max').fill('4')
await portal.locator('#bk-u-min').fill('1')
await portal.locator('#bk-u-ein').fill('15:00')
await portal.locator('#bk-u-aus').fill('10:00')
await portal.locator('[data-aktion="speichern"]').click()
await portal.locator('.bk-preise form').waitFor()
ok(srv.unterkuenfte().length === 1 && srv.unterkuenfte()[0].name === 'Donau Home', 'Unterkunft „Donau Home“ angelegt')
merk.unterkunftId = srv.unterkuenfte()[0]?.$id
// Preise: Staffeln 1 / 7 / 28, Reinigung, Direktrabatt 10 %, Regelbesteuerung, Mustertext
const staffel = (i) => portal.locator('[data-staffel-zeile]').nth(i)
await staffel(0).locator('[data-staffel="preis"]').fill('90,00')
await portal.locator('[data-aktion="staffel-dazu"]').click()
await staffel(1).locator('[data-staffel="ab"]').fill('7')
await staffel(1).locator('[data-staffel="preis"]').fill('80,00')
await portal.locator('[data-aktion="staffel-dazu"]').click()
await staffel(2).locator('[data-staffel="ab"]').fill('28')
await staffel(2).locator('[data-staffel="preis"]').fill('70,00')
await portal.locator('#bk-p-reinigung').fill('60,00')
await portal.locator('#bk-p-inklusive').fill('2')
await portal.locator('#bk-p-aufpreis').fill('10,00')
await portal.locator('#bk-p-rabatt').fill('10')
await portal.locator('#bk-p-steuerart').selectOption('regel')
await portal.locator('#bk-p-ust-ue').fill('7')
await portal.locator('#bk-p-ust-re').fill('19')
await portal.locator('#bk-p-frist').fill('3')
await portal.locator('[data-aktion="storno-muster"]').click()
ok((await portal.locator('#bk-p-storno').inputValue()).startsWith('1. Stornierung durch den Gast.'), 'Mustertext in die Stornobedingungen übernommen')
await shot(portal, 'preise-ausgefuellt')
await portal.locator('[data-aktion="preise-speichern"]').click()
await warteMeldung(portal, 'Die Preise sind gespeichert')
await portal.locator('[data-vorschau]').filter({ hasText: 'Beispiel:' }).waitFor()
const preise = srv.preise()[0]
ok(preise?.staffeln?.length === 3 && preise.staffeln.map((s) => s.abNaechten).join() === '1,7,28', `Staffeln 1/7/28 gespeichert (${JSON.stringify(preise?.staffeln)})`)
ok(preise?.reinigungCent === 6000 && preise?.direktrabattProzent === 10 && preise?.steuerart === 'regel', 'Reinigung 60 €, Direktrabatt 10 %, Regelbesteuerung gespeichert')
ok((preise?.stornobedingungen || '').length > 200, 'Stornobedingungen gespeichert')
ok(enthaelt(await portal.locator('[data-vorschau]').innerText(), '7 Nächte, 2 Personen = '), `Beispielrechnung sichtbar (${norm(await portal.locator('[data-vorschau]').innerText())})`)
await shot(portal, 'preise-gespeichert')
})
// ------------------------------------------------------------ 2
await lauf('2_widget_desktop_und_telefon', async () => {
// Desktop 1280: zwei Monate, Weiter-Knopf
const ctxD = await neuerKontext(browser, { viewport: DESKTOP })
const d = await ctxD.newPage()
const monateD = await sendeWidgetAnfrage(d)
ok(monateD.length === 2, `1280 px: zwei Monate sichtbar (${monateD.join(' | ')})`)
await shot(d, 'widget-leer')
await d.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').last().click()
await d.waitForTimeout(250)
const weiter = await sichtbareMonate(d)
ok(weiter.length === 2 && weiter[0] === monateD[1], `1280 px: „Weiter“ verschiebt um einen Monat (${monateD.join(' | ')} -> ${weiter.join(' | ')})`)
await d.locator('.wku-pfeil:visible[aria-label="Vorheriger Monat"]').first().click()
await d.waitForTimeout(250)
ok((await sichtbareMonate(d))[0] === monateD[0], '1280 px: „Zurück“ geht zum Ausgangsmonat')
await fuelleWidget(d, EVA)
await shot(d, 'widget-ausgefuellt')
await d.locator('button[type="submit"]').click()
await d.locator('[data-webklar-unterkunft]').getByText('Danke!').waitFor()
const dankeD = await d.locator('[data-webklar-unterkunft]').innerText()
ok(/Danke! Ihre Anfrage [A-Z0-9-]+ ist eingegangen/.test(norm(dankeD)), `1280 px: Danke-Text (${norm(dankeD).slice(0, 90)})`)
await shot(d, 'widget-danke')
await ctxD.close()
// Telefon 390: ein Monat, Weiter-Knopf
const ctxT = await neuerKontext(browser, { viewport: PHONE })
const t = await ctxT.newPage()
const monateT = await sendeWidgetAnfrage(t)
ok(monateT.length === 1, `390 px: ein Monat sichtbar (${monateT.join(' | ')})`)
ok(await t.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').count() >= 1, '390 px: „Nächster Monat“ sichtbar')
ok(await t.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), '390 px: kein waagerechtes Scrollen')
await shot(t, 'widget-leer')
await fuelleWidget(t, PAUL)
ok((await sichtbareMonate(t)).length === 1, '390 px: nach dem Weiterklicken weiter ein Monat')
await shot(t, 'widget-ausgefuellt')
await t.locator('button[type="submit"]').click()
await t.locator('[data-webklar-unterkunft]').getByText('Danke!').waitFor()
ok(/Danke! Ihre Anfrage [A-Z0-9-]+ ist eingegangen/.test(norm(await t.locator('[data-webklar-unterkunft]').innerText())), '390 px: Danke-Text')
await shot(t, 'widget-danke')
await ctxT.close()
const a = srv.anfragen()
ok(a.length === 2 && a.every((x) => x.status === 'neu'), `zwei Anfragen im Status neu (${a.map((x) => x.status).join()})`)
merk.eva = a.find((x) => x.gast?.email === EVA.email)?.$id
merk.paul = a.find((x) => x.gast?.email === PAUL.email)?.$id
ok(Boolean(merk.eva && merk.paul), 'beide Anfragen den Gästen zugeordnet')
await srv.warteAufVersand()
ok(srv.gastArten().filter((x) => x === 'G16').length === 2, `G16 an beide Gäste (${srv.gastArten().join()})`)
ok(srv.vermieterArten().includes('K31'), `K31 an den Vermieter (${srv.vermieterArten().join()})`)
ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === EVA.email)), 'G16 liegt in der Mail-Senke')
})
// ------------------------------------------------------------ 3
await lauf('3_portal_anfrage_pruefen_aendern_freigeben', async () => {
await oeffnePortal(portal)
await reiter(portal, 'anfragen')
await portal.locator(`[data-anfrage="${merk.eva}"]`).waitFor()
ok((await portal.locator('[data-anfrage]').count()) === 2, 'Liste „Offen“ zeigt zwei Anfragen')
await shot(portal, 'anfragen-offen')
await portal.locator(`[data-anfrage="${merk.eva}"]`).click()
await portal.locator('.bk-freigabe').waitFor()
const posten = portal.locator('[data-zeile]')
const texte = await posten.locator('[data-posten="text"]').evaluateAll((els) => els.map((e) => e.value))
ok(texte.length === 3, `Vorschlag mit drei Posten (${texte.join(' | ')})`)
ok(texte.some((x) => /Reinigung/i.test(x)) && texte.some((x) => /rabatt/i.test(x)), 'Vorschlag enthält Reinigung und Direktrabatt')
const summe0 = await portal.locator('[data-summen]').innerText()
ok(enthaelt(summe0, eurotext(56400)), `Vorschlag: Gesamt ${eurotext(56400)} (7 × 80 + 60 − 56) – ${norm(summe0)}`)
await shot(portal, 'anfrage-vorschlag')
// Posten ändern: Reinigung auf 50 €
const i = texte.findIndex((x) => /Reinigung/i.test(x))
await posten.nth(i).locator('[data-posten="einzel"]').fill('50,00')
await posten.nth(i).locator('[data-posten="einzel"]').dispatchEvent('change')
await portal.locator('#bk-a-nachricht').fill('Gern auch mit Hund. Schlüsselübergabe nach Absprache.')
await warteBis(async () => enthaelt(await portal.locator('[data-summen]').innerText(), eurotext(55400)), `Summe live ${eurotext(55400)}`)
await shot(portal, 'anfrage-posten-geaendert')
await portal.locator('[data-aktion="freigeben"]').click()
const box = portal.locator(`[data-freigegeben="${merk.eva}"]`)
await box.waitFor()
merk.evaLink = await box.locator('[data-link]').inputValue()
ok(new RegExp(`^${srv.portalUrl}/unterkunft/${SITE_KEY}/buchung/${merk.eva}\\?t=`).test(merk.evaLink), `Buchungslink sichtbar (${merk.evaLink.slice(0, 80)}…)`)
await shot(portal, 'anfrage-freigegeben-link')
const a = srv.anfrage(merk.eva)
ok(a.status === 'freigegeben', `Status freigegeben (${a.status})`)
ok(a.summen?.bruttoCent === 55400 && a.posten.some((p) => /Reinigung/i.test(p.text) && p.einzelCent === 5000), `geänderter Posten gespeichert (brutto ${a.summen?.bruttoCent})`)
await srv.warteAufVersand()
ok(srv.gastArten().includes('G17'), `G17 eingereiht (${srv.gastArten().join()})`)
const g17 = srv.mailsMit().find((m) => m.to.some((x) => x.address === EVA.email) && String(m.text).includes(merk.evaLink))
ok(Boolean(g17), 'G17 mit demselben Buchungslink liegt in der Mail-Senke')
ok(Boolean(g17) && /Gern auch mit Hund/.test(g17.text), 'G17 trägt die Nachricht des Vermieters')
})
// ------------------------------------------------------------ 4
await lauf('4_buchungsseite_vor_ort_buchen_rechnung', async () => {
const ctxG = await neuerKontext(browser, { viewport: DESKTOP })
const g = await ctxG.newPage()
await g.goto(merk.evaLink)
await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung bestätigen' }).waitFor()
const pflicht = g.locator('[data-tag="pflichtangaben"]')
const ptext = await pflicht.innerText()
for (const teil of ['Anbieter: Donau Home', 'Donau Home, ', 'Personen: 2', `Gesamtpreis: ${eurotext(55400)}`, 'Zahlarten:', 'Stornobedingungen: 1. Stornierung durch den Gast', 'Ein Widerrufsrecht besteht nicht', 'Datenschutz:', 'Reserviert bis']) {
ok(enthaelt(ptext, teil), `Pflichtangaben enthalten „${teil}“`)
}
const knopf = g.locator('form button[type="submit"]')
ok(enthaelt(await knopf.innerText(), 'Zahlungspflichtig buchen'), 'Knopf heißt „Zahlungspflichtig buchen“')
const yPflicht = (await pflicht.boundingBox()).y
const yKnopf = (await knopf.boundingBox()).y
ok(yPflicht < yKnopf, `Pflichtangaben stehen über dem Knopf (${Math.round(yPflicht)} < ${Math.round(yKnopf)})`)
ok(await g.locator('input[name="zahlart"]').count() === 2, 'zwei Zahlarten (vor Ort, online) wählbar')
// Pflichtfelder: ohne Adresse kommt ein Hinweis, nichts wird gebucht
await knopf.click()
await g.locator('.uk-fehler:visible').waitFor()
ok(srv.anfrage(merk.eva).status === 'freigegeben', 'ohne Rechnungsadresse wird nicht gebucht')
await shotBeide(g, 'buchungsseite-pflichtfehler')
await g.locator('input[name="strasse"]').fill('Gästeweg 1')
await g.locator('input[name="plz"]').fill('10115')
await g.locator('input[name="ort"]').fill('Berlin')
await g.locator('input[name="zahlart"][value="vorort"]').check()
await shotBeide(g, 'buchungsseite-ausgefuellt')
await knopf.click()
await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung ist bestätigt' }).waitFor()
const btext = await g.locator('.uk-karte').innerText()
ok(enthaelt(btext, 'Die Zahlung erfolgt vor Ort.'), 'Bestätigung: „Die Zahlung erfolgt vor Ort.“')
ok(/Rechnung [A-Z0-9]+-\d{4}-\d{4}/.test(norm(btext)), `Bestätigung nennt die Rechnungsnummer (${norm(btext).match(/Rechnung [^ ]+/)?.[0]})`)
await shotBeide(g, 'buchung-bestaetigt')
const a = srv.anfrage(merk.eva)
ok(a.status === 'gebucht' && a.zahlart === 'vorort' && a.rechnungId, `Status gebucht, Zahlart vor Ort, Rechnung ${a.rechnungId}`)
// Rechnungs-PDF aus dem Browser abrufen
const href = await g.locator('a[data-tag="rechnung"]').getAttribute('href')
const pdf = await g.evaluate(async (h) => {
const r = await fetch(h)
const b = new Uint8Array(await r.arrayBuffer())
return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)), laenge: b.length }
}, href)
ok(pdf.status === 200 && /^application\/pdf/.test(pdf.typ) && pdf.kopf.startsWith('%PDF') && pdf.laenge > 800, `Rechnungs-PDF abrufbar (${pdf.status} ${pdf.typ} ${pdf.kopf.trim()} ${pdf.laenge} Byte)`)
await ctxG.close()
await srv.warteAufVersand()
ok(srv.gastArten().includes('G19'), `G19 eingereiht (${srv.gastArten().join()})`)
const g19 = srv.mailsMit().find((m) => m.to.some((x) => x.address === EVA.email) && (m.attachments || []).some((x) => /^Rechnung-.*\.pdf$/.test(x.filename)))
ok(Boolean(g19), 'G19 mit Rechnungs-PDF im Anhang liegt in der Mail-Senke')
ok(Boolean(g19) && Buffer.from(g19.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF', 'Anhang der G19 ist ein PDF')
ok(srv.vermieterArten().filter((x) => x === 'K32').length >= 1, `K32 an den Vermieter (${srv.vermieterArten().join()})`)
ok(srv.rechnungen().length === 1 && srv.rechnungen()[0].art === 'rechnung', 'eine Rechnung in ukRechnungen')
})
// ------------------------------------------------------------ 5
await lauf('5_portal_als_bezahlt_vor_ort', async () => {
await oeffnePortal(portal)
await reiter(portal, 'anfragen')
await portal.locator('#bk-a-filter').selectOption('gebucht')
await portal.locator(`[data-anfrage="${merk.eva}"]`).click()
const detail = portal.locator(`[data-detail="${merk.eva}"]`)
await detail.waitFor()
await detail.locator('[data-status="gebucht"]').waitFor()
ok(await detail.locator('[data-aktion="rechnung"]').isVisible(), 'Rechnungs-Link im Detail')
const pdf = await portal.evaluate(async (h) => {
const r = await fetch(h)
const b = new Uint8Array(await r.arrayBuffer())
return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)) }
}, await detail.locator('[data-aktion="rechnung"]').getAttribute('href'))
ok(pdf.status === 200 && /^application\/pdf/.test(pdf.typ) && pdf.kopf.startsWith('%PDF'), `Rechnungs-PDF im Portal abrufbar (${pdf.status} ${pdf.typ})`)
await shot(portal, 'anfrage-gebucht')
await detail.locator('[data-aktion="bezahlt"]').click()
await portal.locator(`[data-detail="${merk.eva}"] [data-status="bezahlt"]`).waitFor()
const a = srv.anfrage(merk.eva)
ok(a.status === 'bezahlt' && a.bezahltVia === 'vorort', `Status bezahlt, via vor Ort (${a.status}/${a.bezahltVia})`)
await shot(portal, 'anfrage-bezahlt')
await srv.warteAufVersand()
ok(!srv.gastArten().includes('G20'), `vor Ort bezahlt: keine G20 (die Zahlungsbestaetigung gibt es nur bei Stripe) (${srv.gastArten().join()})`)
})
// ------------------------------------------------------------ 6
await lauf('6_zweite_anfrage_online_bezahlen', async () => {
await oeffnePortal(portal)
await reiter(portal, 'anfragen')
await portal.locator(`[data-anfrage="${merk.paul}"]`).click()
await portal.locator('.bk-freigabe').waitFor()
ok(enthaelt(await portal.locator('[data-summen]').innerText(), eurotext(30300)), `Vorschlag Paul: ${eurotext(30300)} (3 × 90 + 60 − 27)`)
await portal.locator('[data-aktion="freigeben"]').click()
const box = portal.locator(`[data-freigegeben="${merk.paul}"]`)
await box.waitFor()
merk.paulLink = await box.locator('[data-link]').inputValue()
await shot(portal, 'anfrage2-freigegeben')
await srv.warteAufVersand()
ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === PAUL.email) && String(m.text).includes(merk.paulLink)), 'G17 an Paul mit demselben Link')
// Gast am Telefon: online bezahlen
const ctxG = await neuerKontext(browser, { viewport: PHONE })
const g = await ctxG.newPage()
await g.goto(merk.paulLink)
await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung bestätigen' }).waitFor()
ok(await g.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), '390 px: Buchungsseite ohne waagerechtes Scrollen')
await g.locator('input[name="strasse"]').fill('Weg 7')
await g.locator('input[name="plz"]').fill('80331')
await g.locator('input[name="ort"]').fill('München')
await g.locator('input[name="zahlart"][value="online"]').check()
ok(enthaelt(await g.locator('.uk-zahlarten').innerText(), 'gewählt: online'), 'Pflichtangaben nennen die gewählte Zahlart online')
await shot(g, 'buchungsseite-online-ausgefuellt')
await g.locator('form button[type="submit"]').click()
await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Weiterleitung zur Stripe-Kasse')
await g.waitForURL(/checkout\.stripe\.test/)
await shot(g, 'stripe-kasse-nachbau')
const sitzung = srv.kassenSitzungen().at(-1)
ok(Boolean(sitzung) && sitzung.metadata?.wkUk === '1' && sitzung.metadata?.anfrageId === merk.paul, `Kassen-Sitzung mit Metadaten wkUk/anfrageId (${sitzung?.id})`)
ok(srv.anfrage(merk.paul).status === 'gebucht' && srv.anfrage(merk.paul).zahlart === 'online', 'Status gebucht, Zahlart online (noch unbezahlt)')
// Der Gast zahlt bei Stripe: Webhook (signiert), dann Rückkehr auf die Buchungsseite
const bezahlt = srv.bezahleKasse(sitzung.id)
const hook = await srv.sendeConnect('checkout.session.completed', bezahlt)
ok(hook.status === 200 || hook.status === 204, `Webhook checkout.session.completed beantwortet (${hook.status})`)
await warteBis(() => srv.anfrage(merk.paul).status === 'bezahlt', 'Status bezahlt nach dem Webhook')
ok(srv.anfrage(merk.paul).bezahltVia === 'stripe' && Boolean(srv.anfrage(merk.paul).stripePaymentIntentId), 'bezahlt via Stripe mit Zahlungs-ID')
await g.goto(sitzung.success_url)
await g.locator('.uk-karte').getByText('Bezahlt – vielen Dank.').waitFor()
await shot(g, 'buchung-online-bezahlt')
await ctxG.close()
await srv.warteAufVersand()
ok(srv.gastArten().filter((x) => x === 'G20').length === 1, `G20 nach der Online-Zahlung (${srv.gastArten().join()})`)
ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === PAUL.email) && /bezahlt|Zahlung/i.test(m.subject) && !(m.attachments || []).length), `G20 liegt in der Mail-Senke (${srv.mailsMit().filter((m) => m.to.some((x) => x.address === PAUL.email)).map((m) => m.subject).join(' / ')})`)
})
// ------------------------------------------------------------ 7
await lauf('7_kalender_und_ical', async () => {
// eine reservierte Anfrage (Dienstaufruf), eine Sperre per Klick im Kalender
await srv.reserviere({ unterkunftId: merk.unterkunftId, ...RESERVIERT })
await oeffnePortal(portal)
await reiter(portal, 'kalender')
await portal.locator('.bk-raster [data-tag]').first().waitFor()
await portal.locator('[data-aktion="weiter"]').click()
await portal.locator(`.bk-raster [data-tag="${tag(10)}"].ist-gebucht`).waitFor()
const klasse = (t) => portal.locator(`.bk-raster [data-tag="${t}"]`).getAttribute('class')
ok((await klasse(tag(10))).includes('ist-gebucht') && (await klasse(tag(16))).includes('ist-gebucht'), 'Eva (10.–17.) farbig als gebucht')
ok(!(await klasse(tag(17))).includes('ist-gebucht'), 'Abreisetag (17.) ist frei')
ok((await klasse(tag(20))).includes('ist-gebucht') && (await klasse(tag(22))).includes('ist-gebucht'), 'Paul (20.–23.) farbig als gebucht')
ok((await klasse(tag(2))).includes('ist-reserviert') && (await klasse(tag(3))).includes('ist-reserviert'), 'Reservierung (2.–4.) farbig als reserviert')
await portal.locator(`.bk-raster [data-tag="${SPERRE.von}"]`).click()
await portal.locator(`.bk-raster [data-tag="${SPERRE.von}"]`).and(portal.locator('[aria-pressed="true"]')).waitFor()
await portal.locator(`.bk-raster [data-tag="${tag(26)}"]`).click() // zweiter Klick auf denselben Tag: Zeitraum = 1 Nacht
await portal.locator('#bk-k-notiz').fill('Eigener Aufenthalt')
await portal.locator('[data-aktion="sperren-senden"]').click()
await portal.locator(`.bk-raster [data-tag="${tag(26)}"].ist-manuell`).waitFor()
ok(srv.sperren().length === 1 && srv.sperren()[0].von === SPERRE.von && srv.sperren()[0].bis === SPERRE.bis, `Sperre ${SPERRE.von} gespeichert (${srv.sperren().map((s) => `${s.von}…${s.bis}`)})`)
const farben = await portal.evaluate((tage) => tage.map((t) => getComputedStyle(document.querySelector(`.bk-raster [data-tag="${t}"]`)).backgroundColor), [tag(10), tag(2), tag(26), tag(5)])
ok(new Set(farben).size === 4, `gebucht, reserviert, gesperrt und frei haben vier verschiedene Farben (${farben.join(' | ')})`)
await shot(portal, 'kalender-farbig')
await portal.setViewportSize(PHONE)
await shot(portal, 'kalender-farbig')
await portal.setViewportSize(DESKTOP)
// iCal-Export: Link in der Unterkunft, Abruf liefert text/calendar mit den Buchungen
await reiter(portal, 'unterkuenfte')
await portal.locator('[data-aktion="bearbeiten"]').click()
await portal.locator('.bk-preise form').waitFor()
await shot(portal, 'unterkunft-ical-export')
const liste = await portal.evaluate(async () => (await (await fetch('/api/unterkunft/unterkuenfte?projectId=p1')).json()).unterkuenfte)
merk.icalUrl = liste[0].icalExportUrl
ok(/\/unterkunft\/ical\/[A-Za-z0-9_-]{20,64}\.ics$/.test(merk.icalUrl || ''), `iCal-Export-Link vorhanden (${merk.icalUrl})`)
const ics = await holeIcs()
ok(ics.status === 200 && /^text\/calendar/.test(ics.typ), `iCal-Abruf: ${ics.status} ${ics.typ}`)
ok(ics.text.startsWith('BEGIN:VCALENDAR') && ics.text.includes('END:VCALENDAR'), 'iCal ist ein VCALENDAR')
for (const [name, von] of [['Eva', EVA.von], ['Paul', PAUL.von], ['Reservierung', RESERVIERT.von], ['Sperre', SPERRE.von]]) {
ok(ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(von)}`), `iCal enthält ${name} (${von})`)
}
ok((ics.text.match(/BEGIN:VEVENT/g) || []).length === 4, `iCal: vier Ereignisse (${(ics.text.match(/BEGIN:VEVENT/g) || []).length})`)
merk.icsVorher = ics.text
})
// ------------------------------------------------------------ 8
await lauf('8_stornieren_mit_teilerstattung', async () => {
await oeffnePortal(portal)
await reiter(portal, 'anfragen')
await portal.locator('#bk-a-filter').selectOption('gebucht')
await portal.locator(`[data-anfrage="${merk.paul}"]`).click()
const detail = portal.locator(`[data-detail="${merk.paul}"]`)
await detail.waitFor()
await detail.locator('[data-status="bezahlt"]').waitFor()
await detail.locator('[data-aktion="stornieren"]').click()
await portal.locator('#bk-a-grund').fill('Wasserschaden im Bad, die Wohnung ist nicht nutzbar.')
ok((await portal.locator('#bk-a-erstattung').inputValue()) === '303,00', `Erstattung ist mit dem Gesamtbetrag vorbelegt (${await portal.locator('#bk-a-erstattung').inputValue()})`)
await portal.locator('#bk-a-erstattung').fill('150,00')
await shot(portal, 'storno-dialog')
await portal.locator('[data-aktion="storno-senden"]').click()
await portal.locator(`[data-detail="${merk.paul}"] [data-status="storniert"]`).waitFor()
await shot(portal, 'storno-fertig')
const a = srv.anfrage(merk.paul)
ok(a.status === 'storniert' && a.erstattetCent === 15000, `Status storniert, erstattet ${a.erstattetCent} Cent`)
const ers = srv.erstattungen()
ok(ers.length === 1 && ers[0].amount === 15000 && ers[0].payment_intent === a.stripePaymentIntentId, `Erstattung im Stripe-Nachbau: ${ers.map((r) => `${r.id} ${r.amount} ct auf ${r.payment_intent}`).join(', ')}`)
ok(srv.rechnungen().some((r) => r.art === 'storno' && r.anfrageId === merk.paul), 'Storno-Rechnung angelegt')
await srv.warteAufVersand()
ok(srv.gastArten().includes('G22'), `G22 eingereiht (${srv.gastArten().join()})`)
const g22 = srv.mailsMit().find((m) => m.to.some((x) => x.address === PAUL.email) && (m.attachments || []).some((x) => /^Stornorechnung-.*\.pdf$/.test(x.filename)))
ok(Boolean(g22), 'G22 mit Storno-Rechnung als PDF im Anhang')
ok(Boolean(g22) && g22.text.includes('Eine Erstattung von 150,00 € wird veranlasst.') && g22.text.includes('Wasserschaden'), 'G22 nennt die Erstattung (150,00 €) und den Grund')
ok(Boolean(g22) && Buffer.from(g22.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF', 'Anhang der G22 ist ein PDF')
// Rechnungen-Reiter: Rechnung Eva, Rechnung Paul, Storno Paul
await reiter(portal, 'rechnungen')
await portal.locator('[data-rechnung]').first().waitFor()
const arten = await portal.locator('[data-rechnung]').evaluateAll((els) => els.map((e) => e.dataset.art))
ok(arten.length === 3 && arten.filter((x) => x === 'storno').length === 1, `Rechnungen: ${arten.join(', ')}`)
const stornoPdf = await portal.evaluate(async (h) => {
const r = await fetch(h)
const b = new Uint8Array(await r.arrayBuffer())
return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)) }
}, await portal.locator('[data-art="storno"] a[data-aktion="pdf"]').getAttribute('href'))
ok(stornoPdf.status === 200 && stornoPdf.kopf.startsWith('%PDF'), `Storno-Rechnung im Portal abrufbar (${stornoPdf.status} ${stornoPdf.typ})`)
await shot(portal, 'rechnungen')
// iCal: die stornierte Buchung ist weg, die anderen bleiben
const ics = await holeIcs()
ok(!ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(PAUL.von)}`), 'iCal enthält die stornierte Buchung nicht mehr')
ok(ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(EVA.von)}`) && (ics.text.match(/BEGIN:VEVENT/g) || []).length === 3, 'iCal enthält Eva, Reservierung und Sperre weiter')
// Kalender: Pauls Tage sind wieder frei
await reiter(portal, 'kalender')
await portal.locator('[data-aktion="weiter"]').click()
await portal.locator(`.bk-raster [data-tag="${tag(10)}"].ist-gebucht`).waitFor()
ok((await portal.locator(`.bk-raster [data-tag="${tag(21)}"]`).getAttribute('class')).includes('ist-frei'), 'Kalender: Pauls Tage sind nach dem Storno wieder frei')
await shot(portal, 'kalender-nach-storno')
})
await portalCtx.close()
}
/** iCal-Export abrufen (wie ein Kalenderdienst: ohne Browser, ohne Cookie). */
async function holeIcs() {
const antwort = await fetch(`${srv.url}${new URL(merk.icalUrl).pathname}`)
return { status: antwort.status, typ: antwort.headers.get('content-type') || '', text: await antwort.text() }
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch({ executablePath: CHROME })
await szenario()
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; ${nummer} Screenshots in ${SHOTS}`)
for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`)
process.exit(abbruch || gescheitert.length ? 1 : 0)
+230
View File
@@ -0,0 +1,230 @@
// UI-Testserver des Buchungskalenders (fuer test/ui/unterkunft.ui.mjs): die echten Express-Router (/api/public/unterkunft/v1,
// /api/shop/stripe-webhook (leitet Buchungskalender-Ereignisse weiter), /api/unterkunft, /api/funktionen), die Seiten /unterkunft/... samt
// iCal-Export und public/ als statische Dateien, dahinter der Nachbau von Appwrite und Stripe (Connect) aus test/support/ und eine Mail-Senke.
// Nichts verlaesst den Rechner (useTestEnv erlaubt nur 127.0.0.1). Muster: test/ui/shopTestServer.mjs.
//
// Aufruf in einem Skript (KEINE Server-Module vorher importieren, config.js liest die Umgebung beim ersten Import):
// import { startUnterkunftUiServer } from './unterkunftTestServer.mjs'
// const srv = await startUnterkunftUiServer()
// ... 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
//
// Adressen: Der Server lauscht auf srv.url (http://127.0.0.1:PORT). Das Portal heisst fuer Server und Browser srv.portalUrl
// (https://project.webklar.test = config.publicBaseUrl); das Skript leitet diese Adresse im Browser per page.route an srv.url um.
// Die Website des Vermieters heisst srv.kundenUrl (liveDomain des Projekts); ihre Seiten liefert das Skript per page.route.
// Uhr: echte Uhr (Server und Browser).
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.donau-home.example'
export const KUNDE = 'c1'
export const PROJEKT = 'p1'
export const FEATURE = 'f_p1_unterkunft'
export const SITE_KEY = 'donauhomeukwxyz2'
export const CONNECT_SECRET = 'whsec_connect_ui_test'
export const KUNDEN_EMAIL = 'mimi@donau-home.example'
export const KUNDEN_NAME = 'Mimi Donau'
const SAMMLUNGEN = [
'portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen',
'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'shopRechtstexteArchiv', 'termineOutbox', 'featureConsents', 'subscriptions',
]
export async function startUnterkunftUiServer() {
const { UNTERKUNFT_DATETIMES } = await import('../../server/services/unterkunft/schema.js')
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: Object.fromEntries(SAMMLUNGEN.map((n) => [n, schemaKeys(n)])),
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES, ...UNTERKUNFT_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../../server/config.js')
const { startTestApp, postWebhook, sessionCookie } = await import('../support/testApp.js')
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
const { default: publicRoutes, setzeUhrFuerTests } = await import('../../server/routes/publicUnterkunft.js')
const { default: unterkunftRoutes, resetUnterkunftRoutesForTests } = await import('../../server/routes/unterkunft.js')
const { default: shopWebhookRoutes } = await import('../../server/routes/shopWebhook.js')
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
const { unterkunftSeiteRouter, resetUnterkunftSeiteForTests } = await import('../../server/routes/unterkunftSeite.js')
const { legalRouter } = await import('../../server/services/legalDocs.js')
const { leereCache } = await import('../../server/services/unterkunft/kontext.js')
const mails = await import('../../server/services/unterkunft/mails.js')
const zahlung = await import('../../server/services/unterkunft/zahlung.js')
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
// Reihenfolge wie server/index.js: /embed (CORS), /rechtliches, Buchungskalender-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(unterkunftSeiteRouter())
statisch.use(express.static(PUBLIC_DIR))
const app = await startTestApp({
'/api/public/unterkunft/v1': publicRoutes,
'/api/shop/stripe-webhook': shopWebhookRoutes,
'/api/unterkunft': unterkunftRoutes,
'/api/funktionen': funktionenRoutes,
'/': statisch,
})
const uploadDir = await mkdtemp(path.join(tmpdir(), 'unterkunft-ui-'))
const vorher = {
unterkunft: { ...config.unterkunft },
shopMode: config.shop.mode,
shopPilot: config.shop.pilotEmails,
connectWebhookSecret: config.shop.connectWebhookSecret,
uploadDir: config.shop.uploadDir,
publicBaseUrl: config.publicBaseUrl,
smtpFrom: config.smtp.from,
}
const col = (name) => config.collections[name]
const json = (name, wert) => (typeof wert === 'string' ? JSON.parse(wert) : wert)
const srv = {
url: app.url,
portalUrl: PORTAL_URL,
kundenUrl: KUNDEN_URL,
KUNDE, PROJEKT, FEATURE, SITE_KEY,
aw, st, config, mails, zahlung,
sink: null,
warteAufVersand,
/** Cookie des Portal-Kunden (c1) fuer context.addCookies([...]): { name, value, url }. */
async portalCookie() {
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 (JSON-Felder geparst)
einstellungen() { return [...aw.docs('ukEinstellungen').values()][0] ?? null },
unterkuenfte() { return [...aw.docs('ukUnterkuenfte').values()] },
preise() { return [...aw.docs('ukPreise').values()].map((d) => ({ ...d, staffeln: json('staffeln', d.staffeln) })) },
sperren() { return [...aw.docs('ukSperren').values()] },
anfragen() {
return [...aw.docs('ukAnfragen').values()].map((d) => ({
...d, gast: json('gast', d.gast), rechnungsadresse: json('r', d.rechnungsadresse), posten: json('p', d.posten), summen: json('s', d.summen),
}))
},
anfrage(id) { return srv.anfragen().find((a) => a.$id === id) ?? null },
rechnungen() { return [...aw.docs('ukRechnungen').values()].map((d) => ({ ...d, inhalt: json('i', d.inhalt) })) },
outbox() { return [...aw.docs('termineOutbox').values()] },
/** Mails der Senke; mit Teiltext des Betreffs filtern. Vorher warteAufVersand() (die Warteschlange sendet im Hintergrund). */
mailsMit(betreff) { return srv.sink.sent.filter((m) => !betreff || String(m.subject).includes(betreff)) },
/** Arten der bisher eingereihten Gastmails (G16 ...) in Reihenfolge. */
gastArten() { return mails._aufrufe.filter((a) => a.an === 'gast').map((a) => a.art) },
vermieterArten() { return mails._aufrufe.filter((a) => a.an === 'vermieter').map((a) => a.template) },
// ---------------------------------------------------------------- Stripe (Haendlerkonto des Vermieters)
kontoId() { return srv.einstellungen()?.stripeAccountId ?? null },
/** Das Konto nimmt Zahlungen an (wie nach dem Onboarding bei Stripe). */
kontoFreigeben(patch = {}) {
const id = srv.kontoId()
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 })
},
kassenSitzungen() { return [...st.accountSessions(srv.kontoId()).values()] },
erstattungen() { return [...st.refunds(srv.kontoId()).values()] },
/** Sitzung des Gastes als bezahlt abschliessen (Stripe-Nachbau); liefert die Sitzung, wie sie der Webhook traegt. */
bezahleKasse(sessionId, { zahlung: pi = 'pi_uk_ui_1', betrag } = {}) {
const acct = srv.kontoId()
const sitzung = st.accountSessions(acct).get(sessionId)
if (!sitzung) throw new Error(`keine Sitzung ${sessionId}`)
const summe = betrag ?? sitzung.line_items?.[0]?.price_data?.unit_amount ?? srv.anfragen().find((a) => a.stripeSessionId === sessionId)?.summen?.bruttoCent ?? null
st.setPaymentIntent(acct, pi, { amount: summe })
return st.completeSession(acct, sessionId, { payment_status: 'paid', amount_total: summe, payment_intent: pi, 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() },
/** Eine freigegebene (reservierte, noch nicht gebuchte) Anfrage ueber die Dienste anlegen (Kalenderfarbe „reserviert“); liefert { id, token }. */
async reserviere({ unterkunftId, von, bis, personen = 2, name = 'Rita Reserviert', email = 'rita@example.com' }) {
const ab = await import('../../server/services/unterkunft/ablauf.js')
const { unterkunftPerSiteKey } = await import('../../server/services/unterkunft/kontext.js')
const ctx = await unterkunftPerSiteKey(SITE_KEY)
const a = await ab.legeAnfrageAn(ctx, { unterkunftId, von, bis, personen, gast: { name, firma: '', email, telefon: '', nachricht: '' } }, { now: new Date(), ipHash: 'ui' })
const { token } = await ab.gibFrei(ctx, a.$id, { now: new Date() })
return { id: a.$id, token }
},
/** Alles auf Anfang: Dokumente loeschen, Zwischenspeicher leeren, neue Senke; Kunde c1, Projekt p1, Funktion 'unterkunft' aktiv, Schalter on. */
async zuruecksetzen() {
await warteAufVersand()
for (const sammlung of [...SAMMLUNGEN, 'customers', 'websiteProjects', 'workorders']) {
for (const id of [...aw.docs(sammlung).keys()]) await deleteDocument(col(sammlung) ?? sammlung, id)
}
st.checkoutSessions.clear()
st.invoices.clear()
st.calls.length = 0
leereCache()
resetOutboxForTests()
resetTermineLimitsForTests()
resetUnterkunftRoutesForTests()
resetUnterkunftSeiteForTests()
zahlung.leereAbgleichFuerTests()
mails._aufrufe.length = 0
setzeUhrFuerTests(() => new Date())
Object.assign(config.unterkunft, { mode: 'on', minAntwortMs: 0, formMinSekunden: 0 })
config.shop.mode = 'pilot'
config.shop.pilotEmails = ['*']
config.shop.connectWebhookSecret = CONNECT_SECRET
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: 'Donau Home', subdomain: 'donau-home', liveDomain: new URL(KUNDEN_URL).host,
})
aw.seed('customers', { $id: KUNDE, appwriteUserId: `u_${KUNDE}`, name: KUNDEN_NAME, email: KUNDEN_EMAIL, status: 'active', portalAccessEnabled: true })
aw.seed('portalFeatures', {
$id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'unterkunft', siteKey: SITE_KEY, state: 'active', enabled: true,
})
},
/** Beendet Server und Nachbauten (zuerst den Browser schliessen) und stellt config wieder her. */
async stop() {
await warteAufVersand().catch(() => {})
setMailTransportForTests(null)
Object.assign(config.unterkunft, vorher.unterkunft)
Object.assign(config.shop, { mode: vorher.shopMode, pilotEmails: vorher.shopPilot, connectWebhookSecret: vorher.connectWebhookSecret, 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 })
},
}
await srv.zuruecksetzen()
return srv
}