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

+4 -4
View File
@@ -103,15 +103,15 @@ afterEach(() => {
// ---------------------------------------------------------------------------------------- Register ---
test('ticketFreischaltungKeys: Register-Schluessel mit ticketFreischaltung, in Registerreihenfolge', () => {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
NEWS.ticketFreischaltung = false
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'unterkunft', 'zeiten'])
NEWS.ticketFreischaltung = 'ja'
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'zeiten'], 'nur true zaehlt')
assert.deepEqual(reg.ticketFreischaltungKeys(), ['termine', 'shop', 'unterkunft', 'zeiten'], 'nur true zaehlt')
NEWS.ticketFreischaltung = true
TERMINE.ticketFreischaltung = false
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'shop', 'unterkunft', 'zeiten'])
} finally {
TERMINE.ticketFreischaltung = true
}
+3 -2
View File
@@ -122,11 +122,12 @@ test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet'
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
unterkunft: { name: 'Buchungskalender', keine: 'keinen Buchungskalender' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
})
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys())
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
})
// ---------------------------------------------------------------------------------- Schlüsselschutz ---
+2 -2
View File
@@ -75,7 +75,7 @@ test('liste_zustaende', async () => {
for (const p of body.projects) assert.deepEqual([p.features.zeiten.state, p.zeitenStatus, p.zeitenLeer], ['off', null, true])
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine', 'unterkunft', 'zeiten'])
assert.equal(body.features.shop.priceLabel, '299 €')
assert.equal(body.features.news.priceLabel, '30 €')
assert.equal(body.features.termine.priceLabel, '149 €')
@@ -118,7 +118,7 @@ test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Be
const { legalPaths } = await import('../server/services/legalDocs.js')
const V = config.termine.avvVersion
const body = await get('/api/funktionen', { cookie })
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'zeiten'])
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'shop', 'termine', 'unterkunft', 'zeiten'])
assert.equal(body.legal.news, null)
// die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen (avv und widerruf null): kein Eintrag wie beim Terminplaner
assert.equal(body.legal.zeiten, null)
+1 -1
View File
@@ -295,7 +295,7 @@ test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher'
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
+1 -1
View File
@@ -20,7 +20,7 @@ test('app.js: VIEW_HASHES, viewRefreshers, FEATURE_LABELS und APP_PARENT kennen
assert.match(app, /webshop: \(\) => import\('\/shop\.js'\)\.then\(\(m\) => m\.initShop\(document\.getElementById\('webshop-body'\)\)\)/)
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop folgt, zeiten (Öffnungszeiten live) steht am Ende
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', /)
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen', zeiten: 'funktionen' \}/)
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' \}/)
// die Ansicht ist vom Hash aus erreichbar: kein zweiter Schlüssel mit demselben Hash
const hashes = app.match(/VIEW_HASHES = \{([^}]*)\}/)[1]
assert.equal([...hashes.matchAll(/'webshop'/g)].length, 1)
+4 -3
View File
@@ -185,19 +185,20 @@ test('termine_ist_zweite_funktion: Schluessel und Bezeichnungen', () => {
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_LABELS, {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
shop: { name: 'Webshop', keine: 'keinen Webshop' },
zeiten: { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' },
unterkunft: { name: 'Buchungskalender', keine: 'keinen Buchungskalender' },
})
})
test('liste_zeigt_termine_je_projekt_ohne_dokument_als_off', async () => {
const list = await sf.listCustomerFeatures('c1')
const p = list.find((x) => x.projectId === 'pt1')
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(Object.keys(p.features), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.equal(p.features.termine.state, 'off')
assert.equal(p.features.termine.siteKey, null)
assert.equal(p.features.termine.ticketWoid, null)
@@ -263,7 +264,7 @@ test('activateClosedTickets: Funktionen mit ticketFreischaltung; News verhaelt s
const ids = ['f_setup_termine', 'f_setup_news', 'f_setup_analyse']
const reg = await import('../server/services/featureRegistry.js')
try {
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
// News wie bisher; Termine wartet auf Einstellung und Abo (Haken freischaltbar); Analyse hat keine ticketFreischaltung
const done = await sf.activateClosedTickets({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.featureKey), ['news'])
+1 -1
View File
@@ -32,7 +32,7 @@ test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () =>
})
test('KEYS enthaelt die Katalogschluessel der Einrichtungen (News, Terminplaner, Webshop, Oeffnungszeiten live)', () => {
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'zeitenSetup'])
assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup', 'shopSetup', 'unterkunftSetup', 'zeitenSetup'])
})
test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => {
+12
View File
@@ -1278,3 +1278,15 @@ test('verwerfeWartende lässt verworfene Einträge in Ruhe (sie halten keine Gas
assert.equal(await verwerfeWartende('b_gast'), 0)
assert.equal((await lies(doc.$id)).status, 'verworfen')
})
test('einreihen: optionaler Anhang-Verweis wird gespeichert (Senden unverändert), ohne Angabe leer, zu lang = TypeError', async () => {
const mit = await einreihen(eintrag({ bookingId: 'b_anhang', anhang: 'uk-rechnung:r_123' }), JETZT)
assert.equal((await lies(mit.$id)).anhang, 'uk-rechnung:r_123')
const ohne = await einreihen(eintrag({ bookingId: 'b_ohne' }), JETZT)
assert.equal((await lies(ohne.$id)).anhang, '')
await assert.rejects(() => einreihen(eintrag({ anhang: 'x'.repeat(501) }), JETZT), TypeError)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 2, 'Versand nicht verändert')
const attribut = (await import('../server/services/termine/schema.js')).TERMINE_COLLECTIONS.termineOutbox.attributes.find((a) => a.key === 'anhang')
assert.deepEqual(attribut, { key: 'anhang', type: 'string', size: 500, required: false })
})
+1 -1
View File
@@ -88,7 +88,7 @@ test('Schema: Attribute je Collection wie in der Spezifikation', () => {
])
assert.deepEqual(keysOf('termineClaims'), ['featureId', 'ressourceId', 'bookingId', 'zelle', 'bis'])
assert.deepEqual(keysOf('termineOutbox'), [
'featureId', 'bookingId', 'art', 'an', 'fromName', 'replyTo', 'subject', 'html', 'text', 'ics', 'icsMethod',
'featureId', 'bookingId', 'art', 'an', 'fromName', 'replyTo', 'subject', 'html', 'text', 'ics', 'icsMethod', 'anhang',
'status', 'versuche', 'naechsterVersuch', 'gesendetAt', 'fehler', 'createdAt',
])
})
+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)
})
+3 -3
View File
@@ -38,10 +38,10 @@ test('zeiten steht im Register mit 30 € und Abo-Inklusive', () => {
assert.equal(f.orderedConflictMessage, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.')
assert.equal(f.unavailableMessage, 'Öffnungszeiten live ist noch nicht verfügbar.')
// Funktionsschlüssel und Katalog stehen in den abgeleiteten Listen
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.featureKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
assert.deepEqual(sf.FEATURE_LABELS.zeiten, { name: 'Öffnungszeiten live', keine: 'keine Öffnungszeiten live' })
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'zeiten'])
assert.deepEqual(reg.ticketFreischaltungKeys(), ['news', 'termine', 'shop', 'unterkunft', 'zeiten'])
})
test('Zustimmung bezahlt: zwei Haken, Knopf „Zahlungspflichtig bestellen · 30 €“, § 356 Abs. 5 Nr. 2', () => {