Files
Webklar-Kundenbereich/test/unterkunftVerwaltung.test.js
T
knsoandClaude Opus 5.5 a4ade5f649 Buchungskalender: Preise je Personenzahl (Server)
Jede Staffel darf personenCent tragen (Preis pro Nacht für 1 bis k Personen, k = 2 bis 10), ohne Schemaänderung im JSON-Feld staffeln. pruefePreise prüft Länge, Werte und Gleichheit über alle Staffeln und setzt preisProNachtCent = personenCent[0]. berechnePreis wählt die Spalte der Gästezahl (Posten mit Personenzahl), bei mehr Gästen als Spalten gilt die letzte plus Zusatzperson-Aufpreis. preiseVollstaendig prüft die Konsistenz. Ohne personenCent rechnet alles wie bisher. Tests mit Mimis Preismatrix, Doku in unterkunft-api.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:02:45 +00:00

780 lines
49 KiB
JavaScript

import { test, after, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
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 8: Kunden-API fuer Vermieter (routes/unterkunft.js, services/unterkunft/verwaltung.js)
const NAMEN = ['portalFeatures', 'shopConfig', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen']
const aw = await startAppwriteMock({
schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])),
datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const mails = await import('../server/services/unterkunft/mails.js')
const zahlung = await import('../server/services/unterkunft/zahlung.js')
const { UkFehler } = await import('../server/services/unterkunft/fehler.js')
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen, STAFFELN_MAX_ZEICHEN } = await import('../server/services/unterkunft/verwaltung.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
const P = 'pu1'
const FEATURE = `f_${P}_unterkunft`
const cookie = customerCookie('c1')
const vorher = { ...config.unterkunft }
const q = `projectId=${P}`
const get = (pfad, opts = { cookie }) => send('GET', `/api/unterkunft${pfad}${pfad.includes('?') ? '&' : '?'}${q}`, opts)
const put = (pfad, body, opts = { cookie }) => send('PUT', `/api/unterkunft${pfad}?${q}`, { ...opts, body })
const post = (pfad, body = {}, opts = { cookie }) => send('POST', `/api/unterkunft${pfad}?${q}`, { ...opts, body })
const del = (pfad, opts = { cookie }) => send('DELETE', `/api/unterkunft${pfad}?${q}`, opts)
const EINST = {
firma: 'Donau Home', inhaber: 'Mimi', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com',
telefon: '', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'DH', akzentfarbe: '#112233',
}
const PREISE = {
staffeln: [{ abNaechten: 1, preisProNachtCent: 10000 }, { abNaechten: 7, preisProNachtCent: 9000 }],
reinigungCent: 5000, personenInklusive: 2, aufpreisPersonNachtCent: 1500, direktrabattProzent: 5, steuerart: 'regel',
ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vor Anreise, danach 50 Prozent.',
}
const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '0151', nachricht: 'Hallo' }
const POSTEN = [{ text: 'Übernachtung, 2 Nächte', menge: 2, einzelCent: 10000, ustProzent: 7 }]
beforeEach(async () => {
for (const c of [...NAMEN, 'websiteProjects']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
Object.assign(config.unterkunft, vorher, { mode: 'on' })
resetUnterkunftRoutesForTests()
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
st.calls.length = 0
aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Donau', liveDomain: 'donau-home.example' })
aw.seed('websiteProjects', { $id: 'pfremd', customerId: 'c2', projectName: 'Fremd' })
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST })
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: P, name: 'Erste', maxPersonen: 4, mindestNaechte: 1, checkIn: '15:00', checkOut: '10:00', sort: 1, aktiv: true, icalExportToken: 'tok1' })
aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, staffeln: JSON.stringify(PREISE.staffeln) })
})
afterEach(() => {
Object.assign(config.unterkunft, vorher)
})
const anfrage = (id, extra = {}) =>
aw.seed('ukAnfragen', {
$id: id, featureId: FEATURE, customerId: 'c1', projectId: P, unterkunftId: 'u1', nummer: `A-${id.toUpperCase()}`.slice(0, 12), von: '2026-11-10', bis: '2026-11-12', personen: 2,
status: 'neu', gast: JSON.stringify(GAST), tokenHash: 'GEHEIMER-HASH', ipHash: 'geheimip', erstattetCent: 0, ...extra,
})
// ------------------------------------------------------------------------------------------------ Zugriff
test('Zugriff: ohne Sitzung 401, fremdes Projekt 403, Funktion aus 409, Kundenansicht und Ansehen-als 403 bei Schreibzugriff', async () => {
assert.equal((await get('/status', {})).status, 401)
assert.equal((await send('GET', '/api/unterkunft/status?projectId=pfremd', { cookie })).status, 403)
assert.equal((await send('GET', '/api/unterkunft/status', { cookie })).status, 400)
assert.equal((await send('PUT', '/api/unterkunft/einstellungen?projectId=pfremd', { cookie, body: EINST })).status, 403)
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false })
const aus = await get('/unterkuenfte')
assert.equal(aus.status, 409)
assert.equal(aus.body.fehler, 'nicht_aktiv')
assert.equal((await get('/status')).body.state, 'off')
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
const kansicht = await post('/unterkuenfte', { name: 'X', maxPersonen: 2 }, { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'a'.repeat(32) } })
assert.equal(kansicht.status, 403)
const als = await post('/unterkuenfte', { name: 'X', maxPersonen: 2 }, { cookie: viewAsCookie() })
assert.ok([401, 403].includes(als.status))
assert.equal(aw.docs('ukUnterkuenfte').size, 1)
// Zustand bestellt: lesen ja, schreiben nein
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'ordered', enabled: false })
assert.equal((await get('/unterkuenfte')).status, 200)
assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409)
})
test('PUT /einstellungen leert den Speicher der oeffentlichen Schnittstelle: neue Firma sofort sichtbar', async () => {
const K = await import('../server/services/unterkunft/kontext.js')
K.leereCache()
assert.equal((await K.unterkunftPerSiteKey('abcdefghijklmnop')).einstellungen.firma, 'Donau Home')
assert.equal((await put('/einstellungen', { ...EINST, firma: 'Neue Firma' })).status, 200)
assert.equal((await K.unterkunftPerSiteKey('abcdefghijklmnop')).einstellungen.firma, 'Neue Firma')
})
test('Zugriff: Schalter off sperrt die Verwaltung', async () => {
config.unterkunft.mode = 'off'
assert.equal((await get('/unterkuenfte')).status, 409)
})
test('Zugriff: Schreibgrenze 300 je Stunde (429)', async () => {
let letzter
for (let i = 0; i < 301; i++) letzter = await post('/unterkuenfte/u1/ical-export/erneuern')
assert.equal(letzter.status, 429)
})
// ------------------------------------------------------------------------------------------------ Status
test('Status: Zustand, Stripe, Unterkuenfte, Steuerart, Rechnungen, Shop-Konto', async () => {
const r = await get('/status')
assert.deepEqual(r.body, { state: 'active', steuerart: 'regel', stripe: { status: 'keins', vorhandenesShopKonto: false }, onlineZahlung: false, unterkuenfte: 1, rechnungenVorhanden: false })
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' })
aw.seed('portalFeatures', { $id: `f_${P}_shop`, customerId: 'c1', projectId: P, featureKey: 'shop', siteKey: 'shopshopshopshop', state: 'active', enabled: true })
aw.seed('shopConfig', { $id: `f_${P}_shop`, stripeAccountId: 'acct_shop1', stripeStatus: JSON.stringify({ charges_enabled: true }) })
const r2 = (await get('/status')).body
assert.equal(r2.rechnungenVorhanden, true)
assert.deepEqual(r2.stripe, { status: 'keins', vorhandenesShopKonto: true })
assert.ok(!JSON.stringify(r2).includes('acct_'))
})
// ------------------------------------------------------------------------------------------------ Einstellungen
test('Einstellungen: ohne Steuernummer und USt-ID 400 felder.steuernummer', async () => {
const r = await put('/einstellungen', { ...EINST, steuernummer: '', ustId: '' })
assert.equal(r.status, 400)
assert.equal(r.body.fehler, 'ungueltig')
assert.ok(r.body.felder.steuernummer)
assert.equal(aw.docs('ukEinstellungen').get(FEATURE).steuernummer, EINST.steuernummer)
})
test('Einstellungen: speichern, Rueckgabe ohne Stripe-Konto-ID, USt-ID statt Steuernummer genuegt, Validierung je Feld', async () => {
const ok = await put('/einstellungen', { ...EINST, steuernummer: '', ustId: 'de 123456789', iban: 'DE89 3704 0044 0532 0130 00', firma: ' Haus ' })
assert.equal(ok.status, 200)
assert.equal(ok.body.einstellungen.ustId, 'DE123456789')
assert.equal(ok.body.einstellungen.iban, 'DE89370400440532013000')
assert.equal(ok.body.einstellungen.firma, 'Haus')
assert.equal(ok.body.einstellungen.stripeAccountId, undefined)
assert.equal(ok.body.einstellungen.rechnungAuftrag, true)
const schlecht = await put('/einstellungen', { email: 'kaputt', akzentfarbe: 'rot', iban: 'x', rechnungsPraefix: 'A-B' })
assert.equal(schlecht.status, 400)
for (const f of ['email', 'akzentfarbe', 'iban', 'rechnungsPraefix']) assert.ok(schlecht.body.felder[f], f)
const g = await get('/einstellungen')
assert.equal(g.body.einstellungen.firma, 'Haus')
assert.equal(g.body.rechnungenVorhanden, false)
assert.deepEqual(g.body.stripe, { status: 'keins', vorhandenesShopKonto: false })
assert.deepEqual(Object.keys(g.body).sort(), ['einstellungen', 'rechnungenVorhanden', 'stripe'])
})
test('Einstellungen: Praefix nach der ersten Rechnung 409, gleicher Wert bleibt erlaubt', async () => {
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungZaehler: JSON.stringify({ jahr: 2026, n: 1 }) })
const r = await put('/einstellungen', { ...EINST, rechnungsPraefix: 'NEU' })
assert.equal(r.status, 409)
assert.equal(r.body.fehler, 'praefix_gesperrt')
assert.ok(r.body.felder.rechnungsPraefix)
assert.equal(aw.docs('ukEinstellungen').get(FEATURE).rechnungsPraefix, 'DH')
assert.equal((await put('/einstellungen', { ...EINST, firma: 'Neu GmbH' })).status, 200)
aw.seed('ukRechnungen', { $id: 'r9', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 1, inhalt: '{}' })
assert.equal((await get('/einstellungen')).body.rechnungenVorhanden, true)
})
// ------------------------------------------------------------------------------------------------ Unterkuenfte
test('Unterkunft anlegen, sortieren, deaktivieren; Liste mit Export-URL und Importen', async () => {
const neu = await post('/unterkuenfte', { name: 'Zweite', beschreibung: 'Klein', maxPersonen: 2, mindestNaechte: 2, checkIn: '16:00', checkOut: '11:00' })
assert.equal(neu.status, 201)
const u = neu.body.unterkunft
assert.equal(u.sort, 2)
assert.equal(u.aktiv, true)
assert.match(u.icalExportUrl, new RegExp(`^${config.publicBaseUrl}/unterkunft/ical/[A-Za-z0-9_-]{43}\\.ics$`))
assert.deepEqual(u.icalImporte, [])
const sort = await put(`/unterkuenfte/${u.id}`, { sort: 0 })
assert.equal(sort.body.unterkunft.sort, 0)
assert.equal(sort.body.unterkunft.name, 'Zweite')
const aus = await put(`/unterkuenfte/${u.id}`, { aktiv: false })
assert.equal(aus.body.unterkunft.aktiv, false)
const liste = (await get('/unterkuenfte')).body.unterkuenfte
assert.deepEqual(liste.map((x) => x.id), [u.id, 'u1'])
assert.equal(liste[1].icalExportUrl, `${config.publicBaseUrl}/unterkunft/ical/tok1.ics`)
assert.equal(aw.docs('ukUnterkuenfte').get(u.id).customerId, 'c1')
assert.equal(aw.docs('ukUnterkuenfte').get(u.id).featureId, FEATURE)
})
test('Unterkunft: Validierung (Name, Personen, Uhrzeit), fremde und unsichere IDs 404', async () => {
const r = await post('/unterkuenfte', { name: '', maxPersonen: 0, checkIn: '25:00' })
assert.equal(r.status, 400)
for (const f of ['name', 'maxPersonen', 'checkIn']) assert.ok(r.body.felder[f], f)
aw.seed('ukUnterkuenfte', { $id: 'ufremd', featureId: 'anderes', name: 'Fremd', aktiv: true })
assert.equal((await put('/unterkuenfte/ufremd', { name: 'Hack' })).status, 404)
assert.equal(aw.docs('ukUnterkuenfte').get('ufremd').name, 'Fremd')
assert.equal((await put('/unterkuenfte/..%2Fx', { name: 'Hack' })).status, 404)
assert.equal((await get('/unterkuenfte/ufremd/preise')).status, 404)
assert.equal((await del('/unterkuenfte/ufremd')).status, 404)
})
test('Unterkunft loeschen: nur ohne Anfragen', async () => {
anfrage('a1')
assert.equal((await del('/unterkuenfte/u1')).status, 409)
await deleteDocument(config.collections.ukAnfragen, 'a1')
assert.equal((await del('/unterkuenfte/u1')).status, 200)
assert.equal(aw.docs('ukUnterkuenfte').size, 0)
assert.equal(aw.docs('ukPreise').size, 0)
})
// ------------------------------------------------------------------------------------------------ Preise
test('Preise: lesen, speichern, null wenn nichts gespeichert', async () => {
aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, name: 'Zweite', maxPersonen: 2, aktiv: true })
assert.equal((await get('/unterkuenfte/u2/preise')).body.preise, null)
const g = await get('/unterkuenfte/u1/preise')
assert.deepEqual(g.body.preise, PREISE)
const r = await put('/unterkuenfte/u2/preise', { ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 7 })
assert.equal(r.status, 200)
assert.equal(r.body.preise.steuerart, 'kleinunternehmer')
assert.equal(r.body.preise.ustUebernachtung, 0)
assert.equal(aw.docs('ukPreise').size, 2)
})
test('Preise: erste Staffel abNaechten:2, absteigend, mehr als 6, Preisgrenzen, Rabatt, Frist, Stornotext -> 400', async () => {
const fall = (extra) => put('/unterkuenfte/u1/preise', { ...PREISE, ...extra })
const staffel = (n, p = 10000) => ({ abNaechten: n, preisProNachtCent: p })
const faelle = [
[{ staffeln: [staffel(2)] }, 'staffeln'],
[{ staffeln: [staffel(1), staffel(7), staffel(3)] }, 'staffeln'],
[{ staffeln: [1, 2, 3, 4, 5, 6, 7].map((n) => staffel(n)) }, 'staffeln'],
[{ staffeln: [staffel(1, 99)] }, 'staffeln'],
[{ staffeln: [staffel(1, 100001)] }, 'staffeln'],
[{ staffeln: [] }, 'staffeln'],
[{ direktrabattProzent: 51 }, 'direktrabattProzent'],
[{ fristTage: 0 }, 'fristTage'],
[{ fristTage: 15 }, 'fristTage'],
[{ stornobedingungen: 'kurz' }, 'stornobedingungen'],
[{ stornobedingungen: 'x'.repeat(5001) }, 'stornobedingungen'],
[{ steuerart: 'egal' }, 'steuerart'],
[{ ustUebernachtung: 5 }, 'ustUebernachtung'],
]
for (const [extra, feld] of faelle) {
const r = await fall(extra)
assert.equal(r.status, 400, JSON.stringify(extra).slice(0, 60))
assert.ok(r.body.felder[feld], feld)
}
assert.deepEqual(aw.docs('ukPreise').get('pr1').staffeln, JSON.stringify(PREISE.staffeln))
assert.equal((await fall({ staffeln: [1, 2, 3, 4, 5, 6].map((n) => staffel(n)) })).status, 200)
assert.ok(pruefePreise(PREISE).werte)
})
// Preise je Personenzahl (staffeln[].personenCent)
const MIMI_PREISE = {
...PREISE,
staffeln: [
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
{ abNaechten: 14, preisProNachtCent: 4500, personenCent: [4500, 6000, 7200] },
{ abNaechten: 28, preisProNachtCent: 4000, personenCent: [4000, 5500, 6700] },
],
reinigungCent: 4000, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer',
}
const mitStaffeln = (staffeln) => pruefePreise({ ...MIMI_PREISE, staffeln })
const einzelne = (personenCent, extra = {}) => mitStaffeln([{ abNaechten: 1, preisProNachtCent: personenCent?.[0] ?? 5500, personenCent, ...extra }])
test('pruefePreise: Preise je Personenzahl gueltig, personenCent wird sauber uebernommen', () => {
const r = pruefePreise(MIMI_PREISE)
assert.deepEqual(r.fehler, undefined)
assert.deepEqual(r.werte.staffeln, MIMI_PREISE.staffeln)
assert.equal(r.werte.staffeln[0].personenCent !== MIMI_PREISE.staffeln[0].personenCent, true, 'Kopie, nicht dasselbe Array')
// Grenzen: 2 und 10 Werte, 100 und 100000 Cent
assert.ok(einzelne([100, 100000]).werte)
assert.ok(einzelne(Array(10).fill(100000)).werte)
// unbekannte Felder in der Staffel fallen weg
const mitMuell = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000], x: 1 }])
assert.deepEqual(mitMuell.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
// alter Modus bleibt: kein personenCent im Ergebnis, auch bei null
const alt = pruefePreise(PREISE)
assert.deepEqual(alt.werte.staffeln, PREISE.staffeln)
assert.ok(alt.werte.staffeln.every((s) => !('personenCent' in s)))
const mitNull = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: null }])
assert.deepEqual(mitNull.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }])
})
test('pruefePreise: preisProNachtCent wird im Personen-Modus mit personenCent[0] ueberschrieben', () => {
const r = einzelne([5500, 7000, 8200], { preisProNachtCent: 9999 })
assert.equal(r.werte.staffeln[0].preisProNachtCent, 5500)
const ohne = mitStaffeln([{ abNaechten: 1, personenCent: [5500, 7000] }])
assert.equal(ohne.werte.staffeln[0].preisProNachtCent, 5500)
})
test('pruefePreise: personenCent ungueltig -> klare Meldung unter staffeln', () => {
const faelle = {
'Laenge 1': [5500],
'Laenge 11': Array(11).fill(5500),
'leer': [],
'Wert 99': [5500, 99],
'Wert 100001': [5500, 100001],
'Kommazahl': [5500, 7000.5],
'Text': [5500, '7000'],
'null': [5500, null],
'NaN': [5500, NaN],
'kein Array': 'viele',
'Objekt': { 0: 5500, 1: 7000 },
'Zahl': 5500,
}
for (const [name, wert] of Object.entries(faelle)) {
const r = einzelne(wert)
assert.ok(r.fehler?.staffeln, name)
assert.ok(/\S{3,}/.test(r.fehler.staffeln), name)
assert.equal(Object.keys(r.fehler).length, 1, `${name}: nur das Feld staffeln`)
}
assert.match(einzelne([5500]).fehler.staffeln, /2 bis 10/)
assert.match(einzelne([5500, 99]).fehler.staffeln, /1 € und 1\.000 €/)
})
test('pruefePreise: Staffeln mit unterschiedlich vielen oder teils fehlenden personenCent -> Fehler', () => {
const s = (ab, pc) => ({ abNaechten: ab, preisProNachtCent: 5000, ...(pc ? { personenCent: pc } : {}) })
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7, [5000, 6000])]).fehler.staffeln, /gleich viele/)
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7)]).fehler.staffeln, /alle Preisstufen/i)
assert.match(mitStaffeln([s(1), s(7, [5000, 6000, 7000])]).fehler.staffeln, /alle Preisstufen/i)
assert.ok(mitStaffeln([s(1, [5000, 6000]), s(7, [4500, 5500])]).werte)
})
test('pruefePreise: bestehende Staffel-Regeln gelten auch im Personen-Modus', () => {
const s = (ab, pc = [5500, 7000]) => ({ abNaechten: ab, preisProNachtCent: pc[0], personenCent: pc })
assert.match(mitStaffeln([s(2)]).fehler.staffeln, /ab 1 Nacht/)
assert.match(mitStaffeln([s(1), s(7), s(3)]).fehler.staffeln, /aufsteigend/)
assert.match(mitStaffeln([s(1), s(1)]).fehler.staffeln, /aufsteigend/)
assert.match(mitStaffeln([1, 2, 3, 4, 5, 6, 7].map((n) => s(n))).fehler.staffeln, /Höchstens 6/)
assert.ok(mitStaffeln([1, 2, 3, 4, 5, 6].map((n) => s(n))).werte)
assert.match(mitStaffeln([{ abNaechten: 1.5, personenCent: [5500, 7000] }]).fehler.staffeln, /Ungültige Preisstufe/)
})
test('pruefePreise: schlimmster Fall (6 Staffeln x 10 Werte x 100000, ab 365) passt in das Textfeld staffeln (2000 Zeichen)', async () => {
const { UNTERKUNFT_COLLECTIONS } = await import('../server/services/unterkunft/schema.js')
const groesse = UNTERKUNFT_COLLECTIONS.ukPreise.attributes.find((a) => a.key === 'staffeln').size
assert.equal(STAFFELN_MAX_ZEICHEN, groesse, 'Konstante und Schema stimmen ueberein')
const staffeln = [360, 361, 362, 363, 364, 365].map((ab, i) => ({ abNaechten: i === 0 ? 1 : ab, preisProNachtCent: 100000, personenCent: Array(10).fill(100000) }))
const r = mitStaffeln(staffeln)
assert.ok(r.werte, JSON.stringify(r.fehler))
assert.ok(JSON.stringify(r.werte.staffeln).length <= groesse, `${JSON.stringify(r.werte.staffeln).length} > ${groesse}`)
assert.ok(JSON.stringify(r.werte.staffeln).length < 1200, 'deutlich unter der Grenze')
})
test('Preise (HTTP): Personen-Modus speichern, lesen, Vorschau; preisProNachtCent wird ueberschrieben', async () => {
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: MIMI_PREISE.staffeln.map((s) => ({ ...s, preisProNachtCent: 1234 })) })
assert.equal(r.status, 200, JSON.stringify(r.body))
assert.deepEqual(r.body.preise.staffeln, MIMI_PREISE.staffeln)
assert.deepEqual((await get('/unterkuenfte/u1/preise')).body.preise.staffeln, MIMI_PREISE.staffeln)
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), MIMI_PREISE.staffeln)
const v = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-08', personen: 3 })
assert.equal(v.status, 200)
assert.equal(v.body.summen.bruttoCent, 7 * 7700 + 4000)
assert.equal(v.body.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
// zurueck in den alten Modus: ohne personenCent gespeichert
const alt = await put('/unterkuenfte/u1/preise', PREISE)
assert.equal(alt.status, 200)
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), PREISE.staffeln)
})
test('Preise (HTTP): ungueltige personenCent -> 400 felder.staffeln, gespeicherte Preise bleiben', async () => {
const vorherText = aw.docs('ukPreise').get('pr1').staffeln
for (const pc of [[5500], Array(11).fill(5500), [5500, 99], [5500, 100001], [5500, 'x']]) {
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: pc }] })
assert.equal(r.status, 400, JSON.stringify(pc))
assert.ok(r.body.felder.staffeln, JSON.stringify(pc))
}
const ungleich = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [MIMI_PREISE.staffeln[0], { ...MIMI_PREISE.staffeln[1], personenCent: [5000, 6500] }] })
assert.equal(ungleich.status, 400)
assert.equal(aw.docs('ukPreise').get('pr1').staffeln, vorherText)
})
test('Preisvorschau: berechnePreis mit gespeicherten Preisen; ohne Preise 409; ungueltige Eingaben 400', async () => {
const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 })
assert.equal(r.status, 200)
assert.equal(r.body.naechte, 2)
assert.equal(r.body.posten[0].einzelCent, 10000)
assert.equal(r.body.posten.length, 4)
assert.equal(typeof r.body.summen.bruttoCent, 'number')
assert.equal((await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-03', bis: '2026-12-01', personen: 2 })).status, 400)
assert.equal((await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 0 })).status, 400)
assert.equal((await post('/preisvorschau', { unterkunftId: 'nix', von: '2026-12-01', bis: '2026-12-03', personen: 1 })).status, 404)
aw.seed('ukUnterkuenfte', { $id: 'u2', featureId: FEATURE, name: 'Leer', maxPersonen: 2, aktiv: true })
const leer = await post('/preisvorschau', { unterkunftId: 'u2', von: '2026-12-01', bis: '2026-12-03', personen: 1 })
assert.equal(leer.status, 409)
assert.equal(leer.body.fehler, 'preise_unvollstaendig')
})
// ------------------------------------------------------------------------------------------------ Kalender, Sperren, iCal
test('Kalender: Sperren mit notiz/sperreId, Anfragen mit anfrageNummer, iCal-Quelle; zugeschnitten auf das Fenster', async () => {
aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-03', bis: '2026-11-06', quelle: 'manuell', notiz: 'Eigennutzung' })
aw.seed('ukSperren', { $id: 's2', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-20', bis: '2026-11-22', quelle: 'ical:abc123', notiz: '' })
anfrage('a1', { status: 'gebucht', von: '2026-11-10', bis: '2026-11-12' })
anfrage('a2', { status: 'bezahlt', von: '2026-11-12', bis: '2026-11-14' })
anfrage('a3', { status: 'freigegeben', von: '2026-11-15', bis: '2026-11-17', fristAt: new Date(Date.now() + 86400000).toISOString() })
anfrage('a4', { status: 'freigegeben', von: '2026-11-25', bis: '2026-11-27', fristAt: new Date(Date.now() - 1000).toISOString() })
anfrage('a5', { status: 'neu', von: '2026-11-28', bis: '2026-11-29' })
const r = await get('/unterkuenfte/u1/kalender?von=2026-11-01&bis=2026-11-30')
assert.equal(r.status, 200)
const kurz = r.body.bereiche.map((b) => [b.von, b.bis, b.quelle])
assert.deepEqual(kurz, [
['2026-11-03', '2026-11-06', 'manuell'],
['2026-11-10', '2026-11-12', 'gebucht'],
['2026-11-12', '2026-11-14', 'gebucht'],
['2026-11-15', '2026-11-17', 'reserviert'],
['2026-11-20', '2026-11-22', 'ical:abc123'],
])
assert.equal(r.body.bereiche[0].notiz, 'Eigennutzung')
assert.equal(r.body.bereiche[0].sperreId, 's1')
assert.match(r.body.bereiche[1].anfrageNummer, /^A-/)
const klein = await get('/unterkuenfte/u1/kalender?von=2026-11-04&bis=2026-11-05')
assert.deepEqual(klein.body.bereiche.map((b) => [b.von, b.bis]), [['2026-11-04', '2026-11-05']])
assert.equal((await get('/unterkuenfte/u1/kalender?von=2026-11-05&bis=2026-11-04')).status, 400)
assert.equal((await get('/unterkuenfte/u1/kalender?von=2026-01-01&bis=2026-12-31')).status, 400)
})
test('Sperren: anlegen 201 (bis exklusiv), ueber gebuchte Nacht 409 zeitraum_belegt, Grenztag frei, loeschen nur manuell und nur eigene', async () => {
anfrage('a1', { status: 'gebucht', von: '2026-11-10', bis: '2026-11-12' })
const ok = await post('/unterkuenfte/u1/sperren', { von: '2026-11-05', bis: '2026-11-10', notiz: 'Renovierung' })
assert.equal(ok.status, 201)
assert.equal(ok.body.sperre.notiz, 'Renovierung')
const belegt = await post('/unterkuenfte/u1/sperren', { von: '2026-11-11', bis: '2026-11-13' })
assert.equal(belegt.status, 409)
assert.equal(belegt.body.fehler, 'zeitraum_belegt')
assert.equal((await post('/unterkuenfte/u1/sperren', { von: '2026-11-12', bis: '2026-11-13' })).status, 201)
assert.equal((await post('/unterkuenfte/u1/sperren', { von: '2026-11-13', bis: '2026-11-13' })).status, 400)
assert.equal((await post('/unterkuenfte/u1/sperren', { von: 'morgen', bis: '2026-11-13' })).status, 400)
assert.equal((await del(`/sperren/${ok.body.sperre.id}`)).status, 200)
assert.equal(aw.docs('ukSperren').has(ok.body.sperre.id), false)
aw.seed('ukSperren', { $id: 'sfremd', unterkunftId: 'ux', featureId: 'anderes', von: '2026-11-01', bis: '2026-11-02', quelle: 'manuell' })
assert.equal((await del('/sperren/sfremd')).status, 404)
aw.seed('ukSperren', { $id: 'sical', unterkunftId: 'u1', featureId: FEATURE, von: '2027-01-01', bis: '2027-01-02', quelle: 'ical:x1' })
assert.equal((await del('/sperren/sical')).status, 409)
assert.equal((await del('/sperren/..%2F..')).status, 404)
})
test('iCal-Import: nur https, max 500 Zeichen, Feldfehler felder.url; anlegen 201, Liste, loeschen entfernt dessen Sperren; Export erneuern', async () => {
const schlecht = await post('/unterkuenfte/u1/ical-importe', { name: 'Booking', url: 'http://booking.example/x.ics' })
assert.equal(schlecht.status, 400)
assert.ok(schlecht.body.felder.url)
assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: 'B', url: `https://x.example/${'a'.repeat(500)}` })).body.felder.url)
assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: 'B', url: 'https://user:pw@x.example/a.ics' })).body.felder.url)
assert.ok((await post('/unterkuenfte/u1/ical-importe', { name: '', url: 'https://x.example/a.ics' })).body.felder.name)
const ok = await post('/unterkuenfte/u1/ical-importe', { name: 'Booking', url: 'https://ical.booking.example/a.ics' })
assert.equal(ok.status, 201)
const id = ok.body.import.id
const liste = (await get('/unterkuenfte')).body.unterkuenfte[0]
assert.deepEqual(liste.icalImporte, [{ id, name: 'Booking', url: 'https://ical.booking.example/a.ics', letzterAbrufAt: null, letzterErfolgAt: null, fehler: '' }])
aw.seed('ukSperren', { $id: 'si', unterkunftId: 'u1', featureId: FEATURE, von: '2026-12-01', bis: '2026-12-03', quelle: `ical:${id}` })
aw.seed('ukSperren', { $id: 'sm', unterkunftId: 'u1', featureId: FEATURE, von: '2026-12-10', bis: '2026-12-13', quelle: 'manuell' })
assert.equal((await del(`/unterkuenfte/u1/ical-importe/${id}`)).status, 200)
assert.equal(aw.docs('ukSperren').has('si'), false)
assert.equal(aw.docs('ukSperren').has('sm'), true)
assert.equal((await del(`/unterkuenfte/u1/ical-importe/${id}`)).status, 404)
const neu = await post('/unterkuenfte/u1/ical-export/erneuern')
assert.equal(neu.status, 200)
assert.match(neu.body.icalExportUrl, /\/unterkunft\/ical\/[A-Za-z0-9_-]{43}\.ics$/)
assert.notEqual(neu.body.icalExportUrl, `${config.publicBaseUrl}/unterkunft/ical/tok1.ics`)
assert.equal(pruefeIcalUrl('https://x.example/a.ics').werte.url, 'https://x.example/a.ics')
assert.ok(pruefeIcalUrl('ftp://x.example/a').fehler)
})
test('iCal-Import: hoechstens 5 je Unterkunft', async () => {
for (let i = 0; i < 5; i++) assert.equal((await post('/unterkuenfte/u1/ical-importe', { name: `K${i}`, url: `https://x.example/${i}.ics` })).status, 201)
const r = await post('/unterkuenfte/u1/ical-importe', { name: 'Zu viel', url: 'https://x.example/6.ics' })
assert.equal(r.status, 409)
})
// ------------------------------------------------------------------------------------------------ Anfragen
test('Anfragen: Liste mit Filter und ohne Geheimnisse; Detail mit Vorschlag', async () => {
anfrage('a1', { status: 'neu' })
anfrage('a2', { status: 'gebucht', summen: JSON.stringify({ bruttoCent: 21400, nettoCent: 20000, ust: [] }) })
aw.seed('ukAnfragen', { $id: 'afremd', featureId: 'anderes', unterkunftId: 'u1', status: 'neu', nummer: 'A-FREMD', gast: JSON.stringify(GAST), von: '2026-11-10', bis: '2026-11-12' })
const alle = (await get('/anfragen')).body.anfragen
assert.deepEqual(alle.map((a) => a.id).sort(), ['a1', 'a2'])
assert.deepEqual((await get('/anfragen?filter=offen')).body.anfragen.map((a) => a.id), ['a1'])
assert.deepEqual((await get('/anfragen?filter=gebucht')).body.anfragen.map((a) => a.id), ['a2'])
assert.equal((await get('/anfragen?filter=quatsch')).status, 400)
const eintrag = alle.find((a) => a.id === 'a2')
assert.deepEqual(eintrag, { id: 'a2', nummer: 'A-A2', von: '2026-11-10', bis: '2026-11-12', naechte: 2, personen: 2, gast: { name: 'Eva Gast' }, status: 'gebucht', summen: { bruttoCent: 21400 }, unterkunftName: 'Erste' })
assert.equal(alle.find((a) => a.id === 'a1').summen, null)
const d = await get('/anfragen/a1')
assert.equal(d.status, 200)
assert.equal(d.body.anfrage.unterkunftName, 'Erste')
assert.deepEqual(d.body.anfrage.gast, GAST)
assert.equal(d.body.anfrage.steuerart, 'regel')
assert.equal(d.body.anfrage.rechnungNummer, null)
assert.ok(Array.isArray(d.body.vorschlag.posten) && d.body.vorschlag.posten.length >= 1)
assert.equal(d.body.vorschlag.summen.bruttoCent > 0, true)
for (const antwort of [JSON.stringify(d.body), JSON.stringify(alle)]) {
for (const geheim of ['GEHEIMER-HASH', 'geheimip', 'tokenHash', 'ipHash']) assert.ok(!antwort.includes(geheim), geheim)
}
assert.equal((await get('/anfragen/afremd')).status, 404)
assert.equal((await get('/anfragen/..%2Fx')).status, 404)
assert.equal((await get('/anfragen/nix')).status, 404)
})
test('Anfragen im Personen-Modus: Vorschlag und Freigabe ohne Posten rechnen mit der Spalte der Gaestezahl', async () => {
aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...MIMI_PREISE, staffeln: JSON.stringify(MIMI_PREISE.staffeln) })
anfrage('a1', { status: 'neu', von: '2026-11-01', bis: '2026-11-08', personen: 3 })
const d = await get('/anfragen/a1')
assert.equal(d.status, 200)
assert.deepEqual(d.body.vorschlag.posten.map((p) => [p.text, p.menge, p.einzelCent]), [['Übernachtung, 7 Nächte, 3 Personen', 7, 7700], ['Endreinigung', 1, 4000]])
assert.equal(d.body.vorschlag.summen.bruttoCent, 57900)
// Freigabe ohne eigene Posten uebernimmt die berechneten
const f = await post('/anfragen/a1/freigeben', {})
assert.equal(f.status, 200, JSON.stringify(f.body))
assert.equal(f.body.anfrage.summen.bruttoCent, 57900)
assert.equal(f.body.anfrage.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
})
test('Freigeben: Posten + Frist, Antwort mit buchungsLink (Token passt zum Hash), nie tokenHash; Anfrage freigegeben', async () => {
anfrage('a1')
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' })
assert.equal(r.status, 200)
assert.equal(r.body.anfrage.status, 'freigegeben')
assert.equal(r.body.anfrage.nachrichtVermieter, 'Bis bald')
assert.deepEqual(r.body.anfrage.posten, POSTEN)
assert.equal(r.body.anfrage.summen.bruttoCent, 20000)
assert.ok(r.body.anfrage.fristAt)
const m = new RegExp(`^${config.publicBaseUrl}/unterkunft/abcdefghijklmnop/buchung/a1\\?t=([A-Za-z0-9_-]{43})$`).exec(r.body.buchungsLink)
assert.ok(m, r.body.buchungsLink)
const { tokenPasst } = await import('../server/services/unterkunft/token.js')
assert.equal(tokenPasst(aw.docs('ukAnfragen').get('a1').tokenHash, m[1]), true)
const roh = JSON.stringify(r.body)
assert.ok(!roh.includes('tokenHash') && !roh.includes(aw.docs('ukAnfragen').get('a1').tokenHash) && !roh.includes('ipHash'))
assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN })).status, 409)
})
test('Freigeben (Review 5): leere Posten, Summe <= 0, Text 201 Zeichen, 31 Posten, ust 7 bei Kleinunternehmer, Frist -> je 400, Anfrage bleibt neu', async () => {
anfrage('a1')
const p = (extra = {}) => [{ ...POSTEN[0], ...extra }]
const bleibtNeu = () => assert.equal(aw.docs('ukAnfragen').get('a1').status, 'neu')
for (const posten of [[], p({ einzelCent: 0 }), p({ einzelCent: -100 }), p({ text: 'x'.repeat(201) }), Array.from({ length: 31 }, () => POSTEN[0]), p({ ustProzent: 5 }), p({ menge: 0 }), 'kaputt']) {
const r = await post('/anfragen/a1/freigeben', { posten })
assert.equal(r.status, 400, JSON.stringify(posten).slice(0, 50))
assert.equal(r.body.fehler, 'ungueltig')
assert.ok(r.body.felder.posten)
bleibtNeu()
}
assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 15 })).status, 400)
assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 0 })).status, 400)
assert.equal((await post('/anfragen/a1/freigeben', { posten: POSTEN, nachricht: 'x'.repeat(2001) })).status, 400)
bleibtNeu()
// Kleinunternehmer: ust 7 abgelehnt
aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, steuerart: 'kleinunternehmer', ustUebernachtung: 0, ustReinigung: 0, staffeln: JSON.stringify(PREISE.staffeln) })
const klein = await post('/anfragen/a1/freigeben', { posten: p({ ustProzent: 7 }) })
assert.equal(klein.status, 400)
assert.ok(klein.body.felder.posten)
bleibtNeu()
// ohne Posten: Vorschlag aus den Preisen
const vorschlag = await post('/anfragen/a1/freigeben', {})
assert.equal(vorschlag.status, 200)
assert.equal(vorschlag.body.anfrage.status, 'freigegeben')
})
test('Freigeben: belegter Zeitraum 409 zeitraum_belegt', async () => {
anfrage('a1')
anfrage('a2', { status: 'gebucht' })
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN })
assert.equal(r.status, 409)
assert.equal(r.body.fehler, 'zeitraum_belegt')
})
test('Ablehnen, als bezahlt markieren, Stornieren (ohne Erstattung)', async () => {
anfrage('a1')
const ab = await post('/anfragen/a1/ablehnen', { text: 'Leider belegt.' })
assert.equal(ab.status, 200)
assert.equal(ab.body.anfrage.status, 'abgelehnt')
assert.equal(ab.body.anfrage.nachrichtVermieter, 'Leider belegt.')
assert.equal((await post('/anfragen/a1/ablehnen', { text: 'x'.repeat(2001) })).status, 400)
anfrage('a2', { status: 'gebucht', rechnungId: 'r1', summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 18000, ust: [], steuerart: 'regel' }), posten: JSON.stringify(POSTEN) })
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a2', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify({ anfrageNummer: 'A-A2', empfaenger: { name: 'Eva' }, summen: { bruttoCent: 20000, nettoCent: 18000, ust: [] }, posten: POSTEN }) })
const bez = await post('/anfragen/a2/bezahlt')
assert.equal(bez.status, 200)
assert.equal(bez.body.anfrage.status, 'bezahlt')
assert.equal(bez.body.anfrage.bezahltVia, 'vorort')
assert.equal(bez.body.anfrage.rechnungNummer, 'DH-2026-0001')
const sto = await post('/anfragen/a2/stornieren', { grund: 'Gast krank', erstattungCent: 0 })
assert.equal(sto.status, 200)
assert.equal(sto.body.anfrage.status, 'storniert')
assert.ok(sto.body.anfrage.stornoRechnungId)
assert.equal(sto.body.stornoRechnungId, sto.body.anfrage.stornoRechnungId)
assert.equal((await post('/anfragen/a2/stornieren', { erstattungCent: -5 })).status, 400)
assert.equal((await post('/anfragen/afremd/stornieren')).status, 404)
})
test('Stornieren: Erstattung fehlgeschlagen -> 502 erstattung_fehlgeschlagen mit Hinweis, Storno gilt', async () => {
anfrage('a3', {
status: 'bezahlt', bezahltVia: 'stripe', stripePaymentIntentId: 'pi_x1', rechnungId: 'r3',
summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 18000, ust: [], steuerart: 'regel' }), posten: JSON.stringify(POSTEN),
})
aw.seed('ukRechnungen', { $id: 'r3', featureId: FEATURE, anfrageId: 'a3', art: 'rechnung', nummer: 'DH-2026-0002', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify({ anfrageNummer: 'A-A3' }) })
zahlung.setzeErstattungFuerTests(async () => {
throw new UkFehler(502, 'zahlung_fehlgeschlagen')
})
try {
const r = await post('/anfragen/a3/stornieren', { grund: 'x', erstattungCent: 5000 })
assert.equal(r.status, 502)
assert.equal(r.body.fehler, 'erstattung_fehlgeschlagen')
assert.match(r.body.meldung, /Stornierung wurde durchgeführt/)
assert.equal(aw.docs('ukAnfragen').get('a3').status, 'storniert')
} finally {
zahlung.setzeErstattungFuerTests(null)
}
})
// ------------------------------------------------------------------------------------------------ Rechnungen
test('Rechnungen: im Zustand abgeschaltet bleiben Liste und PDF lesbar, Schreiben ist gesperrt (Rechnungen sichern vor der Loeschung)', async () => {
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' }, aussteller: { firma: 'Haus' }, posten: [], summen: { bruttoCent: 100, nettoCent: 93, ust: [] } }) })
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledAt: '2026-10-01T00:00:00.000Z' })
const liste = await get('/rechnungen')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.rechnungen.map((r) => r.id), ['r1'])
const pdf = await get('/rechnungen/r1.pdf')
assert.equal(pdf.status, 200)
assert.equal(pdf.headers.get('content-type'), 'application/pdf')
assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409)
})
test('Rechnungen: Liste nur des Projekts; PDF fremde Rechnung 404, eigene 200 als PDF, unsichere ID 404', async () => {
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' } }) })
aw.seed('ukRechnungen', { $id: 'rfremd', featureId: 'anderes', anfrageId: 'a9', art: 'rechnung', nummer: 'XX-1', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' })
const l = (await get('/rechnungen')).body.rechnungen
assert.deepEqual(l.map((r) => r.id), ['r1'])
assert.equal(l[0].anfrageNummer, 'A-X')
assert.equal(l[0].gastName, 'Eva')
assert.equal(l[0].empfaenger, undefined)
assert.equal(l[0].anfrageId, undefined)
assert.equal((await get('/rechnungen/rfremd.pdf')).status, 404)
assert.equal((await get('/rechnungen/nix.pdf')).status, 404)
assert.equal((await get('/rechnungen/r1')).status, 404)
assert.equal((await get('/rechnungen/..%2F..%2Fx.pdf')).status, 404)
const eigene = await get('/rechnungen/r1.pdf')
assert.equal(eigene.status, 200)
assert.equal(eigene.headers.get('content-type'), 'application/pdf')
assert.match(eigene.headers.get('content-disposition'), /Rechnung-DH-2026-0001\.pdf/)
assert.ok(String(eigene.body).startsWith('%PDF-'), 'PDF-Kopf')
})
// ------------------------------------------------------------------------------------------------ Stripe
test('Stripe verbinden: legt Konto an (Metadaten unterkunft, Key uk-konto-), speichert, Onboarding-Link; zweimal = ein Konto', async () => {
const r = await post('/stripe/verbinden')
assert.equal(r.status, 200)
assert.match(r.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/)
const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts')
assert.equal(anlage.body['metadata[webklar]'], 'unterkunft')
assert.equal(anlage.body['metadata[projectId]'], P)
assert.equal(anlage.idempotencyKey, `uk-konto-${P}`)
const einst = aw.docs('ukEinstellungen').get(FEATURE)
assert.match(einst.stripeAccountId, /^acct_/)
assert.equal(JSON.parse(einst.stripeStatus).charges_enabled, false)
const link = st.calls.find((c) => c.path === '/v1/account_links')
assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#buchungen?stripe=zurueck&projekt=${P}`)
const [a, b] = await Promise.all([post('/stripe/verbinden'), post('/stripe/verbinden')])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1)
const s = (await get('/status')).body
assert.equal(s.stripe.status, 'onboarding')
assert.ok(!JSON.stringify(s).includes(einst.stripeAccountId))
const eg = await get('/einstellungen')
assert.ok(!JSON.stringify(eg.body).includes(einst.stripeAccountId))
})
test('Stripe aktualisieren: ohne Konto 409; mit Konto neuer Status, aktiv bei charges_enabled', async () => {
assert.equal((await post('/stripe/aktualisieren')).body.fehler, 'kein_konto')
await post('/stripe/verbinden')
const id = aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true })
const r = await post('/stripe/aktualisieren')
assert.equal(r.status, 200)
assert.equal(r.body.stripe.status, 'aktiv')
assert.equal(JSON.parse(aw.docs('ukEinstellungen').get(FEATURE).stripeStatus).charges_enabled, true)
assert.equal((await get('/status')).body.stripe.status, 'aktiv')
assert.equal((await put('/einstellungen', { onlineZahlung: true })).status, 200)
assert.equal((await get('/status')).body.onlineZahlung, true)
})
test('Stripe vorhandenes-konto: uebernimmt das Shop-Konto desselben Projekts, ohne eins 409, fremdes Konto bleibt unberuehrt', async () => {
const ohne = await post('/stripe/vorhandenes-konto')
assert.equal(ohne.status, 409)
assert.equal(ohne.body.fehler, 'kein_konto')
aw.seed('portalFeatures', { $id: `f_${P}_shop`, customerId: 'c1', projectId: P, featureKey: 'shop', siteKey: 'shopshopshopshop', state: 'active', enabled: true })
aw.seed('shopConfig', { $id: `f_${P}_shop`, stripeAccountId: 'acct_shop1', stripeStatus: JSON.stringify({ charges_enabled: true }) })
const r = await post('/stripe/vorhandenes-konto')
assert.equal(r.status, 200)
assert.equal(r.body.stripe.status, 'aktiv')
assert.equal(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, 'acct_shop1')
assert.ok(!JSON.stringify(r.body).includes('acct_shop1'))
assert.equal(st.calls.filter((c) => c.path === '/v1/accounts').length, 0)
// ein anderes Projekt desselben Kunden mit Shop-Konto wird nicht beruehrt
aw.seed('shopConfig', { $id: 'f_pzwei_shop', stripeAccountId: 'acct_andere' })
assert.equal(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, 'acct_shop1')
assert.equal((await send('POST', '/api/unterkunft/stripe/vorhandenes-konto?projectId=pfremd', { cookie, body: {} })).status, 403)
})
// ------------------------------------------------------------------------------------------------ Reine Pruefungen
test('pruefeEinstellungen: nur gesendete Felder, Vollstaendigkeit aus vorher + eingabe', () => {
assert.ok(pruefeEinstellungen({ firma: 'A' }).fehler.strasse)
const r = pruefeEinstellungen({ telefon: '123' }, { ...EINST })
assert.deepEqual(r.werte, { telefon: '123' })
assert.ok(pruefeEinstellungen({ steuernummer: '' }, { ...EINST }).fehler.steuernummer)
})
test('Einstellungen: rechnungAuftrag setzt/widerruft rechnungAuftragAt', async () => {
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined })
assert.equal((await get('/einstellungen')).body.einstellungen.rechnungAuftrag, false)
const an = await put('/einstellungen', { ...EINST, rechnungAuftrag: true })
assert.equal(an.body.einstellungen.rechnungAuftrag, true)
const zeit = aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt
assert.ok(zeit)
await put('/einstellungen', { ...EINST, rechnungAuftrag: true })
assert.equal(new Date(aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt).getTime(), new Date(zeit).getTime())
const aus = await put('/einstellungen', { ...EINST, rechnungAuftrag: false })
assert.equal(aus.body.einstellungen.rechnungAuftrag, false)
assert.ok(!aw.docs('ukEinstellungen').get(FEATURE).rechnungAuftragAt)
assert.equal((await put('/einstellungen', { rechnungAuftrag: 'ja' })).status, 400)
})
test('Zustimmung: ohne rechnungAuftrag verweigern freigeben und bucheVerbindlich mit 409 zustimmung_fehlt', async () => {
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, rechnungAuftragAt: undefined })
anfrage('a1')
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN })
assert.equal(r.status, 409)
assert.equal(r.body.fehler, 'zustimmung_fehlt')
assert.equal(aw.docs('ukAnfragen').get('a1').status, 'neu')
const ab = await import('../server/services/unterkunft/ablauf.js')
const { token, hash } = (await import('../server/services/unterkunft/token.js')).neuesToken()
anfrage('a2', { status: 'freigegeben', tokenHash: hash, fristAt: new Date(Date.now() + 86400000).toISOString(), posten: JSON.stringify(POSTEN), summen: JSON.stringify({ bruttoCent: 20000, nettoCent: 20000, ust: [] }) })
const ctx = { featureId: FEATURE, einstellungen: { ...EINST, rechnungAuftragAt: null } }
await assert.rejects(
ab.bucheVerbindlich(ctx, 'a2', token, { rechnungsadresse: { name: 'Eva', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' }, zahlart: 'vorort' }),
(e) => e instanceof UkFehler && e.status === 409 && e.code === 'zustimmung_fehlt',
)
assert.equal(aw.docs('ukAnfragen').get('a2').status, 'freigegeben')
assert.equal(aw.docs('ukRechnungen').size, 0)
})
test('Online-Zahlung: nur mit aktivem Stripe-Konto, sonst 409 mit felder.onlineZahlung', async () => {
await post('/stripe/verbinden')
st.setAccount(aw.docs('ukEinstellungen').get(FEATURE).stripeAccountId, { charges_enabled: false })
await post('/stripe/aktualisieren')
assert.equal((await get('/status')).body.stripe.status, 'onboarding')
const r = await put('/einstellungen', { onlineZahlung: true })
assert.equal(r.status, 409)
assert.ok(r.body.felder.onlineZahlung)
assert.notEqual(aw.docs('ukEinstellungen').get(FEATURE).onlineZahlung, true)
// ohne Konto ebenfalls 409
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST })
const ohne = await put('/einstellungen', { onlineZahlung: true })
assert.equal(ohne.status, 409)
assert.ok(ohne.body.felder.onlineZahlung)
// bereits eingeschaltet: andere Aenderungen scheitern nicht, auch wenn Stripe inaktiv ist
aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, onlineZahlung: true })
assert.equal((await put('/einstellungen', { telefon: '123' })).status, 200)
assert.equal((await put('/einstellungen', { onlineZahlung: true })).status, 200)
})
test('Stornobedingungen mit unausgefuellten Platzhaltern in eckigen Klammern werden abgelehnt', () => {
for (const text of ['Bis [30] Tage vor Anreise kostenfrei, sonst Anteil.', 'Stornierung per E-Mail an [E-Mail-Adresse des Vermieters] moeglich.']) {
const r = pruefePreise({ ...PREISE, stornobedingungen: text })
assert.equal(r.fehler?.stornobedingungen, 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.', text)
}
assert.ok(pruefePreise({ ...PREISE, stornobedingungen: 'Bis 30 Tage vor Anreise kostenfrei, danach 50 Prozent.' }).werte)
})