Files
Webklar-Kundenbereich/test/unterkunftWiring.test.js
T

315 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { default: ukRoutes } = await import('../server/routes/unterkunft.js')
const app = await startTestApp({ '/api/unterkunft': ukRoutes, '/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)
})
test('Kunden-API: ohne Sitzung 401 (Router-weiter Guard)', async () => {
assert.equal((await rawGet('/api/unterkunft/status')).status, 401)
})
test('Webhook: Shop-Erstattung ohne Uk-Metadaten geht nicht an Uk', async () => {
config.shop.connectWebhookSecret = SECRET
const gesehen = []
W.setzeUkEreignisHandlerFuerTests(async (event) => void gesehen.push(event.type))
const r = await postWebhook(ereignis('charge.refunded', { id: 'ch_9', payment_intent: null, metadata: { webklar: 'shop' } }), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
assert.deepEqual(gesehen, [])
})
test('Webhook: ein Fehler im Uk-Teil von account.updated lässt den Shop-Teil gelten und die Antwort 200', async () => {
await leere()
config.shop.connectWebhookSecret = SECRET
config.unterkunft.mode = 'on'
aw.seed('shopConfig', { $id: 'f_shop1', customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: false }), einstellungen: '{}', rechtstexte: '{}', version: 1 })
// Uk-Abfrage scheitert: Index fehlt (400) bzw. Collection-Fehler
mock.method(console, 'warn', () => {})
aw.failNext({ collection: config.collections.ukEinstellungen, method: 'GET', status: 500 })
const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } }
const r = await postWebhook(ereignis('account.updated', konto), { path: PFAD, secret: SECRET })
assert.equal(r.status, 200)
assert.equal(JSON.parse(aw.docs('shopConfig').get('f_shop1').stripeStatus).charges_enabled, true)
})