import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' // Oeffentliche Schnittstelle der Oeffnungszeiten (/api/public/zeiten/v1, Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, // Abschnitt 4.1): Router im Nachbau von Appwrite, feste Uhr (setzeUhrFuerTests), je Test eine eigene Client-Adresse (X-Forwarded-For). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, datetimes: ZEITEN_DATETIMES, defaultLimit: 25, }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { startTestApp, send } = await import('./support/testApp.js') const { kundenansichtCookie } = await import('./support/fixtures.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') const { darfZeitenAnfrage } = await import('../server/services/zeiten/limits.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { default: router, setzeUhrFuerTests, resetPublicZeitenForTests } = await import('../server/routes/publicZeiten.js') const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/zeiten/v1': router }) after(() => Promise.all([app.close(), aw.close()])) const BASIS = '/api/public/zeiten/v1' const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 const SITE_KEY = 'abcdabcdabcdabcd' const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE_ID = 'f_p_projekt01_zeiten' const P_STANDARD = 'p_aaaaaaa1' const MODUS_VORHER = config.zeiten.mode const FEATURE = { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, featureKey: 'zeiten', state: 'active', enabled: true } const tage = (woche = {}) => { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] } return t } const eingabe = (extra = {}) => ({ land: 'RP', plaene: [ { id: P_STANDARD, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'] }) }, ], feiertage: {}, ausnahmen: [], hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Wir sind für Euch da.', knopf: null }], kontakt: { an: true, telefon: '0171 1234567', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: null }, ...extra, }) /** Funktion, Kunde und Projekt in den Nachbau legen; mit zeiten = false bleibt es beim leeren Dokument. */ async function legeAn({ feature = {}, kunde = { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, zeiten = true, extra = {} } = {}) { aw.seed('portalFeatures', { ...FEATURE, ...feature }) if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde }) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Sonnenschein-Website' }) if (zeiten) await speichereZeiten({ feature: FEATURE, eingabe: eingabe(extra), version: 0, by: 'kunde', now: JETZT }) } let clientIp = 0 let ip /** Anfrage an die Schnittstelle: Pfad ab /v1, eigene Client-Adresse je Test. */ const api = (method, pfad, { ip: adresse = ip, headers = {}, ...opts } = {}) => send(method, BASIS + pfad, { ...opts, headers: { 'x-forwarded-for': adresse, ...headers } }) const lies = (pfad = `/${SITE_KEY}`, opts) => api('GET', pfad, opts) beforeEach(async () => { for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } config.zeiten.mode = 'on' resetZeitenCacheForTests() resetTermineLimitsForTests() resetPublicZeitenForTests() setzeUhrFuerTests(() => JETZT) mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) clientIp += 1 ip = `198.51.100.${clientIp}` }) afterEach(() => { config.zeiten.mode = MODUS_VORHER mock.restoreAll() resetPublicZeitenForTests() }) const AKTIV_FALSE = { aktiv: false } const NICHT_ERREICHBAR = { error: 'Vorübergehend nicht erreichbar.' } // ---------------------------------------------------------------- CORS, Methoden test('CORS * und OPTIONS 204', async () => { const vorab = await api('OPTIONS', `/${SITE_KEY}`, { headers: { origin: 'https://baeckerei.example', 'access-control-request-method': 'GET' } }) assert.equal(vorab.status, 204) assert.equal(vorab.headers.get('access-control-allow-origin'), '*') assert.equal(vorab.headers.get('access-control-allow-methods'), 'GET, HEAD, OPTIONS') assert.match(vorab.headers.get('access-control-allow-headers'), /If-None-Match/) assert.equal(vorab.headers.get('access-control-max-age'), '86400') assert.equal(vorab.body, null) await legeAn() const antwort = await lies(undefined, { headers: { origin: 'https://baeckerei.example' } }) assert.equal(antwort.status, 200) assert.equal(antwort.headers.get('access-control-allow-origin'), '*') assert.equal(antwort.headers.get('set-cookie'), null) assert.equal(antwort.headers.get('x-content-type-options'), 'nosniff') assert.match(antwort.headers.get('content-type'), /^application\/json/) // der Preflight zaehlt nicht gegen die Grenze und ist auch auf unbekannten Pfaden ein 204 const anderswo = await api('OPTIONS', '/irgendwas/anderes') assert.equal(anderswo.status, 204) assert.equal(anderswo.headers.get('access-control-allow-origin'), '*') }) test('nur GET/HEAD, sonst 405', async () => { await legeAn() for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { const res = await api(method, `/${SITE_KEY}`, { body: { a: 1 } }) assert.equal(res.status, 405, method) assert.deepEqual(res.body, { error: 'Methode nicht erlaubt' }, method) assert.equal(res.headers.get('access-control-allow-origin'), '*', `${method}: die Meldung bleibt fuer das Skript lesbar`) } const head = await api('HEAD', `/${SITE_KEY}`) assert.equal(head.status, 200) assert.equal(head.body, null, 'HEAD ohne Rumpf') assert.match(head.headers.get('etag'), /^W\/"[A-Za-z0-9_-]{27}"$/) assert.equal(head.headers.get('cache-control'), 'public, max-age=60') }) // ---------------------------------------------------------------- Kopfzeilen, ETag test('Cache-Control public, max-age=60 und Expose-Headers Date', async () => { await legeAn() const res = await lies() assert.equal(res.status, 200) assert.equal(res.headers.get('cache-control'), 'public, max-age=60') assert.equal(res.headers.get('access-control-expose-headers'), 'Date') assert.equal(res.body.aktiv, true) assert.equal(res.body.stand, JETZT.toISOString()) assert.equal(res.body.plaene.length, 1) assert.equal(res.body.firma.name, 'Bäckerei Sonnenschein') // auch die kurze Antwort { aktiv: false } und die Fehlerantworten tragen die Date-Freigabe, damit das Skript die Serverzeit lesen kann const leer = await lies('/zzzzzzzzzzzzzzzz') assert.equal(leer.headers.get('access-control-expose-headers'), 'Date') assert.equal(leer.headers.get('cache-control'), 'public, max-age=60') const fremd = await api('POST', `/${SITE_KEY}`) assert.equal(fremd.headers.get('access-control-expose-headers'), 'Date') }) test('Antwort ohne interne IDs, Kundendaten und Zugangswerte', async () => { await legeAn({ kunde: { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein', email: 'sonne@example.com', phone: '0699' } }) const res = await lies() const text = JSON.stringify(res.body) for (const intern of [FEATURE_ID, KUNDE, PROJEKT, SITE_KEY, P_STANDARD, 'h_dauer001', 'Frau Sonne', 'sonne@example.com', 'Sonnenschein-Website']) { assert.ok(!text.includes(intern), `Wert ${intern}`) } }) test('ETag bleibt gleich, obwohl stand sich ändert ⇒ 304', async () => { await legeAn() const erste = await lies() assert.equal(erste.status, 200) const etag = erste.headers.get('etag') assert.match(etag, /^W\/"[A-Za-z0-9_-]{27}"$/, 'weiches ETag, sha1 base64url') // der Speicher laeuft nach 30 s ab und die Antwort wird neu gebaut: gleiche Daten, neuer Zeitstempel (noch dieselbe Minute: // der erste Eintrag von wechsel beginnt mit der aktuellen Minute und gehoert zum Body) setzeUhrFuerTests(() => new Date(JETZT.getTime() + 30 * 1000)) const neu = await lies() assert.equal(neu.status, 200) assert.notEqual(neu.body.stand, erste.body.stand, 'stand ist neu') assert.equal(neu.headers.get('etag'), etag, 'das ETag haengt nicht an stand') const bedingt = await lies(undefined, { headers: { 'if-none-match': etag } }) assert.equal(bedingt.status, 304) assert.equal(bedingt.body, null) assert.equal(bedingt.headers.get('etag'), etag) assert.equal(bedingt.headers.get('cache-control'), 'public, max-age=60') assert.equal(bedingt.headers.get('access-control-allow-origin'), '*') for (const wert of [etag.slice(2), `"abc", ${etag}`, '*']) { const res = await lies(undefined, { headers: { 'if-none-match': wert } }) assert.equal(res.status, 304, wert) } const veraltet = await lies(undefined, { headers: { 'if-none-match': 'W/"nicht-mehr-aktuell"' } }) assert.equal(veraltet.status, 200) }) test('ETag ändert sich mit dem Inhalt (Hinweis geändert) und ist für { aktiv: false } stabil', async () => { await legeAn() const vorher = (await lies()).headers.get('etag') await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ hinweise: [{ id: 'h_dauer001', art: 'info', text: 'Neuer Text', knopf: null }] }), version: 1, by: 'kunde', now: JETZT }) const nachher = await lies() assert.equal(nachher.status, 200) assert.equal(nachher.body.hinweise[0].text, 'Neuer Text') assert.notEqual(nachher.headers.get('etag'), vorher) const leer1 = await lies('/zzzzzzzzzzzzzzzz') setzeUhrFuerTests(() => new Date(JETZT.getTime() + 5 * 60 * 1000)) const leer2 = await lies('/zzzzzzzzzzzzzzzz') assert.deepEqual(leer1.body, AKTIV_FALSE) assert.equal(leer1.headers.get('etag'), leer2.headers.get('etag')) assert.equal((await lies('/zzzzzzzzzzzzzzzz', { headers: { 'if-none-match': leer1.headers.get('etag') } })).status, 304) }) // ---------------------------------------------------------------- Grenze test('121. Anfrage in einer Minute ⇒ 429 mit Retry-After', async () => { await legeAn() for (let i = 1; i <= 120; i += 1) { const res = await lies() assert.equal(res.status, 200, `Anfrage ${i}`) } const zuViele = await lies() assert.equal(zuViele.status, 429) assert.equal(zuViele.headers.get('retry-after'), '60') assert.equal(zuViele.headers.get('access-control-allow-origin'), '*') assert.equal(zuViele.headers.get('cache-control'), 'no-store') assert.deepEqual(zuViele.body, { error: 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' }) // der Preflight zaehlt nicht, eine andere Adresse hat ihre eigene Grenze, nach einer Minute geht es wieder assert.equal((await api('OPTIONS', `/${SITE_KEY}`)).status, 204) assert.equal((await lies(undefined, { ip: '198.51.100.250' })).status, 200) setzeUhrFuerTests(() => new Date(JETZT.getTime() + 60 * 1000)) assert.equal((await lies()).status, 200) }) test('darfZeitenAnfrage: 120 je Minute und IP, IPv6 je /64, getrennt von den Zählern des Terminplaners', async () => { const jetzt = new Date('2026-10-05T10:00:00.000Z') for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), true, `Anfrage ${i + 1}`) assert.equal(darfZeitenAnfrage('203.0.113.9', jetzt), false) assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 59999)), false, 'das Fenster dauert 60 s') assert.equal(darfZeitenAnfrage('203.0.113.9', new Date(jetzt.getTime() + 60000)), true) assert.equal(darfZeitenAnfrage('203.0.113.10', jetzt), true, 'andere IP, anderer Zähler') for (let i = 0; i < 120; i += 1) assert.equal(darfZeitenAnfrage('2001:db8:1:2:aaaa::1', jetzt), true) assert.equal(darfZeitenAnfrage('2001:db8:1:2:bbbb::7', jetzt), false, 'dasselbe /64 teilt sich den Zähler') assert.equal(darfZeitenAnfrage('2001:db8:1:3::1', jetzt), true) const { darfAnfrage } = await import('../server/services/termine/limits.js') assert.equal(darfAnfrage('203.0.113.9', jetzt), true, 'der Terminplaner zählt für dieselbe IP für sich') }) // ---------------------------------------------------------------- Fehler test('Appwrite-Fehler ⇒ 503 ohne Details', async () => { await legeAn() const RAW = 'Nachbau kaputt: geheimer Rohtext' aw.failNext({ collection: 'portalFeatures', status: 500, message: RAW }) const res = await api('GET', `/${SITE_KEY}`) assert.equal(res.status, 503) assert.deepEqual(res.body, NICHT_ERREICHBAR) assert.equal(res.headers.get('access-control-allow-origin'), '*') assert.equal(res.headers.get('cache-control'), 'no-store') assert.ok(!JSON.stringify(res.body).includes('Nachbau')) assert.ok(console.error.mock.calls.length > 0, 'der Fehler steht im Log, nicht in der Antwort') // nichts wurde gemerkt: danach geht es wieder assert.equal((await lies()).status, 200) }) // ---------------------------------------------------------------- aktiv:false test('aktiv:false bei Schalter aus, unbekanntem Schlüssel, Zustand off/ordered/disabled', async () => { await legeAn() config.zeiten.mode = 'off' const aus = await lies() assert.equal(aus.status, 200) assert.deepEqual(aus.body, AKTIV_FALSE) assert.equal(aus.headers.get('access-control-allow-origin'), '*') assert.equal(aus.headers.get('cache-control'), 'public, max-age=60') config.zeiten.mode = 'on' assert.equal((await lies()).body.aktiv, true) assert.deepEqual((await lies('/zzzzzzzzzzzzzzzz')).body, AKTIV_FALSE, 'unbekannter Schluessel') for (const kaputt of ['/ABCDABCDABCDABCD', '/abc', '/abcdabcdabcdabc1', `/${'a'.repeat(200)}`]) { const res = await lies(kaputt) assert.equal(res.status, 200, kaputt) assert.deepEqual(res.body, AKTIV_FALSE, kaputt) } for (const state of ['off', 'ordered', 'disabled']) { await legeAn({ feature: { state, enabled: false }, zeiten: false }) resetZeitenCacheForTests() const res = await lies() assert.equal(res.status, 200, state) assert.deepEqual(res.body, AKTIV_FALSE, state) } for (const state of ['setup', 'active']) { await legeAn({ feature: { state }, zeiten: false }) resetZeitenCacheForTests() assert.equal((await lies()).body.aktiv, true, state) } }) test('Kunde gelöscht ⇒ aktiv:false', async () => { await legeAn({ kunde: null }) assert.deepEqual((await lies()).body, AKTIV_FALSE) }) // ---------------------------------------------------------------- Rest test('unbekannter Pfad ⇒ 404 mit CORS', async () => { for (const pfad of ['', '/', `/${SITE_KEY}/mehr`, '/a/b/c']) { const res = await api('GET', pfad) assert.equal(res.status, 404, pfad) assert.deepEqual(res.body, { error: 'Nicht gefunden' }, pfad) assert.equal(res.headers.get('access-control-allow-origin'), '*', pfad) } }) test('Kundenansicht-Cookie (Mitarbeiter im Portal eines Kunden) stört den öffentlichen Abruf nicht', async () => { await legeAn() const cookie = kundenansichtCookie() const res = await lies(undefined, { cookie, headers: { 'x-kundenansicht': 'b'.repeat(32) } }) assert.equal(res.status, 200) assert.equal(res.body.aktiv, true) const abgelaufen = await lies(undefined, { cookie: kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) }) assert.equal(abgelaufen.status, 200) }) test('Reihenfolge in server/index.js: direkt nach dem Mount der News, hinter kundenansichtGuard, Termine unberührt', () => { const quelle = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') const guard = quelle.indexOf('app.use(kundenansichtGuard)') const news = quelle.indexOf("app.use('/api/public/news', publicNewsRoutes)") const zeiten = quelle.indexOf("app.use('/api/public/zeiten/v1', publicZeitenRoutes)") const termine = quelle.indexOf("app.use('/api/public/termine/v1', publicTermineRoutes)") assert.ok(guard >= 0 && news >= 0 && zeiten >= 0 && termine >= 0, 'alle Mounts gefunden') assert.equal(quelle.indexOf("app.use('/api/public/zeiten/v1'", zeiten + 1), -1, 'nur einmal eingehaengt') assert.ok(guard < zeiten, 'erst der Waechter der Kundenansicht') assert.equal(quelle.slice(news, zeiten).split('\n').length, 2, 'unmittelbar die naechste Zeile') assert.ok(termine < quelle.indexOf('app.use(express.json('), 'Termine bleibt vor dem globalen Parser') assert.match(quelle, /import publicZeitenRoutes from '\.\/routes\/publicZeiten\.js'/) })