Files

702 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 vorher = page.viewportSize()
const breite = vorher.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)
// wie test/ui/shop.ui.mjs: Fenster auf Seitenhoehe stellen statt fullPage (die Portalansicht blendet sonst leer ein)
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600))
await page.setViewportSize({ width: breite, height: hoehe })
await page.waitForTimeout(100)
const datei = `${SHOTS}/${String(nummer).padStart(2, '0')}-${name}-${breite}.png`
await page.screenshot({ path: datei })
await page.setViewportSize(vorher)
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
/** Sichtbarkeit wirklich pruefen (nicht nur DOM): sichtbar, Hoehe > 0, nicht durchsichtig (Einblend-Animation fertig). */
async function sichtbar(locator, name) {
await warteBis(async () => {
if (!(await locator.first().isVisible().catch(() => false))) return false
const box = await locator.first().boundingBox()
const deckend = await locator.first().evaluate((el) => { let o = 1; for (let e = el; e; e = e.parentElement) o *= Number(getComputedStyle(e).opacity); return o > 0.99 })
return Boolean(box) && box.height > 0 && box.width > 0 && deckend
}, `${name} sichtbar`, 5000).catch(() => ok(false, `${name} ist nicht sichtbar (Hoehe 0, hidden oder durchsichtig)`))
}
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()
await sichtbar(page.locator('#view-buchungen'), 'Ansicht Buchungskalender')
await sichtbar(page.locator('#view-buchungen h1'), 'Titel Buchungskalender')
await sichtbar(page.locator('[data-reiter="anfragen"]'), 'Reiter Anfragen')
}
const reiter = async (page, key) => {
await page.locator(`[data-reiter="${key}"]`).click()
await page.locator(`#bk-tab-${key}[aria-selected="true"]`).waitFor()
await sichtbar(page.locator('#bk-panel > *'), `Inhalt des Reiters ${key}`)
}
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()
const muster = await portal.locator('#bk-p-storno').inputValue()
ok(muster.startsWith('1. Stornierung durch den Gast.'), 'Mustertext in die Stornobedingungen übernommen')
// mit den Platzhaltern wird nicht gespeichert
await portal.locator('[data-aktion="preise-speichern"]').click()
await warteBis(async () => enthaelt(await portal.locator('[data-fehler="stornobedingungen"]').innerText(), 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'), 'Platzhalter-Fehler am Feld')
ok(srv.preise().length === 0, 'Preise mit Platzhaltern nicht gespeichert')
await shot(portal, 'preise-platzhalter-fehler')
// Platzhalter durch Werte ersetzen
await portal.locator('#bk-p-storno').fill(muster.replace('[E-Mail-Adresse des Vermieters]', 'buchung@donau-home.example').replace(/\[(\d+)\]/g, '$1'))
ok(!/\[[^\]]*\]/.test(await portal.locator('#bk-p-storno').inputValue()), 'keine eckigen Klammern mehr in den Stornobedingungen')
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('K36'), `K36 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 === 'K37').length >= 1, `K37 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)