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'), '*') })