702 lines
45 KiB
JavaScript
702 lines
45 KiB
JavaScript
// 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('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)
|