319 lines
18 KiB
JavaScript
319 lines
18 KiB
JavaScript
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 für unbekannte Seiten {aktiv:false}', 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 })
|
||
// Tagesjob: die Fristen gelten auch bei off, solange es Buchungsdaten gibt (dann laeuft er, sonst {aus:true})
|
||
const tagAus = await jobs.taeglicherJob(T0)
|
||
assert.ok(tagAus.aus === true || tagAus.aufbewahrung?.fehler === 0, JSON.stringify(tagAus))
|
||
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), { importe: 0, erfolg: 0, fehler: 0 })
|
||
const tag = await jobs.taeglicherJob(T0)
|
||
assert.equal(tag.aufbewahrung.fehler, 0, JSON.stringify(tag))
|
||
assert.equal(tag.abgeschaltet.fehler, 0)
|
||
})
|
||
|
||
// -------------------------------------------------------------------------------------------------- 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)
|
||
})
|