292 lines
17 KiB
JavaScript
292 lines
17 KiB
JavaScript
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)
|
|
})
|