Files
Webklar-Kundenbereich/test/unterkunftAnfrageFunktion.test.js
T

343 lines
20 KiB
JavaScript

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 <andrej@webklar.com>')
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('<div data-webklar-unterkunft="abcdefghijklmnop" data-api="https://project.webklar.com"></div>'))
assert.ok(text.includes('<script src="https://project.webklar.com/embed/unterkunft.js" defer></script>'))
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 €/)
})
const ansichtAlt = { ...config.kundenansichtNeu }; after(() => Object.assign(config.kundenansichtNeu, ansichtAlt))
const neueAnsicht = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = [customers.c1.email] }
test('Abo-Modell: Zusage als K43 ohne Preis und ohne „bestellen“, Absage unverändert; alte Ansicht bekommt weiter K34 mit 199 €', async () => {
for (const an of [true, false]) {
neueAnsicht(an); aufrufe.kunde.length = 0; await deleteDocument(config.collections.ukEinstellungen, FEATURE).catch(() => {}); assert.equal((await stelle()).status, 200); assert.equal((await entscheide({ entscheidung: 'annehmen' })).status, 200)
const m = aufrufe.kunde[0]; const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, [])
assert.deepEqual([m.template, 'preis' in m.values, 'ust_hinweis' in m.values], an ? ['K43', false, false] : ['K34', true, true])
assert.equal(m.values.portal_url, `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${P}`)
if (an) assert.doesNotMatch(r.text + r.html, /€|UStG|bestell/i); else { assert.match(r.text, /199 €/); assert.match(r.text, /bestellen/) }
}
neueAnsicht(true); aufrufe.kunde.length = 0; await deleteDocument(config.collections.ukEinstellungen, FEATURE).catch(() => {}); await stelle(); assert.equal((await entscheide({ entscheidung: 'ablehnen', begruendung: 'Passt nicht.' })).status, 200)
assert.deepEqual([aufrufe.kunde[0].template, aufrufe.kunde[0].values.begruendung, aufrufe.kunde[0].blocks], ['K43', 'Passt nicht.', { angenommen: false, abgelehnt: true }])
})