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, leereBelegungSpeicherFuerTests } = 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) leereBelegungSpeicherFuerTests() 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: Antwort 10 s im Speicher (kein Datenbankzugriff), nach einem Schreiben sofort frisch, danach nach Ablauf frisch', async () => { aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-05', bis: '2026-11-08', quelle: 'manuell' }) const url = `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2026-11-22` assert.equal((await send('GET', url)).status, 200) aw.failNext({ collection: 'ukSperren', method: 'GET', status: 500 }) const gemerkt = await send('GET', url) assert.equal(gemerkt.status, 200, 'aus dem Speicher, kein Zugriff auf ukSperren') assert.deepEqual(gemerkt.body.belegt, [{ von: '2026-11-05', bis: '2026-11-08' }]) // anderer Zeitraum = anderer Schluessel: trifft den eingeplanten Fehler assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-02&bis=2026-11-22`)).status, 503) // Schreiben ueber den Speicher-Pfad macht die Antwort ungueltig const sp = await import('../server/services/unterkunft/speicher.js') await sp.legeSperreAn({ unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' }) assert.deepEqual((await send('GET', url)).body.belegt, [{ von: '2026-11-05', bis: '2026-11-08' }, { von: '2026-11-10', bis: '2026-11-12' }]) // Ablauf nach 10 s aw.seed('ukSperren', { $id: 's9', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-15', bis: '2026-11-16', quelle: 'manuell' }) assert.equal((await send('GET', url)).body.belegt.length, 2) setzeUhrFuerTests(() => new Date(NOW.getTime() + 11000)) assert.equal((await send('GET', url)).body.belegt.length, 3) }) test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => { aw.seed('ukSperren', { $id: 'sz', unterkunftId: 'u1', featureId: FEATURE, von: '2028-04-28', bis: '2028-06-10', quelle: 'manuell' }) const geklemmt = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2028-04-01&bis=2030-01-01`) assert.equal(geklemmt.status, 200) assert.equal(geklemmt.body.bisMax, '2028-05-01') assert.deepEqual(geklemmt.body.belegt, [{ von: '2028-04-28', bis: '2028-05-01' }]) assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 200) assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-04`)).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=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', 'K36']) }) 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('Anfrage: fehlerhafte Felder (Datum, Unterkunft) verbrauchen das Stundenkontingent der Seite nicht', async () => { config.unterkunft.anfragenJeSiteStunde = 1 for (const extra of [{ von: 'morgen' }, { bis: '' }, { unterkunft: 'a/b' }]) assert.equal((await post(anfrage(extra))).status, 400) 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, 429) }) 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'), '*') }) test('Fix: manipulierte Unterkunfts-IDs -> 400 (Belegung, Anfrage); Speicher fragt nicht nach', async () => { for (const id of ['../x', 'a?b', 'a%23b', 'x'.repeat(37)]) { const r = await send('GET', `${BASE}/belegung?unterkunft=${encodeURIComponent(id)}&von=2026-11-01&bis=2026-11-05`) assert.equal(r.status, 400, id) assert.ok(r.body.felder.unterkunft) const p = await post(anfrage({ unterkunft: id })) assert.equal(p.status, 400, id) assert.ok(p.body.felder.unterkunft) } assert.equal(aw.docs('ukAnfragen').size, 0) const sp = await import('../server/services/unterkunft/speicher.js') const echtesFetch = globalThis.fetch let aufrufe = 0 globalThis.fetch = (...a) => (aufrufe++, echtesFetch(...a)) try { for (const id of ['../x', 'a?b', '', 'x'.repeat(37), 5, null]) assert.equal(await sp.ladeUnterkunft(id), null) assert.equal(await sp.ladeAnfrage('../../x'), null) } finally { globalThis.fetch = echtesFetch } assert.equal(aufrufe, 0) }) test('Fix: POST prueft die IP-Grenze vor dem siteKey-Lookup', async () => { const echtesFetch = globalThis.fetch let aufrufe = 0 globalThis.fetch = (u, ...a) => (String(u?.url ?? u).startsWith(aw.url) && aufrufe++, echtesFetch(u, ...a)) try { const stati = [] for (let i = 0; i < 125; i++) { const key = ('a'.repeat(15) + String.fromCharCode(97 + (i % 26))).slice(0, 16) stati.push((await send('POST', `/api/public/unterkunft/v1/${key}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } })).status) } assert.ok(aufrufe > 0, 'unter der Grenze wird nachgeschlagen') assert.equal(stati[119], 200) assert.deepEqual(stati.slice(120), [429, 429, 429, 429, 429]) const nachher = aufrufe await send('POST', `${BASE}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } }) assert.equal(aufrufe, nachher, 'ueber der IP-Grenze keine Lookups mehr') } finally { globalThis.fetch = echtesFetch } }) test('Fix: Pilotmodus mit fremdem Besitzer -> {aktiv:false}; zu grosser Rumpf -> 413', async () => { config.unterkunft.mode = 'pilot' config.unterkunft.pilotEmails = ['andere@example.com'] K.leereCache() assert.deepEqual((await send('GET', BASE)).body, { aktiv: false }) config.unterkunft.pilotEmails = ['mimi@example.com'] assert.equal((await send('GET', BASE)).body.aktiv, true) const gross = await post(anfrage({ nachricht: 'x'.repeat(20000) })) assert.equal(gross.status, 413) })