275 lines
15 KiB
JavaScript
275 lines
15 KiB
JavaScript
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')
|
||
})
|
||
})
|