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 { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Buchungskalender Task 5: Verdrahtung in den gemeinsamen Dateien (Register, Haken, Rechtstexte, Stripe-Preis, Bestellweg, // Routen, Kontext, Jobs, Webhook-Weiche, Portal-Quelltext, API-Vertrag). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), ukEinstellungen: schemaKeys('ukEinstellungen'), }, datetimes: {}, defaultLimit: 25, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, rawGet, postWebhook } = await import('./support/testApp.js') const reg = await import('../server/services/featureRegistry.js') const legal = await import('../server/services/legalDocs.js') const K = await import('../server/services/unterkunft/kontext.js') const jobs = await import('../server/services/unterkunft/jobs.js') const { default: publicRoutes } = await import('../server/routes/publicUnterkunft.js') const { default: webhookRoutes } = await import('../server/routes/shopWebhook.js') const W = await import('../server/routes/unterkunftWebhook.js') const app = await startTestApp({ '/api/public/unterkunft/v1': publicRoutes, '/api/shop/stripe-webhook': webhookRoutes }) after(async () => { await app.close() await aw.close() await st.close() }) const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') const SITE_KEY = 'abcdefghijklmnop' const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE = 'f_p_projekt01_unterkunft' const T0 = new Date('2026-10-07T10:00:00.000Z') const vorher = { ...config.unterkunft } const urspruenglich = { mode: config.unterkunft.mode, pilotEmails: config.unterkunft.pilotEmails } beforeEach(() => { Object.assign(config.unterkunft, vorher) K.leereCache() W.setzeUkEreignisHandlerFuerTests(null) }) afterEach(() => { mock.restoreAll() Object.assign(config.unterkunft, urspruenglich) }) // -------------------------------------------------------------------------------------------------- Register test('Register: Eintrag unterkunft mit Produkt unterkunftSetup, returnHash buchungen und den Texten der Spec', () => { const e = reg.featureConfig('unterkunft') assert.equal(e.key, 'unterkunft') assert.equal(e.product, 'unterkunftSetup') assert.equal(e.returnHash, 'buchungen') assert.equal(e.returnProjekt, true) assert.equal(e.requiresWartungsAbo, false) assert.equal(e.ticketFreischaltung, true) assert.equal(e.label.name, 'Buchungskalender') assert.equal(e.priceLabel, '199 €') assert.deepEqual(e.consent.items.map((i) => i.id), ['unterlagen', 'verantwortung', 'rechnung', 'stripe', 'beginn', 'information']) assert.match(e.consent.items.find((i) => i.id === 'rechnung').text, /§ 14 Abs\. 2 Satz 4 UStG/) assert.equal(e.consent.version, config.unterkunft.fassung) assert.equal(e.leistungsbeschreibung.url, `/rechtliches/leistungsbeschreibung-unterkunft-${config.unterkunft.fassung}.html`) assert.equal(e.avv.url, `/rechtliches/avv-unterkunft-${config.unterkunft.fassung}.html`) assert.equal(e.widerruf.url, `/rechtliches/widerrufsbelehrung-funktionen-${config.unterkunft.fassung}.html`) assert.equal(e.ticket.topic({ projectName: 'Donau Home' }), 'Buchungskalender einbauen – Donau Home') assert.ok(reg.featureKeys().includes('unterkunft')) assert.equal(config.unterkunftSetup.priceNickname, 'Buchungskalender-Einrichtung 199 €') }) test('Haken: featureHaken(unterkunft) hat alle fünf, Stubs und Schalter wirken', async () => { const h = reg.featureHaken('unterkunft') for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']) assert.equal(typeof h[name], 'function', name) config.unterkunft.mode = 'off' assert.equal(h.verfuegbar('a@b.de'), false) config.unterkunft.mode = 'on' assert.equal(h.verfuegbar('a@b.de'), true) assert.deepEqual(await h.bestellpruefung({ customerId: KUNDE, projectId: PROJEKT }), ['Anfrage noch nicht freigegeben']) assert.equal(await h.freischaltbar({ $id: FEATURE }), false) }) test('Rechtstexte: Satz unterkunft mit Slugs und PDF-Namen, Funktion verweist darauf', () => { const p = legal.featureLegalPaths('unterkunft') assert.deepEqual(p, { lb: `/rechtliches/leistungsbeschreibung-unterkunft-${config.unterkunft.fassung}.html`, avv: `/rechtliches/avv-unterkunft-${config.unterkunft.fassung}.html`, widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${config.unterkunft.fassung}.html`, }) assert.deepEqual(Object.keys(legal.legalPdfFiles('unterkunft', config.unterkunft.fassung)).map((k) => legal.legalPdfFiles('unterkunft', '2026-10-07')[k].filename), [ 'Leistungsbeschreibung-Buchungskalender-2026-10-07.pdf', 'AVV-Buchungskalender-2026-10-07.pdf', 'Widerrufsbelehrung-Muster-Widerrufsformular-2026-10-07.pdf', ]) }) test('Stripe: Preiskatalog, Skript-Schlüssel und Bestellweg kennen unterkunftSetup', async () => { const sb = lies('server/services/stripeBilling.js') assert.match(sb, /unterkunftSetup: 'unterkunft-einrichtung'/) assert.match(sb, /key === 'unterkunftSetup'\) return config\.unterkunftSetup/) const { KEYS, catalogEntry } = await import('../scripts/stripe-products.mjs') assert.ok(KEYS.includes('unterkunftSetup')) assert.equal(catalogEntry('unterkunftSetup').amountCents, 19900) const abo = lies('server/routes/abo.js') assert.match(abo, /ORDERS = \[[^\]]*'unterkunft'/) assert.match(abo, /FEATURE_OF_ORDER = \{[^}]*unterkunft: 'unterkunft'/) assert.match(abo, /'Buchungskalender \(Einrichtung\)'/) assert.match(lies('server/routes/funktionen.js'), /unterkunft: \{ verfuegbar: unterkunftFuerKunde\(req\.session\.email\) \}/) }) // -------------------------------------------------------------------------------------------------- Portal-Quelltext test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => { const app = lies('public/app.js') assert.match(app, /VIEW_HASHES = \{[^}]*buchungen: 'buchungen'/) assert.match(app, /buchungen: \(\) => import\('\/buchungen\.js'\)\.then\(\(m\) => m\.initBuchungen\(document\.getElementById\('buchungen-body'\)\)\)/) assert.match(app, /APP_PARENT = \{[^}]*buchungen: 'funktionen'/) assert.match(app, /FEATURE_LABELS = \{[^}]*unterkunft: 'Buchungskalender'/) const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*?
[\s\S]*?<\/section>/) assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1) assert.match(lies('public/buchungen.js'), /export function initBuchungen\(el\)/) }) test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => { const index = lies('server/index.js') assert.match(index, /app\.use\('\/api\/public\/unterkunft\/v1', publicUnterkunftRoutes\)/) assert.ok(index.indexOf("'/api/public/unterkunft/v1'") < index.indexOf("express.json({ limit: '2mb'"), 'vor dem globalen Parser') assert.match(index, /app\.use\('\/api\/unterkunft', unterkunftRoutes\)/) assert.ok(index.indexOf('app.use(unterkunftSeiteRouter())') < index.indexOf('app.use(express.static(publicDir))'), 'vor express.static') assert.match(index, /}, 80 \* 1000\)\.unref\(\)[\s\S]*?60 \* 1000/) assert.match(index, /unterkunftViertelstunden, 15 \* 60 \* 1000/) assert.match(index, /unterkunftTages, 24 \* 60 \* 60 \* 1000/) assert.match(lies('server/routes/team/index.js'), /router\.use\('\/unterkunft', unterkunftTeamRoutes\)/) }) test('API-Vertrag: docs/funktionen/unterkunft-api.md nennt alle Pfade', () => { const doc = lies('docs/funktionen/unterkunft-api.md') for (const teil of ['GET /:siteKey/belegung', 'POST /:siteKey/anfrage', '/buchung/:id/zahlung', 'rechnung.pdf', '/unterkuenfte/:id/kalender', '/anfragen/:id/stornieren', '/preisvorschau', '/rechnungen/:id.pdf', '/stripe/vorhandenes-konto', '/anfrage-funktion/zurueckziehen', '/api/team/unterkunft', 'N7']) { assert.ok(doc.includes(teil), teil) } }) // -------------------------------------------------------------------------------------------------- Öffentliche Schnittstelle test('Öffentlich: GET /api/public/unterkunft/v1/ liefert {aktiv:false} (Gerüst)', async () => { const r = await rawGet('/api/public/unterkunft/v1/aaaaaaaaaaaaaaaa') assert.equal(r.status, 200) assert.deepEqual(r.body, { aktiv: false }) }) // -------------------------------------------------------------------------------------------------- Kontext function seedFeature(over = {}) { aw.seed('portalFeatures', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'unterkunft', siteKey: SITE_KEY, state: 'active', enabled: true, ...over }) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Donau Home' }) aw.seed('customers', { $id: KUNDE, name: 'Mimi', email: 'mimi@example.com' }) } async function leere() { const { deleteDocument } = await import('../server/services/appwriteAdmin.js') for (const c of ['portalFeatures', 'websiteProjects', 'customers', 'ukEinstellungen']) { for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) } } test('Kontext: unterkunftPerSiteKey beachtet Modus, Zustand, Pilotliste, lädt Einstellungen und merkt 30 Sekunden', async () => { await leere() seedFeature() config.unterkunft.mode = 'off' assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null) config.unterkunft.mode = 'on' assert.equal(await K.unterkunftPerSiteKey('kurz', { now: T0 }), null) const ctx = await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }) assert.equal(ctx.featureId, FEATURE) assert.equal(ctx.customerId, KUNDE) assert.equal(ctx.projectId, PROJEKT) assert.equal(ctx.siteKey, SITE_KEY) assert.equal(ctx.state, 'active') assert.equal(ctx.besitzerEmail, 'mimi@example.com') assert.equal(ctx.einstellungen, null, 'vor dem Setup noch keine Einstellungen') assert.equal(ctx.project.projectName, 'Donau Home') assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: new Date(T0.getTime() + 20_000) }), ctx, 'gemerkt') assert.notEqual(await K.unterkunftPerSiteKey(SITE_KEY, { now: new Date(T0.getTime() + 31_000) }), ctx, 'nach 30 s neu') // Pilot: nur Besitzer aus der Liste; der Modus gilt auch bei gemerktem Kontext config.unterkunft.mode = 'pilot' config.unterkunft.pilotEmails = ['andere@example.com'] assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null) config.unterkunft.pilotEmails = ['mimi@example.com'] assert.ok(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 })) // leereCache + Zustand disabled => null config.unterkunft.mode = 'on' const { updateDocument } = await import('../server/services/appwriteAdmin.js') await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'disabled' }) K.leereCache(SITE_KEY) assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null) }) test('Kontext: Einstellungen kommen geparst ins ctx; ladeFuerProjekt liest jeden Zustand außer off und prüft den Kunden', async () => { await leere() seedFeature({ state: 'setup' }) aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, firma: 'Donau Home', stripeStatus: JSON.stringify({ charges_enabled: true }) }) config.unterkunft.mode = 'on' assert.equal(await K.unterkunftPerSiteKey(SITE_KEY, { now: T0 }), null, 'setup ist für Gäste nicht erreichbar') const ctx = await K.ladeFuerProjekt(KUNDE, PROJEKT) assert.equal(ctx.state, 'setup') assert.equal(ctx.einstellungen.firma, 'Donau Home') assert.deepEqual(ctx.einstellungen.stripeStatus, { charges_enabled: true }) assert.equal(ctx.project.$id, PROJEKT) assert.equal(await K.ladeFuerProjekt('c_fremd', PROJEKT), null) assert.equal(await K.ladeFuerProjekt(KUNDE, 'p_gibtsnicht'), null) const { updateDocument } = await import('../server/services/appwriteAdmin.js') await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' }) assert.equal(await K.ladeFuerProjekt(KUNDE, PROJEKT), null) }) // -------------------------------------------------------------------------------------------------- Jobs test('Jobs: im Modus off {aus:true}; Minutenjob ruft die Fristen ab, die anderen laufen leer durch', async () => { config.unterkunft.mode = 'off' assert.deepEqual(await jobs.minutenJob(T0), { aus: true }) assert.deepEqual(await jobs.viertelstundenJob(T0), { aus: true }) assert.deepEqual(await jobs.taeglicherJob(T0), { aus: true }) await leere() config.unterkunft.mode = 'on' const r = await jobs.minutenJob(T0) assert.notEqual(r?.aus, true) assert.notEqual(r?.fehler, true, JSON.stringify(r)) assert.deepEqual(await jobs.viertelstundenJob(T0), {}) assert.deepEqual(await jobs.taeglicherJob(T0), {}) }) // -------------------------------------------------------------------------------------------------- Webhook const SECRET = 'whsec_connect_test' const PFAD = '/api/shop/stripe-webhook' const ACCOUNT = 'acct_vermieter1' let nr = 0 const ereignis = (type, objekt, over = {}) => ({ id: `evt_uk${(nr += 1)}`, object: 'event', type, livemode: false, account: ACCOUNT, data: { object: objekt }, ...over }) test('Webhook: Checkout mit wkUk und Erstattung mit webklar=unterkunft gehen an verarbeiteUkEreignis, alles andere nicht', async () => { config.shop.connectWebhookSecret = SECRET const gesehen = [] W.setzeUkEreignisHandlerFuerTests(async (event) => void gesehen.push(event.type)) let r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_1', payment_status: 'paid', metadata: { wkUk: '1', anfrageId: 'a1' } }), { path: PFAD, secret: SECRET }) assert.equal(r.status, 200) r = await postWebhook(ereignis('charge.refunded', { id: 'ch_1', metadata: { webklar: 'unterkunft' } }), { path: PFAD, secret: SECRET }) assert.equal(r.status, 200) assert.deepEqual(gesehen, ['checkout.session.completed', 'charge.refunded']) // Shop-Sitzung und Fremdes: nicht an Uk r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_2', payment_status: 'paid', metadata: { wkShop: '1', orderId: 'o_gibtsnicht' } }), { path: PFAD, secret: SECRET }) assert.equal(r.status, 200) assert.deepEqual(gesehen, ['checkout.session.completed', 'charge.refunded'], 'Shop-Ereignis geht nicht an Uk') r = await postWebhook(ereignis('checkout.session.completed', { id: 'cs_3', payment_status: 'paid', metadata: {} }), { path: PFAD, secret: SECRET }) assert.equal(r.status, 200) assert.equal(gesehen.length, 2) }) test('Webhook: account.updated speichert den Status auch in ukEinstellungen mit demselben Konto (nur bei Modus ≠ off)', async () => { await leere() config.shop.connectWebhookSecret = SECRET aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: false }) }) aw.seed('ukEinstellungen', { $id: 'f_anders', featureId: 'f_anders', stripeAccountId: 'acct_anderer', stripeStatus: JSON.stringify({ charges_enabled: false }) }) const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } } const status = (id) => JSON.parse(aw.docs('ukEinstellungen').get(id).stripeStatus) config.unterkunft.mode = 'off' assert.equal((await postWebhook(ereignis('account.updated', konto), { path: PFAD, secret: SECRET })).status, 200) assert.equal(status(FEATURE).charges_enabled, false, 'off: keine Wirkung') config.unterkunft.mode = 'on' assert.equal((await postWebhook(ereignis('account.updated', konto), { path: PFAD, secret: SECRET })).status, 200) assert.equal(status(FEATURE).charges_enabled, true) assert.equal(status(FEATURE).payouts_enabled, true) assert.ok(status(FEATURE).geprueftAt) assert.equal(status('f_anders').charges_enabled, false, 'anderes Konto bleibt unberührt') assert.equal(await W.kontoAktualisiertUk('acct_unbekannt', konto, T0), 0) assert.equal(await W.kontoAktualisiertUk('kein-konto', konto, T0), 0) })