Buchungskalender: Verdrahtung, Router-Gerüste und API-Vertrag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:59:38 +00:00
1 parent 1d2ca2791d
commit a68baf10d9
37 files changed
+888 -30

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
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, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\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/<siteKey> 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)
})