diff --git a/test/ui/unterkunft.ui.mjs b/test/ui/unterkunft.ui.mjs
new file mode 100644
index 0000000..6ec9459
--- /dev/null
+++ b/test/ui/unterkunft.ui.mjs
@@ -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 = () => `
Donau Home
+
+Donau Home – Monteurwohnung Stetten (Testseite des Vermieters)Jetzt anfragen
+
+`
+
+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: `Stripe (Nachbau)Onboarding
` })
+ }
+ stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url })
+ return route.fulfill({ status: 200, contentType: 'text/html', body: 'Stripe-Kasse (Nachbau)Stripe-Kasse (Nachbau)
' })
+ })
+ ctx.on('page', (seite) => {
+ offeneSeiten.add(seite)
+ seite.on('close', () => offeneSeiten.delete(seite))
+ seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`))
+ seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
+ seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } })
+ seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) })
+ })
+ return ctx
+}
+
+let browser = null
+async function lauf(name, fn) {
+ if (process.env.NUR && !name.includes(process.env.NUR)) return
+ schritt = { name, fehler: [], hinweise: [] }
+ console.log(`--- ${name}`)
+ try {
+ await fn()
+ } catch (e) {
+ schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`)
+ if (process.env.STACK) console.log(e.stack)
+ }
+ if (schritt.fehler.length) {
+ let i = 0
+ for (const seite of offeneSeiten) {
+ i += 1
+ await seite.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)
diff --git a/test/ui/unterkunftTestServer.mjs b/test/ui/unterkunftTestServer.mjs
new file mode 100644
index 0000000..b7e38fb
--- /dev/null
+++ b/test/ui/unterkunftTestServer.mjs
@@ -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
+}