import { test, after, beforeEach } 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 11: Funktion bestellen "auf Anfrage", Freischaltung, Ticket-Details, Team-API const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukSperren', 'ukRechnungen', 'featureConsents'] const aw = await startAppwriteMock({ schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])), datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, send, rawGet, post: rawPost } = await import('./support/testApp.js') const { customerCookie, kundenansichtCookie, seedFixtures, customers } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { renderMail } = await import('../server/services/mailTemplates.js') const af = await import('../server/services/unterkunft/anfrageFunktion.js') const { freischaltbar } = await import('../server/services/unterkunft/freischaltung.js') const { unterkunftTicketDetails } = await import('../server/services/unterkunft/ticketDetails.js') const { featureHaken } = await import('../server/services/featureRegistry.js') const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes, '/api/team': teamRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) seedFixtures(aw) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) const P = 'pu1' const FEATURE = `f_${P}_unterkunft` const cookie = customerCookie('c1') const staff = { headers: { authorization: 'Bearer jwt-staff' } } const person = { name: 'Kunde Eins', email: customers.c1.email } const vorher = { ...config.unterkunft } const GUELTIG = { art: 'ferienwohnung', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' } const stelle = (b = {}, opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion?projectId=${P}`, { ...opts, body: { ...GUELTIG, ...b } }) const zurueck = (opts = { cookie }) => send('POST', `/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${P}`, { ...opts, body: {} }) const status = (opts = { cookie }) => send('GET', `/api/unterkunft/status?projectId=${P}`, opts) const entscheide = (b, opts = staff) => rawPost(`/api/team/unterkunft/c1/anfrage`, { projectId: P, ...b }, opts) const einst = () => aw.docs('ukEinstellungen').get(FEATURE) ?? null // Mails: die Aufrufe werden festgehalten und gegen die echten Vorlagen K34/T23 gerendert (problems muss leer bleiben) const aufrufe = { admin: [], kunde: [], probleme: [], adminFehler: false, kundeFehler: false } af._versand.admin = async (m) => { aufrufe.admin.push(m) aufrufe.probleme.push(...renderMail(m.template, m.values, m.blocks ?? {}).problems.map((x) => `${m.template}: ${x}`)) if (aufrufe.adminFehler) throw new Error('SMTP') return { sent: true } } af._versand.kunde = async (an, m) => { aufrufe.kunde.push({ an, ...m }) aufrufe.probleme.push(...renderMail(m.template, m.values, m.blocks ?? {}).problems.map((x) => `${m.template}: ${x}`)) if (aufrufe.kundeFehler) throw new Error('SMTP') } 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: 'pilot', pilotEmails: [customers.c1.email] }) resetUnterkunftRoutesForTests() aufrufe.admin.length = 0 aufrufe.probleme.length = 0 aufrufe.kunde.length = 0 aufrufe.adminFehler = aufrufe.kundeFehler = false aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Donau', archived: false }) aw.seed('websiteProjects', { $id: 'pfremd', customerId: 'c2', projectName: 'Fremd' }) }) test('normalisiereAnfrage: gueltig, Alias monteur, Fehler je Feld', () => { const ok = af.normalisiereAnfrage({ ...GUELTIG, art: 'monteur', x: 1 }) assert.deepEqual(ok.anfrage, { art: 'monteurzimmer', einheiten: 3, portale: ['booking', 'airbnb'], nachricht: 'Hallo' }) const f = (a) => af.normalisiereAnfrage({ ...GUELTIG, ...a }).fehler assert.ok(f({ art: 'villa' }).art) assert.ok(f({ art: 'constructor' }).art) assert.ok(f({ einheiten: 0 }).einheiten) assert.ok(f({ einheiten: '3' }).einheiten) assert.ok(f({ einheiten: 1000 }).einheiten) assert.equal(f({ einheiten: 999 }), undefined) assert.ok(f({ portale: ['ebay'] }).portale) assert.ok(f({ portale: 'booking' }).portale) assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: [] }).anfrage.portale, []) assert.deepEqual(af.normalisiereAnfrage({ ...GUELTIG, portale: true }).anfrage.portale, ['andere']) assert.ok(f({ nachricht: 'n'.repeat(1001) }).nachricht) assert.equal(af.normalisiereAnfrage({ ...GUELTIG, nachricht: undefined }).anfrage.nachricht, '') assert.ok(af.normalisiereAnfrage(null).fehler.art) }) test('Anfrage stellen: gespeichert, T23 ans Team, Status-Block fuer state off', async () => { const leer = await status() assert.equal(leer.status, 200) assert.equal(leer.body.state, 'off') assert.equal(leer.body.anfrage, null) const res = await stelle({ art: 'monteur' }) assert.equal(res.status, 200) assert.equal(res.body.anfrage.status, 'offen') assert.equal(res.body.anfrage.anfrage.art, 'monteur', 'das Portal liest monteur') assert.deepEqual(res.body.anfrage.anfrage.portale, ['booking', 'airbnb']) assert.equal(einst().anfrageStatus, 'offen') assert.equal(JSON.parse(einst().anfrage).art, 'monteurzimmer') assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') assert.equal(aufrufe.admin.length, 1) assert.equal(aufrufe.admin[0].template, 'T23') assert.equal(aufrufe.admin[0].values.projekt, 'Donau') assert.match(aufrufe.admin[0].values.nachricht, /^Art: Monteurwohnung\nEinheiten: 3\nPortale: Booking\.com, Airbnb/) assert.equal(aufrufe.admin[0].values.woms_url, `${config.womsBaseUrl}/customers/c1?tab=funktionen`) assert.equal(aufrufe.kunde.length, 0) const s = (await status()).body.anfrage assert.equal(s.status, 'offen') assert.ok(s.at) assert.deepEqual(Object.keys(s.anfrage).sort(), ['art', 'einheiten', 'nachricht', 'portale']) }) test('Anfrage: doppelt 409, nur bei state off, Eingabefehler, Schalter, fremdes Projekt, Kundenansicht', async () => { assert.equal((await stelle()).status, 200) const doppelt = await stelle() assert.equal(doppelt.status, 409) assert.equal(doppelt.body.fehler, 'anfrage_laeuft') assert.equal(aufrufe.admin.length, 1) await deleteDocument(config.collections.ukEinstellungen, FEATURE) aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) const aktiv = await stelle() assert.equal(aktiv.status, 409) assert.equal(aktiv.body.fehler, 'vorhanden') aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) const schlecht = await stelle({ einheiten: 0, art: 'x' }) assert.equal(schlecht.status, 400) assert.equal(schlecht.body.fehler, 'ungueltig') assert.ok(schlecht.body.felder.einheiten && schlecht.body.felder.art) assert.equal(einst(), null) assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion?projectId=pfremd', { cookie, body: GUELTIG })).status, 403) assert.equal((await send('POST', '/api/unterkunft/anfrage-funktion', { cookie, body: GUELTIG })).status, 400) assert.equal((await stelle({}, {})).status, 401) assert.equal((await stelle({}, { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'a'.repeat(32) } })).status, 403) config.unterkunft.mode = 'off' const aus = await stelle() assert.equal(aus.status, 409) assert.equal(aus.body.fehler, 'nicht_verfuegbar') assert.equal(einst(), null) }) test('Anfrage: Mail-Fehler bei T23 verhindert nichts; Grenze 20 je Stunde', async () => { aufrufe.adminFehler = true assert.equal((await stelle()).status, 200) assert.equal(einst().anfrageStatus, 'offen') let letzter for (let i = 0; i < 20; i++) letzter = await zurueck() assert.equal(letzter.status, 429) }) test('zurueckziehen: nur offen; danach neue Anfrage moeglich; Status-Block zeigt keine zurueckgezogene', async () => { assert.equal((await zurueck()).status, 409) await stelle() const z = await zurueck() assert.equal(z.status, 200) assert.equal(z.body.anfrage.status, 'zurueckgezogen') assert.equal((await status()).body.anfrage, null) assert.equal((await zurueck()).status, 409) assert.equal((await stelle({ einheiten: 5 })).status, 200) assert.equal(JSON.parse(einst().anfrage).einheiten, 5) }) test('Team: Uebersicht je Projekt und Entscheidung annehmen/ablehnen mit K34', async () => { assert.equal((await rawGet('/api/team/unterkunft/c1', {})).status, 401) const leer = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects assert.equal(leer.length, 1) assert.deepEqual(leer[0], { projectId: P, name: 'Donau', state: 'off', unterkuenfte: 0, offeneAnfragen: 0, stripe: { accountId: false, status: null }, anfrage: null }) await stelle({ art: 'monteur', portale: [] }) const p = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects[0] assert.equal(p.anfrage.status, 'offen') assert.equal(p.anfrage.art, 'monteurzimmer') assert.equal(p.anfrage.einheiten, 3) assert.equal(p.anfrage.portale, false) assert.equal((await entscheide({ entscheidung: 'vielleicht' })).status, 400) const ohneGrund = await entscheide({ entscheidung: 'ablehnen', begruendung: ' ' }) assert.equal(ohneGrund.status, 400) assert.equal(einst().anfrageStatus, 'offen') assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), [af.ANFRAGE_OFFEN_TEXT]) const ab = await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' }) assert.equal(ab.status, 200) assert.equal(ab.body.anfrage.status, 'abgelehnt') assert.equal(aufrufe.kunde.length, 1) assert.equal(aufrufe.kunde[0].template, 'K34') assert.equal(aufrufe.kunde[0].an, customers.c1.email) assert.equal(aufrufe.kunde[0].values.begruendung, 'Passt nicht.') assert.equal(aufrufe.kunde[0].values.portal_url, `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${P}`) assert.deepEqual(aufrufe.kunde[0].blocks, { angenommen: false, abgelehnt: true }) assert.equal((await status()).body.anfrage.begruendung, 'Passt nicht.') assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409, 'nicht mehr offen') await stelle() assert.equal(einst().anfrageBegruendung, '', 'neue Anfrage loescht die Entscheidung') aufrufe.kundeFehler = true const an = await entscheide({ entscheidung: 'annehmen' }) assert.equal(an.status, 200) assert.equal(an.body.mailFehler, true) assert.equal(an.body.anfrage.status, 'angenommen') assert.equal(einst().anfrageEntschiedenVon, 'Andrej ') assert.deepEqual(aufrufe.kunde.at(-1).blocks, { angenommen: true, abgelehnt: false }) assert.deepEqual(await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P }), []) assert.deepEqual(await featureHaken('unterkunft').bestellpruefung({ customerId: 'c1', projectId: P }), []) assert.equal((await status()).body.anfrage.status, 'angenommen') // fremdes Projekt des Kunden assert.equal((await rawPost('/api/team/unterkunft/c1/anfrage', { projectId: 'pfremd', entscheidung: 'annehmen' }, staff)).status, 403) }) test('Team: Stripe-Form wie Webshop, Entscheidung nur bei state off, Status ohne Lesefehler-500', async () => { await stelle() aw.seed('ukEinstellungen', { ...einst(), stripeAccountId: 'acct_1', stripeStatus: JSON.stringify({ charges_enabled: true }) }) const p = (await rawGet('/api/team/unterkunft/c1', staff)).body.projects[0] assert.deepEqual(p.stripe, { accountId: true, status: { charges_enabled: true } }) aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(FEATURE), state: 'setup' }) assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 409) aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(FEATURE), state: 'off' }) const echt = config.collections.ukEinstellungen config.collections.ukEinstellungen = 'gibt_es_nicht' try { const s = await status() assert.equal(s.status, 200) assert.equal(s.body.anfrage, null) } finally { config.collections.ukEinstellungen = echt } }) test('anfrageOffenePunkte: ohne Dokumente offen, Lesefehler wird weitergereicht', async () => { assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) assert.equal((await af.anfrageOffenePunkte({ customerId: 'c1', projectId: P })).length, 1) }) // ------------------------------------------------------------------------------------------------ Freischaltung const EINST = { firma: 'Donau Home', strasse: 'Weg 1', plz: '78576', ort: 'Stetten', email: 'mimi@example.com', steuernummer: '12/345/67890', ustId: '', rechnungAuftragAt: '2026-10-01T00:00:00.000Z', } const PREISE = { staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]), reinigungCent: 5000, personenInklusive: 2, aufpreisPersonNachtCent: 1500, direktrabattProzent: 0, steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vor Anreise.', } function seedFrei({ einst = {}, aktiv = true, preise = {} } = {}) { aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false }) aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, ...einst }) aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: P, name: 'Erste', maxPersonen: 4, aktiv, sort: 1 }) aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...PREISE, ...preise }) } const doc = () => aw.docs('portalFeatures').get(FEATURE) test('freischaltbar: false ohne Daten, true mit vollstaendigen Daten, je Pflicht eine Gegenprobe', async () => { assert.equal(await freischaltbar({ $id: FEATURE }), false) assert.equal(await freischaltbar(null), false) seedFrei() assert.equal(await freischaltbar(doc()), true) assert.equal(await featureHaken('unterkunft').freischaltbar(doc()), true) const ust = { steuernummer: '', ustId: 'DE123456789' } for (const [name, kaputt] of [ ['Firma', { einst: { firma: '' } }], ['Strasse', { einst: { strasse: ' ' } }], ['PLZ', { einst: { plz: '' } }], ['Ort', { einst: { ort: '' } }], ['E-Mail', { einst: { email: '' } }], ['Steuer', { einst: { steuernummer: '', ustId: '' } }], ['Auftrag', { einst: { rechnungAuftragAt: null } }], ['Unterkunft inaktiv', { aktiv: false }], ['Preise leer', { preise: { staffeln: '[]' } }], ['Storno leer', { preise: { stornobedingungen: ' ' } }], ]) { await beforeEachClear() seedFrei(kaputt) assert.equal(await freischaltbar(doc()), false, name) } await beforeEachClear() seedFrei({ einst: ust }) assert.equal(await freischaltbar(doc()), true, 'ustId statt Steuernummer') }) async function beforeEachClear() { for (const c of ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukPreise']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) } test('freischaltbar: Lesefehler => false (fail-closed)', async () => { seedFrei() const d = doc() const echt = config.collections.ukPreise config.collections.ukPreise = 'gibt_es_nicht' try { assert.equal(await freischaltbar(d), false) } finally { config.collections.ukPreise = echt } }) test('ticketDetails: siteKey, Snippet, Schritte, Verweis, Anfrage; wirft nie', async () => { seedFrei() aw.seed('ukEinstellungen', { $id: FEATURE, featureId: FEATURE, ...EINST, anfrage: JSON.stringify({ art: 'pension', einheiten: 4, portale: ['airbnb'], nachricht: 'x' }), anfrageStatus: 'angenommen' }) const text = await unterkunftTicketDetails({ doc: doc(), project: { projectName: 'Donau' }, customer: { name: 'Mimi' } }) assert.match(text, /siteKey: abcdefghijklmnop/) assert.ok(text.includes('
')) assert.ok(text.includes('')) assert.match(text, /docs\/funktionen\/unterkunft-einbau\.md/) assert.match(text, /Art: Pension oder Gästezimmer, Einheiten: 4, Portale: Airbnb/) assert.match(text, /Funktion: Buchungskalender/) assert.match(text, /CSS-Variablen/) assert.match(text, /--wku-akzent, --wku-akzent-text, --wku-hintergrund, --wku-text, --wku-rand, --wku-radius, --wku-schrift, --wku-schrift-titel/) assert.doesNotMatch(text, /wk-uk-/) assert.match(text, /Verweis: /) const notfall = await unterkunftTicketDetails(null) assert.match(notfall, /Buchungskalender/) const viaConfig = await config.features.unterkunft.ticket.details({ doc: doc(), project: {}, customer: null, feature: { key: 'Buchungskalender' } }) assert.ok(viaConfig.includes('abcdefghijklmnop')) }) test('T23 und K34 (angenommen und abgelehnt) passen zu den Vorlagen: keine Platzhalter-Probleme', async () => { assert.equal((await stelle({ nachricht: 'Hallo\nZeile 2' })).status, 200) assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200) assert.equal((await stelle()).status, 200) assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200) assert.deepEqual(aufrufe.admin.map((m) => m.template), ['T23', 'T23']) assert.deepEqual(aufrufe.kunde.map((m) => m.template), ['K34', 'K34']) assert.deepEqual(aufrufe.probleme, []) const t22 = renderMail('T23', aufrufe.admin[0].values, {}) assert.match(t22.text, /Art: Ferienwohnung oder Ferienhaus/) assert.match(t22.text, /Hallo/) const k34 = renderMail('K34', aufrufe.kunde[1].values, aufrufe.kunde[1].blocks) assert.match(k34.text, /199 €/) })