Files

350 lines
19 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.
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 Satz 1 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')
})
})
test('Fehler der Rechnungsadresse verschwinden, sobald der Gast die Felder ausfuellt (auch die allgemeine Meldung)', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(fx.finde(el, '[data-fehler="strasse"]')), /Straße/)
const meldung = fx.finde(el, '.uk-fehler')
assert.match(meldung.textContent, /Bitte prüfen Sie Ihre Angaben\./)
await fx.setze(fx.finde(el, '[name="strasse"]'), 'Hauptstr. 5')
assert.equal(fx.text(fx.finde(el, '[data-fehler="strasse"]')), '')
assert.match(fx.text(fx.finde(el, '[data-fehler="plz"]')), /PLZ/, 'andere Fehler bleiben, bis auch sie behoben sind')
assert.match(meldung.textContent, /Bitte prüfen/)
await fx.setze(fx.finde(el, '[name="plz"]'), '12345')
await fx.setze(fx.finde(el, '[name="ort"]'), 'Berlin')
assert.equal(meldung.textContent, '')
assert.ok(meldung.hidden)
})
})
/* ----------------------------------------------------- Abschluss: 409-Codes, a11y --- */
test('409-Codes beim Buchen: eigene Banner-Texte statt stillem Neuladen', async () => {
const faelle = [
['online_aus', /Online-Zahlung ist gerade nicht möglich – bitte wählen Sie „Vor Ort bezahlen“\./, false],
['einstellungen_unvollstaendig', /Die Buchung ist gerade nicht möglich\. Bitte wenden Sie sich direkt an den Anbieter\./, false],
['zustimmung_fehlt', /Die Buchung ist gerade nicht möglich\. Bitte wenden Sie sich direkt an den Anbieter\./, false],
['status_ungueltig', /Der Stand Ihrer Buchung hat sich geändert\./, true],
]
for (const [code, muster, neuLaden] of faelle) {
const antworten = [{ status: 200, body: DATEN }, { status: 409, body: { fehler: code } }]
if (neuLaden) antworten.push({ status: 200, body: { ...DATEN, status: 'gebucht', zahlart: 'vorort' } })
await mitFx(antworten, undefined, async (fx, el) => {
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.match(fx.text(el), muster, code)
assert.equal(fx.fetchLog.length, neuLaden ? 3 : 2, code)
})
}
})
test('409 bei /zahlung: status_ungueltig laedt neu mit Hinweis; online_aus zeigt Text', async () => {
const d = { ...DATEN, status: 'gebucht', zahlart: 'online', bezahlt: false, rechnungNummer: 'R-1' }
await mitFx([{ status: 200, body: d }, { status: 409, body: { fehler: 'status_ungueltig' } }, { status: 200, body: { ...d, bezahlt: true } }], undefined, async (fx, el) => {
await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]'))
assert.match(fx.text(el), /Der Stand Ihrer Buchung hat sich geändert\./)
assert.match(fx.text(el), /Bezahlt – vielen Dank\./)
})
await mitFx([{ status: 200, body: d }, { status: 409, body: { fehler: 'online_aus' } }], undefined, async (fx, el) => {
await fx.klick(fx.finde(el, '[data-tag="online-zahlen"]'))
assert.match(fx.text(el), /Online-Zahlung ist gerade nicht möglich/)
})
})
test('zahlungUrl nur bei https; Widerrufs-Zitat vollstaendig', async () => {
await mitFx([{ status: 200, body: DATEN }, { status: 200, body: { status: 'gebucht', rechnungNummer: 'R-1', zahlungUrl: 'javascript:alert(1)' } }], undefined, async (fx, el) => {
assert.match(fx.text(el), /§ 312g Abs\. 2 Satz 1 Nr\. 9 BGB/)
await fuelle(fx, el)
await fx.klick(fx.finde(el, 'input[value="online"]'))
await fx.klick(fx.finde(el, 'button[type="submit"]'))
assert.deepEqual(fx.assigned, [])
})
})
test('Feldfehler: aria-invalid, aria-describedby, Fokus auf erstes Fehlerfeld; Zahlart als fieldset/legend', async () => {
await mitFx([{ status: 200, body: DATEN }], undefined, async (fx, el) => {
const gruppe = fx.finde(el, 'fieldset[data-tag="zahlart"]')
assert.ok(gruppe)
assert.equal(fx.text(fx.finde(gruppe, 'legend')), 'Zahlart')
await fx.klick(fx.finde(el, 'button[type="submit"]'))
const strasse = fx.finde(el, '[name="strasse"]')
assert.equal(strasse.getAttribute('aria-invalid'), 'true')
assert.equal(strasse.getAttribute('aria-describedby'), 'uk-fehler-strasse')
assert.equal(fx.document.activeElement, strasse)
await fx.setze(strasse, 'Weg 1')
assert.equal(strasse.getAttribute('aria-invalid'), null)
})
})