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) })