Buchungskalender: öffentliche API für Kalender und Anfrage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:06:30 +00:00
1 parent d7b5b98187
commit 035967f8eb
3 files changed
+522 -7

No files matched your search

+319 -6
View File
@@ -1,17 +1,330 @@
import { randomInt } from 'node:crypto'
import express, { Router } from 'express'
import { config } from '../config.js'
import { analyseDomains, analyseHosts } from '../services/analyse.js'
import { legeAnfrageAn } from '../services/unterkunft/ablauf.js'
import { belegteBereiche } from '../services/unterkunft/belegung.js'
import { istTag } from '../services/unterkunft/datum.js'
import { UkFehler } from '../services/unterkunft/fehler.js'
import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js'
import { ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js'
import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js'
import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js'
import { heute } from '../services/termine/zeit.js'
/**
* Oeffentliche Schnittstelle des Buchungskalenders: /api/public/unterkunft/v1 (Vertrag: docs/funktionen/unterkunft-api.md).
* Gaeste ohne Session; gemountet VOR dem globalen JSON-Parser, deshalb mit eigenem 16-KB-Parser (wie publicShop.js).
* Geruest (Task 5): Task 6 fuellt Konfiguration, Belegung und Anfrage, Task 7 die Buchungsseite.
* Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage. Task 7 ergaenzt die Buchungsseite.
*
* - CORS: GET fuer alle (*). POST und Preflight nur von Seiten des Betriebs (analyseDomains des Projekts), des Portals
* (config.publicBaseUrl) oder gleicher Herkunft; sonst 403.
* - Grenzen: je IP 120 Abrufe je Minute, je Seite 600 je Minute; Anfragen je IP 10 je 10 Minuten und je Seite
* config.unterkunft.anfragenJeSiteStunde je Stunde.
* - Fehlerform: { fehler: code, felder? }.
*/
const router = Router()
router.use(express.json({ limit: '16kb' }))
// Task 6: GET /:siteKey, GET /:siteKey/belegung, POST /:siteKey/anfrage
const BODY_MAX = 16 * 1024
const STUNDE_MS = 60 * 60 * 1000
const NUMMER_ZEICHEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'
const STANDARD_AKZENT = '#8a6a3f'
const AKZENT_RE = /^#[0-9a-fA-F]{6}$/
let uhr = () => new Date()
export function setzeUhrFuerTests(neueUhr) {
uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date()
}
// ---------------------------------------------------------------- Antworten und Fehler
const fehlerMit = (status, fehler, extra = {}) => Object.assign(new Error(fehler), { status, fehlerCode: fehler, ...extra })
const zuViele = (retryAfter) => fehlerMit(429, 'zu_viele_anfragen', { retryAfter })
const nichtErlaubt = () => fehlerMit(403, 'nicht_erlaubt')
const nichtGefunden = () => fehlerMit(404, 'nicht_gefunden')
const ungueltig = (felder) => fehlerMit(400, 'ungueltig', felder ? { felder } : {})
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function fehlerErgebnis(err) {
if (err instanceof UkFehler && err.status >= 400 && err.status < 500) {
const felder = Object.fromEntries(Object.entries(err.felder ?? {}).filter(([, v]) => typeof v === 'string' || typeof v === 'number'))
return { status: err.status, body: { fehler: err.code, ...(Object.keys(felder).length ? { felder } : {}) } }
}
if (typeof err?.fehlerCode === 'string' && err.status >= 400 && err.status < 500) {
const body = { fehler: err.fehlerCode }
if (err.felder) body.felder = err.felder
return { status: err.status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
}
console.error(`[publicUnterkunft] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return { status: 503, body: { fehler: 'nicht_erreichbar' } }
}
/** Schreibt { status, body, cache, headers }; GET/HEAD tragen immer `*`, POST nur mit geprueftem cors.erlaubt (Meldungen ohne Daten auch `*`). */
function schreibe(req, res, ergebnis, cors = { erlaubt: null }) {
const { status = 200, body, typ = 'application/json; charset=utf-8', cache: cacheControl = 'no-store', headers = {} } = ergebnis
const lesen = req.method === 'GET' || req.method === 'HEAD'
if (lesen) res.set('Access-Control-Allow-Origin', '*')
else if (cors.erlaubt) res.set({ 'Access-Control-Allow-Origin': cors.erlaubt, Vary: 'Origin' })
else if (status === 429 || status === 503 || body?.aktiv === false) res.set('Access-Control-Allow-Origin', '*')
res.status(status)
res.set({ 'Cache-Control': cacheControl, 'X-Content-Type-Options': 'nosniff', ...headers })
if (status === 204) return res.end()
const inhalt = typeof body === 'string' ? body : JSON.stringify(body)
res.set({ 'Content-Type': typ, 'Content-Length': String(Buffer.byteLength(inhalt)) })
return res.end(inhalt)
}
function notantwort(res, err) {
try {
console.error(`[publicUnterkunft] Antwort nicht geschrieben: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
if (res.headersSent) return void res.end()
const inhalt = JSON.stringify({ fehler: 'nicht_erreichbar' })
res.statusCode = 503
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Length', String(inhalt.length))
return void res.end(inhalt)
} catch {
try {
res.destroy()
} catch {
// nichts mehr zu tun
}
}
}
const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const minAntwortMs = () => (Number.isFinite(config.unterkunft?.minAntwortMs) && config.unterkunft.minAntwortMs >= 0 ? config.unterkunft.minAntwortMs : 300)
/**
* Handler mit Fehlerabbildung und optionaler Mindestantwortzeit. fn(req, now, cors) liefert ein Ergebnis fuer schreibe oder wirft;
* cors.erlaubt setzt fn, sobald die Herkunft geprueft ist. Mit mindestdauer antwortet auch ein Fehler nicht schneller als minAntwortMs.
*/
const route = (fn, { mindestdauer = false } = {}) => async (req, res) => {
try {
const cors = { erlaubt: null }
let ergebnis
try {
ergebnis = await fn(req, uhr(), cors)
} catch (err) {
ergebnis = fehlerErgebnis(err)
}
if (mindestdauer) {
const rest = minAntwortMs() - (performance.now() - req.ukStart)
if (rest > 0) await warte(rest)
}
schreibe(req, res, ergebnis, cors)
} catch (err) {
notantwort(res, err)
}
}
// ---------------------------------------------------------------- Beginn: Zeit, Rumpf
router.use((req, res, next) => {
req.ukStart = performance.now()
if (Number(req.get('content-length')) > BODY_MAX) {
res.set('Connection', 'close')
return schreibe(req, res, { status: 413, body: { fehler: 'zu_gross' } })
}
return next()
})
router.use(express.json({ limit: BODY_MAX }))
// ---------------------------------------------------------------- Herkunft
/**
* Die Origin der Anfrage, wenn sie POST und Preflight darf: Host in analyseDomains(project), Host von config.publicBaseUrl
* oder gleiche Herkunft wie die Anfrage. Sonst null.
*/
function erlaubteHerkunft(req, project) {
const origin = req.get('origin')
if (typeof origin !== 'string' || origin === '') return null
let url
try {
url = new URL(origin)
} catch {
return null
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.origin !== origin) return null
const erlaubt = new Set([String(req.get('host') || '').toLowerCase()])
try {
erlaubt.add(new URL(config.publicBaseUrl).host.toLowerCase())
} catch {
// Portal-Adresse unlesbar: dann gilt nur die gleiche Herkunft
}
if (project) for (const domain of analyseDomains(project)) erlaubt.add(domain)
return erlaubt.has(url.host) ? origin : null
}
function pruefeHerkunft(req, cors, project) {
const origin = erlaubteHerkunft(req, project)
if (!origin) throw nichtErlaubt()
cors.erlaubt = origin
}
const vorabantwort = (origin) => ({
status: 204,
body: '',
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
},
})
// Preflight: ohne erreichbare Seite gibt es nichts zu pruefen und nichts zu verraten.
router.options(
'/:siteKey/:was',
route(async (req, now, cors) => {
if (req.params.was !== 'anfrage') throw nichtGefunden()
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return vorabantwort('*')
pruefeHerkunft(req, cors, ctx.project)
return vorabantwort(cors.erlaubt)
}),
)
// ---------------------------------------------------------------- Konfiguration
const aktivFalse = () => ({ body: { aktiv: false }, cache: 'public, max-age=15' })
function datenschutzUrl(project) {
if (!project) return null
const hosts = analyseHosts(project) // [Vorschau-Subdomain, Live-Domain]: die Live-Domain, wenn es eine gibt
const host = hosts[hosts.length - 1]
return host ? `https://${host}/datenschutz` : null
}
router.get(
'/:siteKey',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
const e = ctx.einstellungen ?? {}
const unterkuenfte = await listeUnterkuenfte(ctx.featureId, { nurAktive: true })
return {
body: {
aktiv: true,
anbieter: { name: String(e.firma ?? ''), ort: String(e.ort ?? ''), datenschutzUrl: datenschutzUrl(ctx.project) },
akzentfarbe: AKZENT_RE.test(e.akzentfarbe ?? '') ? e.akzentfarbe : STANDARD_AKZENT,
formToken: formToken(req.params.siteKey, now),
unterkuenfte: unterkuenfte.map((u) => ({
id: u.$id,
name: u.name,
maxPersonen: u.maxPersonen,
mindestNaechte: u.mindestNaechte,
checkIn: u.checkIn,
checkOut: u.checkOut,
})),
},
}
}),
)
// ---------------------------------------------------------------- Belegung
function monateDazu(tag, n) {
const [j, m, t] = tag.split('-').map(Number)
const d = new Date(Date.UTC(j, m - 1 + n, 1))
const letzter = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
d.setUTCDate(Math.min(t, letzter))
return d.toISOString().slice(0, 10)
}
/** Fasst sich beruehrende oder ueberlappende Bereiche (nach von sortiert) zusammen; die Quelle wird nicht ausgegeben. */
function verschmelze(bereiche) {
const aus = []
for (const b of bereiche) {
const letzter = aus[aus.length - 1]
if (letzter && b.von <= letzter.bis) {
if (b.bis > letzter.bis) letzter.bis = b.bis
} else aus.push({ von: b.von, bis: b.bis })
}
return aus
}
router.get(
'/:siteKey/belegung',
route(async (req, now) => {
if (!darfAnfrage(req.ip, now)) throw zuViele(60)
const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now })
if (!ctx) return aktivFalse()
if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60)
const { unterkunft, von, bis } = req.query
if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis <= von) {
throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' })
}
const tag = heute(now)
const bisMax = monateDazu(tag, config.unterkunft.horizontMonate)
if (bis > bisMax) throw ungueltig({ zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' })
const u = await ladeUnterkunft(unterkunft)
if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden()
const ab = von < tag ? tag : von
const belegt = ab < bis ? verschmelze(await belegteBereiche(u.$id, ab, bis, { now })) : []
return { body: { belegt, heute: tag, bisMax } }
}),
)
// ---------------------------------------------------------------- Anfrage
function scheinNummer() {
let n = 'A-'
for (let k = 0; k < 4; k++) n += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
return n
}
const text = (wert) => (typeof wert === 'string' ? wert : '')
router.post(
'/:siteKey/anfrage',
route(
async (req, now, cors) => {
const { siteKey } = req.params
const ctx = await unterkunftPerSiteKey(siteKey, { now })
if (!ctx) return { body: { aktiv: false } }
pruefeHerkunft(req, cors, ctx.project)
if (!darfBuchungsversuch(req.ip, now)) throw zuViele(600)
const b = req.body
if (!istObjekt(b)) throw ungueltig()
// Honigtopf gefuellt oder zu schnell abgeschickt: dieselbe Erfolgsantwort, gespeichert und gesendet wird nichts.
if (b.kontakt_url !== undefined && b.kontakt_url !== null && b.kontakt_url !== '') return { status: 201, body: { nummer: scheinNummer() } }
const pruefung = pruefeFormToken(siteKey, b.formToken, now)
if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } }
if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden')
if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600)
const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen
const anfrage = await legeAnfrageAn(
ctx,
{
unterkunftId: text(b.unterkunft),
von: b.von,
bis: b.bis,
personen,
gast: { name: text(b.name), firma: text(b.firma), email: text(b.email), telefon: text(b.telefon), nachricht: text(b.nachricht) },
},
{ now, ipHash: ipHashVon(req.ip) },
)
return { status: 201, body: { nummer: anfrage.nummer } }
},
{ mindestdauer: true },
),
)
// Rumpf nicht lesbar (kaputtes JSON): 400 in der Fehlerform der Schnittstelle
router.use((err, req, res, next) => {
if (err?.type === 'entity.parse.failed' || err?.type === 'entity.too.large') {
return schreibe(req, res, { status: err.type === 'entity.too.large' ? 413 : 400, body: { fehler: err.type === 'entity.too.large' ? 'zu_gross' : 'ungueltig' } })
}
return next(err)
})
// Task 7: GET|POST /:siteKey/buchung/:id, POST /:siteKey/buchung/:id/zahlung, GET /:siteKey/buchung/:id/rechnung.pdf
/** Solange nichts gefuellt ist: jede Seite ist (noch) nicht aktiv, ohne zu verraten, ob der Schluessel existiert. */
router.get('/:siteKey', (_req, res) => res.json({ aktiv: false }))
export default router
+202
View File
@@ -0,0 +1,202 @@
import { test, after, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Buchungskalender Task 6: oeffentliche API (Konfiguration, Belegung, Anfrage)
const NAMEN = ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen']
const aw = await startAppwriteMock({
schema: Object.fromEntries(NAMEN.filter((n) => !['websiteProjects', 'customers'].includes(n)).map((n) => [n, schemaKeys(n)])),
datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] },
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const K = await import('../server/services/unterkunft/kontext.js')
const T = await import('../server/services/unterkunft/token.js')
const mails = await import('../server/services/unterkunft/mails.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { default: publicRoutes, setzeUhrFuerTests } = await import('../server/routes/publicUnterkunft.js')
const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes })
after(async () => {
await app.close()
await aw.close()
})
const SITE = 'abcdefghijklmnop'
const FEATURE = 'f_uk'
const NOW = new Date('2026-11-01T12:00:00.000Z')
const BASE = `/api/public/unterkunft/v1/${SITE}`
const ORIGIN = 'https://donau-home.example'
const vorher = { ...config.unterkunft }
beforeEach(async () => {
for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
Object.assign(config.unterkunft, vorher, { mode: 'on', minAntwortMs: 0, formMinSekunden: 3 })
K.leereCache()
resetTermineLimitsForTests()
mails._aufrufe.length = 0
setzeUhrFuerTests(() => NOW)
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true })
aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau', subdomain: 'donau', liveDomain: 'donau-home.example' })
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', ort: 'Stetten', email: 'x@y.de', steuernummer: 'GEHEIM', iban: 'DE00', akzentfarbe: '#112233' })
aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Zweite', maxPersonen: 2, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 2, aktiv: true, icalExportToken: 'geheim' })
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', maxPersonen: 4, mindestNaechte: 2, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true })
aw.seed('ukUnterkuenfte', { $id: 'u3', featureId: FEATURE, name: 'Aus', maxPersonen: 4, mindestNaechte: 1, sort: 0, aktiv: false })
aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: FEATURE, staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), personenInklusive: 2, steuerart: 'regel' })
})
afterEach(() => {
Object.assign(config.unterkunft, vorher)
})
const formToken = (alter = 60_000) => T.formToken(SITE, new Date(NOW.getTime() - alter))
const anfrage = (extra = {}) => ({ unterkunft: 'u1', von: '2026-11-10', bis: '2026-11-12', personen: 2, name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo', formToken: formToken(), kontakt_url: '', ...extra })
const post = (body, headers = { origin: ORIGIN }) => send('POST', `${BASE}/anfrage`, { body, headers })
test('Konfiguration: unbekannter oder inaktiver siteKey und Modus off -> {aktiv:false}', async () => {
assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz')).body, { aktiv: false })
assert.deepEqual((await send('GET', '/api/public/unterkunft/v1/kurz')).body, { aktiv: false })
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'off', enabled: true })
K.leereCache()
assert.deepEqual((await send('GET', BASE)).body, { aktiv: false })
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: SITE, state: 'active', enabled: true })
config.unterkunft.mode = 'off'
K.leereCache()
const r = await send('GET', BASE)
assert.deepEqual(r.body, { aktiv: false })
assert.equal(r.headers.get('access-control-allow-origin'), '*')
})
test('Konfiguration: aktiv, nur aktive Unterkuenfte sortiert, keine internen Felder', async () => {
const r = await send('GET', BASE)
assert.equal(r.status, 200)
assert.equal(r.headers.get('access-control-allow-origin'), '*')
assert.equal(r.body.aktiv, true)
assert.deepEqual(r.body.anbieter, { name: 'Donau Home', ort: 'Stetten', datenschutzUrl: 'https://donau-home.example/datenschutz' })
assert.equal(r.body.akzentfarbe, '#112233')
assert.equal(T.pruefeFormToken(SITE, r.body.formToken, new Date(NOW.getTime() + 10_000)), 'ok')
assert.deepEqual(r.body.unterkuenfte.map((u) => u.id), ['u1', 'u2'])
assert.deepEqual(Object.keys(r.body.unterkuenfte[0]).sort(), ['checkIn', 'checkOut', 'id', 'maxPersonen', 'mindestNaechte', 'name'])
const roh = JSON.stringify(r.body)
for (const geheim of ['c1', 'p1', 'GEHEIM', 'DE00', 'mimi@example.com', FEATURE, 'geheim']) assert.ok(!roh.includes(geheim), geheim)
})
test('Konfiguration: ohne Einstellungen und Domain -> Standardfarbe, datenschutzUrl null', async () => {
await deleteDocument(config.collections.ukEinstellungen, FEATURE)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' })
K.leereCache()
const r = await send('GET', BASE)
assert.equal(r.body.akzentfarbe, '#8a6a3f')
assert.equal(r.body.anbieter.datenschutzUrl, null)
assert.equal(r.body.anbieter.name, '')
})
test('Belegung: fasst angrenzende Bereiche zusammen, ohne Quelle, auf das Fenster zugeschnitten', async () => {
aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-05', bis: '2026-11-08', quelle: 'manuell' })
aw.seed('ukSperren', { $id: 's2', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-08', bis: '2026-11-10', quelle: 'ical:abc', notiz: 'x' })
aw.seed('ukAnfragen', { $id: 'a1', featureId: FEATURE, unterkunftId: 'u1', nummer: 'A-AAAA', von: '2026-11-20', bis: '2026-11-25', status: 'gebucht', personen: 2 })
aw.seed('ukSperren', { $id: 's3', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-12', bis: '2026-11-14', quelle: 'manuell' })
const r = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-22`)
assert.equal(r.status, 200)
assert.deepEqual(r.body.belegt, [
{ von: '2026-11-05', bis: '2026-11-10' },
{ von: '2026-11-12', bis: '2026-11-14' },
{ von: '2026-11-20', bis: '2026-11-22' },
])
assert.equal(r.body.heute, '2026-11-01')
assert.equal(r.body.bisMax, '2028-05-01')
assert.equal(r.headers.get('access-control-allow-origin'), '*')
})
test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => {
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 400)
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-01`)).status, 200)
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-05`)).status, 400)
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=morgen&bis=2026-11-05`)).status, 400)
assert.equal((await send('GET', `${BASE}/belegung?von=2026-11-01&bis=2026-11-05`)).status, 400)
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u3&von=2026-11-01&bis=2026-11-05`)).status, 404)
assert.equal((await send('GET', `${BASE}/belegung?unterkunft=nix&von=2026-11-01&bis=2026-11-05`)).status, 404)
assert.deepEqual((await send('GET', `/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-05`)).body, { aktiv: false })
})
test('Anfrage: gueltig -> 201 {nummer} + Dokument neu, CORS-Origin gespiegelt', async () => {
const r = await post(anfrage())
assert.equal(r.status, 201)
assert.match(r.body.nummer, /^A-[A-Z2-9]{4}$/)
assert.equal(r.headers.get('access-control-allow-origin'), ORIGIN)
const docs = [...aw.docs('ukAnfragen').values()]
assert.equal(docs.length, 1)
assert.equal(docs[0].status, 'neu')
assert.equal(docs[0].nummer, r.body.nummer)
assert.equal(docs[0].personen, 2)
assert.deepEqual(mails._aufrufe.map((m) => m.art || m.template), ['G16', 'K31'])
})
test('Anfrage: Honigtopf und zu schnelles Token -> 201 mit Schein-Nummer, nichts gespeichert', async () => {
const a = await post(anfrage({ kontakt_url: 'http://spam.example' }))
assert.equal(a.status, 201)
assert.match(a.body.nummer, /^A-[A-Z2-9]{4}$/)
const b = await post(anfrage({ formToken: formToken(500) }))
assert.equal(b.status, 201)
assert.match(b.body.nummer, /^A-[A-Z2-9]{4}$/)
assert.equal(aw.docs('ukAnfragen').size, 0)
assert.equal(mails._aufrufe.length, 0)
})
test('Anfrage: fehlende E-Mail, ungueltiges Token, Fachfehler', async () => {
const a = await post(anfrage({ email: '' }))
assert.equal(a.status, 400)
assert.equal(a.body.fehler, 'ungueltig')
assert.ok(a.body.felder.email)
const b = await post(anfrage({ formToken: 'quatsch' }))
assert.equal(b.status, 400)
assert.equal(b.body.fehler, 'neu_laden')
const c = await post(anfrage({ personen: 9 }))
assert.equal(c.status, 400)
assert.equal(c.body.fehler, 'zu_viele_personen')
assert.equal(c.body.felder.maxPersonen, 4)
const d = await post(anfrage({ personen: '2', von: '2026-11-10', bis: '2026-11-11' }))
assert.equal(d.body.fehler, 'mindestnaechte')
aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' })
const e = await post(anfrage())
assert.equal(e.status, 409)
assert.equal(e.body.fehler, 'zeitraum_belegt')
const f = await send('POST', `${BASE}/anfrage`, { body: '{kaputt', headers: { origin: ORIGIN, 'content-type': 'application/json' } })
assert.equal(f.status, 400)
assert.equal(aw.docs('ukAnfragen').size, 0)
})
test('Anfrage: mehr als anfragenJeSiteStunde je Seite -> 429', async () => {
config.unterkunft.anfragenJeSiteStunde = 2
assert.equal((await post(anfrage({ von: '2026-11-10', bis: '2026-11-12' }))).status, 201)
assert.equal((await post(anfrage({ von: '2026-12-10', bis: '2026-12-12' }))).status, 201)
const r = await post(anfrage({ von: '2027-01-10', bis: '2027-01-12' }))
assert.equal(r.status, 429)
assert.equal(r.body.fehler, 'zu_viele_anfragen')
assert.ok(r.headers.get('retry-after'))
assert.equal(aw.docs('ukAnfragen').size, 2)
})
test('CORS: Preflight erlaubt/fremd, POST von fremder oder fehlender Origin -> 403', async () => {
const ok = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: ORIGIN, 'access-control-request-method': 'POST' } })
assert.equal(ok.status, 204)
assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN)
const sub = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://donau.project.webklar.com' } })
assert.equal(sub.headers.get('access-control-allow-origin'), 'https://donau.project.webklar.com')
const fremd = await send('OPTIONS', `${BASE}/anfrage`, { headers: { origin: 'https://boese.example' } })
assert.equal(fremd.status, 403)
assert.equal(fremd.headers.get('access-control-allow-origin'), null)
const p = await post(anfrage(), { origin: 'https://boese.example' })
assert.equal(p.status, 403)
assert.equal((await post(anfrage(), {})).status, 403)
assert.equal(aw.docs('ukAnfragen').size, 0)
// unbekannte Seite: Preflight verraet nichts
const unbekannt = await send('OPTIONS', '/api/public/unterkunft/v1/zzzzzzzzzzzzzzzz/anfrage', { headers: { origin: 'https://boese.example' } })
assert.equal(unbekannt.status, 204)
assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*')
})
+1 -1
View File
@@ -150,7 +150,7 @@ test('API-Vertrag: docs/funktionen/unterkunft-api.md nennt alle Pfade', () => {
})
// -------------------------------------------------------------------------------------------------- Öffentliche Schnittstelle
test('Öffentlich: GET /api/public/unterkunft/v1/<siteKey> liefert {aktiv:false} (Gerüst)', async () => {
test('Öffentlich: GET /api/public/unterkunft/v1/<siteKey> liefert für unbekannte Seiten {aktiv:false}', async () => {
const r = await rawGet('/api/public/unterkunft/v1/aaaaaaaaaaaaaaaa')
assert.equal(r.status, 200)
assert.deepEqual(r.body, { aktiv: false })