Buchungskalender: Strang widget zusammenführen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:50:03 +00:00
commit cebde0e070
10 files changed
+1380 -2

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, statSync } from 'node:fs'
import vm from 'node:vm'
// Statische Pruefung des Einbau-Skripts public/embed/unterkunft.js (Task 16; Muster test/shopEmbedStatic.test.js).
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('gueltiges JavaScript, unter 40 KB, IIFE mit use strict', () => {
assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'unterkunft.js' }))
assert.ok(statSync(new URL('../public/embed/unterkunft.js', import.meta.url)).size < 40 * 1024)
assert.match(OHNE_KOMMENTARE, /^\s*\(function \(\) \{\s*'use strict'/)
})
test('kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function', () => {
assert.doesNotMatch(OHNE_KOMMENTARE, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(|new Function/)
})
test('API-Pfad, Praefix .wku-, @layer wku, Style-Id, kein Storage, keine Cookies, keine Fremdadressen', () => {
assert.ok(OHNE_KOMMENTARE.includes('/api/public/unterkunft/v1/'))
assert.ok(OHNE_KOMMENTARE.includes('@layer wku'))
assert.ok(OHNE_KOMMENTARE.includes('wku-style'))
assert.match(OHNE_KOMMENTARE, /\.wku-root/)
assert.doesNotMatch(OHNE_KOMMENTARE, /localStorage|sessionStorage|document\.cookie|https?:\/\//)
})
test('Pflichttexte und Honeypot', () => {
for (const t of ['Anfragen gerne per Telefon oder E-Mail.', 'Bitte wählen Sie Anreise und Abreise im Kalender.', 'Anfrage senden', 'kontakt_url', 'formToken']) assert.ok(QUELLE.includes(t), t)
assert.match(QUELLE, /Danke! Ihre Anfrage/)
})
+88
View File
@@ -0,0 +1,88 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
// Reine Kalenderlogik des Widgets public/embed/unterkunft.js (Task 16): Datei per node:vm laden, data-test="1" macht WKU_LOGIK als __wkuLogik sichtbar.
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
function lade() {
const doc = { readyState: 'complete', currentScript: { dataset: { test: '1' } }, querySelectorAll: () => [], addEventListener() {} }
const ctx = vm.createContext({ document: doc, console })
vm.runInContext(QUELLE, ctx, { filename: 'unterkunft.js' })
return ctx.__wkuLogik
}
const L = lade()
test('ohne data-test bleibt die Logik verborgen', () => {
const doc = { readyState: 'complete', currentScript: { dataset: {} }, querySelectorAll: () => [], addEventListener() {} }
const ctx = vm.createContext({ document: doc })
vm.runInContext(QUELLE, ctx)
assert.equal(ctx.__wkuLogik, undefined)
})
test('monatsRaster: Montag zuerst, 1.11.2026 (Sonntag) hat 6 Leerfelder, 30 Tage', () => {
const r = L.monatsRaster(2026, 11)
assert.equal(r.length % 7, 0)
assert.ok(r.slice(0, 6).every((z) => z.tag === null))
assert.equal(r[6].tag, '2026-11-01')
assert.equal(r.filter((z) => z.tag).length, 30)
assert.equal(L.monatsRaster(2026, 2).filter((z) => z.tag).length, 28)
assert.equal(L.monatsRaster(2028, 2).filter((z) => z.tag).length, 29)
assert.equal(L.monatsRaster(2026, 12)[1].tag, '2026-12-01') // 1.12.2026 ist Dienstag
})
test('nachtBelegt: Nacht von von (inklusive) bis bis (exklusive)', () => {
const b = [{ von: '2026-11-10', bis: '2026-11-12' }]
assert.equal(L.nachtBelegt('2026-11-09', b), false)
assert.equal(L.nachtBelegt('2026-11-10', b), true)
assert.equal(L.nachtBelegt('2026-11-11', b), true)
assert.equal(L.nachtBelegt('2026-11-12', b), false)
})
test('waehle: Anreise, Abreise, Neustart', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
let z = L.waehle({}, '2026-11-12', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-12', bis: null })
z = L.waehle(z, '2026-11-15', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-12', bis: '2026-11-15' })
z = L.waehle(z, '2026-11-14', b, 1) // dritter Klick = neue Anreise
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-14', bis: null })
z = L.waehle(z, '2026-11-10', b, 1) // Klick vor Anreise
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-10', bis: null })
})
test('waehle: belegte Nacht dazwischen -> neue Anreise; Abreise am Anreisetag eines Fremdbuchers erlaubt', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
let z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-27', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-27', bis: null })
z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-20', b, 1) // Nacht 18., 19. frei, Abreise am 20.
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-18', bis: '2026-11-20' })
z = L.waehle({ von: '2026-11-18', bis: null }, '2026-11-21', b, 1)
assert.deepEqual({ von: z.von, bis: z.bis }, { von: '2026-11-18', bis: null }) // Nacht 20. belegt, 21. selbst belegt: weder Abreise noch Anreise, Zustand bleibt
})
test('waehle: Anreise auf belegter Nacht nicht moeglich; Anreise am Abreisetag eines Fremdbuchers moeglich', () => {
const b = [{ von: '2026-11-20', bis: '2026-11-25' }]
const z1 = L.waehle({}, '2026-11-22', b, 1)
assert.equal(z1.von, null)
const z2 = L.waehle({}, '2026-11-25', b, 1)
assert.equal(z2.von, '2026-11-25')
})
test('waehle: unter den Mindestnaechten -> Hinweis-Zustand, Anreise bleibt', () => {
const z = L.waehle({ von: '2026-11-12', bis: null }, '2026-11-13', [], 3)
assert.equal(z.fehler, 'mindestnaechte')
assert.equal(z.von, '2026-11-12')
assert.equal(z.bis, null)
const ok = L.waehle({ von: '2026-11-12', bis: null }, '2026-11-15', [], 3)
assert.equal(ok.bis, '2026-11-15')
assert.equal(ok.fehler, undefined)
})
test('zusammenfassung', () => {
assert.equal(L.zusammenfassung('2026-11-12', '2026-11-30'), '12.–30. Nov. · 18 Nächte')
assert.equal(L.zusammenfassung('2026-11-28', '2026-12-03'), '28. Nov. – 3. Dez. · 5 Nächte')
assert.equal(L.zusammenfassung('2026-11-12', '2026-11-13'), '12.–13. Nov. · 1 Nacht')
assert.equal(L.zusammenfassung('2026-12-30', '2027-01-02'), '30. Dez. 2026 – 2. Jan. 2027 · 3 Nächte')
assert.equal(L.zusammenfassung('2026-03-01', '2026-03-05').slice(0, 18), '1.–5. März · 4 Näc')
})
+274
View File
@@ -0,0 +1,274 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { useTestEnv } from './support/testEnv.js'
import { installFakeDom } from './support/fakeDom.js'
// Task 17: Standalone-Seiten /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id plus Skript public/unterkunft-buchung.js
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { startTestApp } = await import('./support/testApp.js')
const { unterkunftSeiteRouter, resetUnterkunftSeiteForTests } = await import('../server/routes/unterkunftSeite.js')
resetUnterkunftSeiteForTests()
const app = await startTestApp({ '/': unterkunftSeiteRouter() })
after(() => app.close())
const KEY = 'abcdefghijklmnop'
const get = async (path) => {
const r = await fetch(app.url + path)
return { status: r.status, headers: r.headers, text: await r.text() }
}
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
/* ----------------------------------------------------------------- Routen --- */
test('Widget-Seite und Buchungsseite: 200, Schluessel escaped, Schutz-Header', async () => {
for (const [pfad, ansicht] of [[`/unterkunft/${KEY}`, 'widget'], [`/unterkunft/${KEY}/buchung/Ab_c-123?t=geheim`, 'buchung']]) {
const r = await get(pfad)
assert.equal(r.status, 200, pfad)
assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/)
assert.match(r.text, new RegExp(`<div id="uk-app" data-sitekey="${KEY}" data-ansicht="${ansicht}"`))
assert.doesNotMatch(r.text, /__SITEKEY__|__ANSICHT__|geheim/)
assert.match(r.text, /<script src="\/unterkunft-buchung\.js" defer><\/script>/)
assert.equal(r.headers.get('cache-control'), 'no-store')
assert.equal(r.headers.get('x-robots-tag'), 'noindex')
assert.equal(r.headers.get('referrer-policy'), 'no-referrer')
assert.match(r.text, /<meta name="referrer" content="no-referrer">/)
assert.equal(r.headers.get('x-content-type-options'), 'nosniff')
const csp = r.headers.get('content-security-policy')
for (const d of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'self'"]) assert.ok(csp.includes(d), d)
assert.ok(!/script-src[^;]*unsafe/.test(csp))
}
})
test('ungueltige Schluessel, ical und fremde Pfade: 404 ohne Container', async () => {
for (const p of ['/unterkunft/xyz', '/unterkunft/ical', `/unterkunft/${KEY.toUpperCase()}`, `/unterkunft/${KEY}a`, '/unterkunft/%3Cscript%3E', '/unterkunft/ical/buchung/abc', `/unterkunft/${KEY}/buchung`, `/unterkunft/${KEY}/buchung/a%20b`, `/unterkunft/${KEY}/buchung/${'a'.repeat(37)}`, `/unterkunft/${KEY}/agb`, '/unterkunft', '/unterkunft/']) {
const r = await get(p)
assert.equal(r.status, 404, p)
assert.doesNotMatch(r.text, /uk-app/, p)
}
})
test('Seitenvorlage: kein Inline-Skript, Stylesheet und Skripte vom eigenen Ursprung', () => {
const html = lies('public/unterkunft-seite.html')
assert.doesNotMatch(html, /<script(?![^>]*\bsrc=)/)
assert.match(html, /name="robots" content="noindex"/)
assert.match(html, /<script src="\/embed\/unterkunft\.js" defer>/)
})
/* ------------------------------------------------------------ Seitenskript --- */
const QUELLE = lies('public/unterkunft-buchung.js')
const BASIS = `/api/public/unterkunft/v1/${KEY}/buchung/B1`
const DATEN = {
status: 'freigegeben', nummer: 'A-1234', unterkunft: { name: 'Ferienwohnung', checkIn: '15:00', checkOut: '11:00' },
von: '2026-11-10', bis: '2026-11-14', naechte: 4, personen: 2,
posten: [{ text: 'Übernachtung, 4 Nächte', menge: 4, einzelCent: 10000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }],
summen: { bruttoCent: 45000, nettoCent: 41000, ust: [{ satz: 7, nettoCent: 37383, steuerCent: 2617 }, { satz: 19, nettoCent: 4202, steuerCent: 798 }] },
steuerart: 'regel', fristAt: '2026-10-10T12:30:00.000Z', stornobedingungen: 'Bis 14 Tage vor Anreise kostenfrei.',
anbieter: { firma: 'Haus Test GmbH', inhaber: 'Mimi', strasse: 'Weg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '07461 1', datenschutzUrl: 'https://kunde.example/datenschutz' },
gast: { name: 'Max Muster', firma: '' }, zahlarten: ['vorort', 'online'], bezahlt: false, akzentfarbe: '#0f766e',
}
function start(antworten, { pfad = `/unterkunft/${KEY}/buchung/B1`, suche = '?t=tok-123', ansicht = 'buchung' } = {}) {
const fx = installFakeDom({ antworten })
fx.window.location.pathname = pfad
fx.window.location.search = suche
fx.assigned = []
fx.window.location.assign = (url) => fx.assigned.push(url)
const el = fx.document.createElement('div')
el.id = 'uk-app'
el.setAttribute('data-sitekey', KEY)
el.setAttribute('data-ansicht', ansicht)
fx.document.body.appendChild(el)
vm.runInThisContext(QUELLE, { filename: 'unterkunft-buchung.js' })
return { fx, el }
}
const mitFx = async (antworten, opt, fn) => {
const { fx, el } = start(antworten, opt)
try { await fx.ruhe(); await fn(fx, el) } finally { fx.restore() }
}
test('Ansicht widget: Container bekommt data-webklar-unterkunft, kein Abruf', async () => {
await mitFx([], { pfad: `/unterkunft/${KEY}`, suche: '', ansicht: 'widget' }, (fx, el) => {
assert.equal(el.getAttribute('data-webklar-unterkunft'), KEY)
assert.equal(fx.fetchLog.length, 0)
})
})
test('freigegeben: Pflichtangaben in Reihenfolge direkt ueber dem Knopf, Abruf mit Token', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, (fx, el) => {
assert.equal(fx.fetchLog[0].method, 'GET')
assert.equal(fx.fetchLog[0].url, `${BASIS}?t=tok-123`)
const knopf = fx.finde(el, 'button[type="submit"]')
assert.equal(knopf.textContent, 'Zahlungspflichtig buchen')
const form = fx.finde(el, 'form')
const kinder = form.children
const block = fx.finde(el, '[data-tag="pflichtangaben"]')
assert.equal(kinder[kinder.indexOf(block) + 1], knopf, 'Block steht direkt ueber dem Knopf')
const t = fx.text(block)
const marken = ['Anbieter: Haus Test GmbH', 'Weg 1', 'Unterkunft: Ferienwohnung, 10.11.2026 bis 14.11.2026', 'Check-in ab 15:00 Uhr', 'Check-out bis 11:00 Uhr', 'Personen: 2', 'Übernachtung, 4 Nächte', 'Endreinigung', 'Gesamtpreis: 450,00', '7 % USt', 'Zahlarten:', 'Stornobedingungen: Bis 14 Tage vor Anreise kostenfrei.', 'Ein Widerrufsrecht besteht nicht (§ 312g Abs. 2 Nr. 9 BGB).', 'Datenschutz:', 'Reserviert bis 10.10.2026 14:30 Uhr']
let pos = -1
for (const m of marken) {
const i = t.indexOf(m, pos + 1)
assert.ok(i > pos, `"${m}" fehlt oder steht an falscher Stelle in: ${t}`)
pos = i
}
assert.doesNotMatch(t, /§ 19 UStG/)
assert.equal(fx.finde(el, 'a[href="https://kunde.example/datenschutz"]')?.getAttribute('rel'), 'noopener noreferrer')
assert.match(el.getAttribute('style'), /--uk-akzent:#0f766e/)
assert.equal(fx.finde(el, '[name="name"]').value, 'Max Muster')
assert.equal(fx.finde(el, '[name="land"]').value, 'Deutschland')
})
})
test('Kleinunternehmer: § 19-Hinweis statt USt-Ausweis; Option online fehlt ohne zahlarten', async () => {
const d = { ...DATEN, steuerart: 'kleinunternehmer', summen: { bruttoCent: 45000, nettoCent: 45000, ust: [] }, zahlarten: ['vorort'] }
await mitFx([{ status: 200, body: d }], undefined, (fx, el) => {
const t = fx.text(fx.finde(el, '[data-tag="pflichtangaben"]'))
assert.match(t, /Gemäß § 19 UStG wird keine Umsatzsteuer berechnet\./)
assert.doesNotMatch(t, /% USt/)
assert.equal(fx.finde(el, 'input[value="online"]'), null)
assert.ok(fx.finde(el, 'input[value="vorort"]'))
})
})
test('Pflichtfelder der Rechnungsadresse werden vor dem Senden geprueft', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog.length, 1)
assert.match(fx.text(fx.finde(el, '[data-fehler="strasse"]')), /Straße/)
assert.match(fx.text(el), /Bitte prüfen Sie Ihre Angaben\./)
})
})
async function fuelle(fx, el) {
await fx.setze(fx.finde(el, '[name="strasse"]'), 'Hauptstr. 5')
await fx.setze(fx.finde(el, '[name="plz"]'), '12345')
await fx.setze(fx.finde(el, '[name="ort"]'), 'Berlin')
}
test('Buchen vor Ort: POST mit Vertragsfeldern, Bestaetigung mit Rechnung-Link, kein Online-Knopf', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-2026-1' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'input[value="vorort"]'))
await fx.klick(fx.finde(el, 'button[type="submit"]'))
const post = fx.fetchLog[1]
assert.equal(post.method, 'POST')
assert.equal(post.url, `${BASIS}?t=tok-123`)
assert.deepEqual(post.body, { rechnungsadresse: { name: 'Max Muster', firma: '', strasse: 'Hauptstr. 5', plz: '12345', ort: 'Berlin', land: 'Deutschland' }, zahlart: 'vorort' })
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.equal(fx.finde(el, '[data-tag="rechnung"]').getAttribute('href'), `${BASIS}/rechnung.pdf?t=tok-123&art=rechnung`)
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
assert.deepEqual(fx.assigned, [])
})
})
test('Buchen online mit zahlungUrl: Weiterleitung', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-1', zahlungUrl: 'https://checkout.stripe.com/c/pay/x' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'input[value="online"]'))
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog[1].body.zahlart, 'online')
assert.deepEqual(fx.assigned, ['https://checkout.stripe.com/c/pay/x'])
})
})
test('gebucht, online unbezahlt: „Jetzt online bezahlen“ holt /zahlung und leitet weiter', async () => {
const d = { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false, rechnungNummer: 'R-1' }
await mitFx([{ status: 200, body: d }, { status: 200, body: { url: 'https://checkout.stripe.com/c/pay/y' } }], undefined, async (fx, el) => {
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.ok(fx.finde(el, '[data-tag="rechnung"]'))
await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]'))
assert.equal(fx.fetchLog[1].method, 'POST')
assert.equal(fx.fetchLog[1].url, `${BASIS}/zahlung?t=tok-123`)
assert.deepEqual(fx.assigned, ['https://checkout.stripe.com/c/pay/y'])
})
})
test('gebucht und bezahlt: kein Zahlknopf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: true } }], undefined, (fx, el) => {
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
assert.match(fx.text(el), /Bezahlt – vielen Dank\./)
})
})
test('abgelaufen, 404 und Netzfehler: feste Texte; ohne Token kein Abruf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'abgelaufen' } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Die Reservierung ist abgelaufen\. Bitte fragen Sie erneut an\./)
})
await mitFx([{ status: 404, body: { fehler: 'nicht_gefunden' } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Dieser Link ist ungültig\./)
})
await mitFx([], { suche: '' }, (fx, el) => {
assert.match(fx.text(el), /Dieser Link ist ungültig\./)
assert.equal(fx.fetchLog.length, 0)
})
await mitFx([() => { throw new Error('netz') }], undefined, (fx, el) => {
assert.match(fx.text(el), /nicht geladen werden/)
})
})
test('Fehler beim Buchen: abgelaufen, Feldfehler, Server-Ausfall (Knopf wieder frei)', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 410, body: { fehler: 'frist_abgelaufen' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Die Reservierung ist abgelaufen\./)
})
await mitFx([{ status: 200, body: DATEN }, { status: 409, body: { fehler: 'zeitraum_belegt' } }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Der Zeitraum ist leider inzwischen belegt\. Bitte fragen Sie einen anderen Zeitraum an\./)
})
await mitFx([{ status: 200, body: DATEN }, { status: 400, body: { fehler: 'ungueltig', felder: { plz: 'PLZ ungültig' } } }, { status: 500, body: {} }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.text(fx.finde(el, '[data-fehler="plz"]')), 'PLZ ungültig')
assert.equal(fx.finde(el, 'button[type="submit"]').hasAttribute('disabled'), false)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /nicht abgeschlossen werden/)
})
})
test('wk_zahlung=abbruch zeigt den Hinweis', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false } }], { suche: '?t=tok-123&wk_zahlung=abbruch' }, (fx, el) => {
assert.match(fx.text(el), /Zahlung abgebrochen – Sie können jetzt oder vor Ort bezahlen\./)
})
})
test('Token verlaesst die Seite nur in den Schnittstellen-Aufrufen', async () => {
assert.doesNotMatch(QUELLE, /console\.|localStorage|sessionStorage|sendBeacon|document\.cookie/)
await mitFx([{ status: 200, body: DATEN }], undefined, (fx) => {
for (const a of fx.fetchLog) assert.ok(a.url.startsWith('/api/public/unterkunft/v1/'), a.url)
})
})
test('Status bezahlt wie gebucht: Bestaetigung, kein Zahlknopf', async () => {
await mitFx([{ status: 200, body: { ...DATEN, status: 'bezahlt', zahlart: 'online', bezahlt: true } }], undefined, (fx, el) => {
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
assert.match(fx.text(el), /Bezahlt – vielen Dank\./)
assert.equal(fx.finde(el, '[data-tag="online-zahlen"]'), null)
})
})
test('Wiederholung: POST 409 oder ok ohne gebucht laedt neu und zeigt den aktuellen Stand', async () => {
const gebucht = { ...DATEN, status: 'gebucht', zahlart: 'vorort', bezahlt: false, rechnungNummer: 'R-1' }
for (const post of [{ status: 409, body: { fehler: 'bereits_gebucht' } }, { status: 200, body: {} }]) {
await mitFx([{ status: 200, body: DATEN }, post, { status: 200, body: gebucht }], undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.equal(fx.fetchLog.length, 3)
assert.equal(fx.fetchLog[2].method, 'GET')
assert.match(fx.text(el), /Ihre Buchung ist bestätigt/)
})
}
})
test('Uebersicht: Menge × Einzelpreis, gewaehlte Zahlart, Titel', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
assert.match(fx.text(fx.finde(el, '[data-tag="posten"]')), /Übernachtung, 4 Nächte \(4 × 100,00\s€\)400,00\s€/)
assert.match(fx.text(fx.finde(el, '.uk-zahlarten')), /\(gewählt: vor Ort\)/)
await fx.klick(fx.finde(el, 'input[value="online"]'))
assert.match(fx.text(fx.finde(el, '.uk-zahlarten')), /\(gewählt: online\)/)
assert.equal(fx.document.title, 'Buchung – Ferienwohnung')
})
})
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
import { installFakeDom } from './support/fakeDom.js'
// Ablauf des Widgets public/embed/unterkunft.js gegen den Vertrag docs/funktionen/unterkunft-api.md (Fake-fetch, Minimal-DOM).
const QUELLE = readFileSync(new URL('../public/embed/unterkunft.js', import.meta.url), 'utf8')
const CFG = { aktiv: true, anbieter: { name: 'Haus Test', ort: 'X', datenschutzUrl: 'https://kunde.example/datenschutz' }, akzentfarbe: '#0f766e', formToken: 'tok1', unterkuenfte: [{ id: 'u1', name: 'Ferienwohnung', maxPersonen: 3, mindestNaechte: 2, checkIn: '15:00', checkOut: '11:00' }] }
const BEL = { belegt: [{ von: '2026-11-20', bis: '2026-11-25' }], heute: '2026-11-01', bisMax: '2028-04-30' }
function start(antworten) {
const fx = installFakeDom({ antworten })
fx.document.readyState = 'complete'
fx.document.currentScript = { dataset: {}, src: 'https://project.webklar.com/embed/unterkunft.js' }
const el = fx.document.createElement('div')
el.setAttribute('data-webklar-unterkunft', 'abcdefghijklmnop')
fx.document.body.appendChild(el)
vm.runInThisContext(QUELLE, { filename: 'unterkunft.js' })
return { fx, el }
}
const tag = (fx, el, t) => fx.finde(el, `[data-tag="${t}"]`)
test('Ladefehler und aktiv:false zeigen nur den Ausweichsatz', async () => {
for (const antwort of [{ status: 200, body: { aktiv: false } }, { status: 500, body: {} }]) {
const { fx, el } = start([antwort])
try {
await fx.ruhe()
assert.equal(fx.text(el), 'Anfragen gerne per Telefon oder E-Mail.')
} finally { fx.restore() }
}
})
test('Kalender, Auswahl, Mindestnaechte, Absenden mit Vertragsfeldern, Danke-Text', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }, { status: 201, body: { nummer: 'A-1234' } }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
assert.match(fx.fetchLog[0].url, /^https:\/\/project\.webklar\.com\/api\/public\/unterkunft\/v1\/abcdefghijklmnop$/)
assert.match(fx.fetchLog[1].url, /\/belegung\?unterkunft=u1&von=\d{4}-\d{2}-\d{2}&bis=\d{4}-\d{2}-\d{2}$/)
assert.equal(tag(fx, el, '2026-11-10').getAttribute('aria-label'), 'Dienstag, 10. November 2026, frei')
assert.equal(tag(fx, el, '2026-11-21').getAttribute('aria-label').includes('belegt'), true)
assert.ok(fx.finde(el, '[name="kontakt_url"]'), 'Honeypot')
// ohne Zeitraum
await fx.setze(fx.finde(el, '[name="name"]'), 'Max'); await fx.setze(fx.finde(el, '[name="email"]'), 'max@example.com')
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), /Bitte wählen Sie Anreise und Abreise im Kalender\./)
assert.equal(fx.fetchLog.length, 2)
// Mindestnaechte (2): 1 Nacht -> Hinweis
await fx.klick(tag(fx, el, '2026-11-10')); await fx.klick(tag(fx, el, '2026-11-11'))
assert.match(fx.text(el), /Mindestaufenthalt: 2 Nächte/)
await fx.klick(tag(fx, el, '2026-11-14'))
assert.match(fx.text(el), /10\.–14\. Nov\. · 4 Nächte/)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
const post = fx.fetchLog[2]
assert.equal(post.method, 'POST')
assert.match(post.url, /\/anfrage$/)
assert.deepEqual(post.body, { unterkunft: 'u1', von: '2026-11-10', bis: '2026-11-14', personen: 1, name: 'Max', firma: '', email: 'max@example.com', telefon: '', nachricht: '', formToken: 'tok1', kontakt_url: '' })
assert.match(fx.text(el), /Danke! Ihre Anfrage A-1234 ist eingegangen\. Sie erhalten gleich eine Bestätigung per E-Mail\./)
} finally { fx.restore() }
})
test('Pfeiltasten bewegen den Fokus', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
await fx.klick(tag(fx, el, '2026-11-10'))
await fx.sende(tag(fx, el, '2026-11-10'), 'keydown', { key: 'ArrowRight' })
assert.equal(fx.document.activeElement.getAttribute('data-tag'), '2026-11-11')
await fx.sende(fx.document.activeElement, 'keydown', { key: 'ArrowDown' })
assert.equal(fx.document.activeElement.getAttribute('data-tag'), '2026-11-18')
} finally { fx.restore() }
})
test('Monatsnavigation: erster Monat hat aktiven Weiter-Knopf, Fokus bleibt, Tab-Stop im gezeigten Monat, Abreisetag-Label', async () => {
const { fx, el } = start([{ status: 200, body: CFG }, { status: 200, body: BEL }])
try {
await fx.ruhe(); await fx.ruhe()
const erster = fx.finde(el, '.wku-monat')
const weiter = fx.finde(erster, '[data-nav="vor1"]')
assert.ok(weiter && !weiter.hasAttribute('disabled'))
await fx.klick(weiter)
assert.match(fx.text(el), /Dezember 2026/)
assert.equal(fx.document.activeElement.getAttribute('data-nav'), 'vor1')
assert.equal(fx.finde(el, '[data-tag][tabindex="0"]').getAttribute('data-tag'), '2026-12-01')
await fx.klick(fx.finde(el, '[data-nav="zur"]'))
assert.equal(fx.document.activeElement.getAttribute('data-nav'), 'vor1') // Zurueck ist im ersten Monat gesperrt, Fokus wechselt zum Weiter-Knopf
assert.equal(fx.finde(el, '[data-tag][tabindex="0"]').getAttribute('data-tag'), '2026-11-01')
await fx.klick(tag(fx, el, '2026-11-18'))
assert.match(tag(fx, el, '2026-11-20').getAttribute('aria-label'), /belegt – als Abreisetag möglich/)
assert.doesNotMatch(tag(fx, el, '2026-11-22').getAttribute('aria-label'), /Abreisetag möglich/)
} finally { fx.restore() }
})