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

320 lines
18 KiB
JavaScript

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Buchungskalender Task 4: Statusmaschine (ablauf.js)
const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen']
const aw = await startAppwriteMock({
schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])),
datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] },
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const ab = await import('../server/services/unterkunft/ablauf.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 sp = await import('../server/services/unterkunft/speicher.js')
const { istFrei } = await import('../server/services/unterkunft/belegung.js')
after(() => aw.close())
const NOW = new Date('2026-11-01T12:00:00Z')
const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'sk', einstellungen: { firma: 'Haus', strasse: 'Str 1', plz: '10115', ort: 'Berlin', steuernummer: '12/345', onlineZahlung: false } }
const GAST = { name: 'Eva Gast', firma: '', email: 'eva@example.com', telefon: '', nachricht: 'Hallo' }
const ADRESSE = { name: 'Eva Gast', strasse: 'Weg 1', plz: '12345', ort: 'Berlin', land: 'DE' }
beforeEach(async () => {
for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
mails._aufrufe.length = 0
zahlung._aufrufe.length = 0
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung', maxPersonen: 4, mindestNaechte: 2, aktiv: true })
aw.seed('ukPreise', {
$id: 'p1', unterkunftId: 'u1', featureId: 'f1', staffeln: JSON.stringify([{ abNaechten: 1, preisProNachtCent: 10000 }]),
reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'regel',
ustUebernachtung: 7, ustReinigung: 19, fristTage: 3, stornobedingungen: 'Kostenlos bis 7 Tage vorher.',
})
})
const eingabe = (v = '2026-11-10', b = '2026-11-12', extra = {}) => ({ unterkunftId: 'u1', von: v, bis: b, personen: 2, gast: GAST, ...extra })
const anlegen = (v, b) => ab.legeAnfrageAn(ctx, eingabe(v, b), { now: NOW, ipHash: 'x' })
const arten = () => mails._aufrufe.map((m) => m.art || m.template)
const fehlerCode = async (p) => {
try {
await p
} catch (e) {
assert.ok(e instanceof UkFehler, String(e))
return `${e.status} ${e.code}`
}
return 'kein Fehler'
}
test('Anfrage wird neu, Nummer-Format, Mails G16 + K31', async () => {
const a = await anlegen()
assert.equal(a.status, 'neu')
assert.match(a.nummer, /^A-[A-Z2-9]{4}$/)
assert.deepEqual(arten(), ['G16', 'K31'])
})
test('Ungueltige Anfragen: nichts gespeichert', async () => {
aw.seed('ukSperren', { $id: 's1', unterkunftId: 'u1', featureId: 'f1', von: '2026-12-01', bis: '2026-12-05', quelle: 'manuell' })
assert.equal(await fehlerCode(anlegen('2026-10-30', '2026-11-02')), '400 ungueltig')
assert.equal(await fehlerCode(anlegen('2026-11-10', '2026-11-11')), '400 mindestnaechte')
assert.equal(await fehlerCode(ab.legeAnfrageAn(ctx, eingabe('2026-11-10', '2026-11-12', { personen: 5 }), { now: NOW })), '400 zu_viele_personen')
assert.equal(await fehlerCode(anlegen('2026-12-03', '2026-12-06')), '409 zeitraum_belegt')
assert.equal(await fehlerCode(anlegen('2028-11-10', '2028-11-12')), '400 ungueltig')
assert.equal(aw.docs('ukAnfragen').size, 0)
assert.equal(mails._aufrufe.length, 0)
})
test('Anreise heute ist erlaubt', async () => {
const a = await anlegen('2026-11-01', '2026-11-03')
assert.equal(a.status, 'neu')
})
test('Review 2: ueberlappende Anfragen, zweite Freigabe scheitert ohne Mail', async () => {
const a = await anlegen('2026-11-10', '2026-11-13')
const b = await anlegen('2026-11-12', '2026-11-15')
mails._aufrufe.length = 0
const r = await ab.gibFrei(ctx, a.$id, { now: NOW })
assert.equal(r.anfrage.status, 'freigegeben')
assert.deepEqual(arten(), ['G17'])
assert.ok(r.token)
mails._aufrufe.length = 0
assert.equal(await fehlerCode(ab.gibFrei(ctx, b.$id, { now: NOW })), '409 zeitraum_belegt')
assert.equal(mails._aufrufe.length, 0)
assert.equal((await sp.ladeAnfrage(b.$id)).status, 'neu')
})
test('gibFrei mit eigenen Posten und Schnappschuss; Fehler bei Unsinn', async () => {
const a = await anlegen()
assert.equal(await fehlerCode(ab.gibFrei(ctx, a.$id, { posten: [], now: NOW })), '400 ungueltig')
const r = await ab.gibFrei(ctx, a.$id, { posten: [{ text: 'Pauschale', menge: 1, einzelCent: 50000, ustProzent: 7 }], now: NOW })
assert.equal(r.anfrage.summen.bruttoCent, 50000)
assert.equal(r.anfrage.stornobedingungenText, 'Kostenlos bis 7 Tage vorher.')
assert.equal(r.anfrage.rechtstexteFassung, '2026-10-07')
assert.equal(r.anfrage.ipHash, '')
assert.equal(Date.parse(r.anfrage.fristAt), NOW.getTime() + 3 * 86400000)
assert.equal(await fehlerCode(ab.gibFrei(ctx, a.$id, { now: NOW })), '409 status_ungueltig')
})
test('gibFrei ohne Posten rechnet den Preis', async () => {
const a = await anlegen()
const r = await ab.gibFrei(ctx, a.$id, { now: NOW })
assert.equal(r.anfrage.summen.bruttoCent, 20000)
})
test('lehneAb nur aus neu, Mail G18', async () => {
const a = await anlegen()
mails._aufrufe.length = 0
const r = await ab.lehneAb(ctx, a.$id, { text: 'Leider belegt', now: NOW })
assert.equal(r.status, 'abgelehnt')
assert.deepEqual(arten(), ['G18'])
assert.equal(await fehlerCode(ab.lehneAb(ctx, a.$id, { text: 'x', now: NOW })), '409 status_ungueltig')
})
async function freigegeben() {
const a = await anlegen()
const { token } = await ab.gibFrei(ctx, a.$id, { now: NOW })
mails._aufrufe.length = 0
return { id: a.$id, token }
}
test('bucheVerbindlich: falsches Token 404, nach Frist 410, Adresse Pflicht', async () => {
const { id, token } = await freigegeben()
assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, 'falsch', { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '404 nicht_gefunden')
assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, 'gibtsnicht', token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '404 nicht_gefunden')
const spaet = new Date(NOW.getTime() + 4 * 86400000)
assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: spaet })), '410 frist_abgelaufen')
assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { name: 'x' }, zahlart: 'vorort', now: NOW })), '400 ungueltig')
assert.equal(aw.docs('ukRechnungen').size, 0)
})
test('bucheVerbindlich zweimal parallel: genau eine Rechnung, gleiche Nummer', async () => {
const { id, token } = await freigegeben()
const opt = { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }
const [x, y] = await Promise.all([ab.bucheVerbindlich(ctx, id, token, opt), ab.bucheVerbindlich(ctx, id, token, opt)])
assert.equal(aw.docs('ukRechnungen').size, 1)
assert.equal(x.rechnung.nummer, 'RE-2026-0001')
assert.equal(y.rechnung.nummer, x.rechnung.nummer)
assert.equal(x.anfrage.status, 'gebucht')
assert.deepEqual(arten(), ['G19', 'K32'])
})
test('bucheVerbindlich online ohne Stripe-Konto: 409 online_aus', async () => {
const { id, token } = await freigegeben()
assert.equal(await fehlerCode(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })), '409 online_aus')
})
test('Fristablauf: abgelaufen, G21, Zeitraum frei', async () => {
const { id } = await freigegeben()
assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW }), false)
assert.deepEqual(await ab.laufeFristenAb(NOW), { abgelaufen: 0 })
const spaet = new Date(NOW.getTime() + 4 * 86400000)
assert.deepEqual(await ab.laufeFristenAb(spaet), { abgelaufen: 1 })
assert.equal((await sp.ladeAnfrage(id)).status, 'abgelaufen')
assert.deepEqual(arten(), ['G21'])
assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: spaet }), true)
})
test('Bezahlt: idempotent, G20 nur bei stripe', async () => {
const { id, token } = await freigegeben()
await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
mails._aufrufe.length = 0
const a = await ab.markiereBezahlt(ctx, id, { via: 'vorort', now: NOW })
assert.equal(a.status, 'bezahlt')
await ab.markiereBezahlt(ctx, id, { via: 'vorort', now: NOW })
assert.equal(mails._aufrufe.length, 0)
const b = await anlegen('2026-12-10', '2026-12-12')
const f = await ab.gibFrei(ctx, b.$id, { now: NOW })
await ab.bucheVerbindlich(ctx, b.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
mails._aufrufe.length = 0
await ab.markiereBezahlt(ctx, b.$id, { via: 'stripe', paymentIntentId: 'pi_1', now: NOW })
assert.deepEqual(arten(), ['G20'])
assert.equal(await fehlerCode(ab.markiereBezahlt(ctx, (await anlegen('2026-12-20', '2026-12-22')).$id, { via: 'vorort', now: NOW })), '409 status_ungueltig')
})
test('Storno: Stornorechnung mit eigener Nummer, G22; Erstattung > brutto = 400', async () => {
const { id, token } = await freigegeben()
const { rechnung } = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
mails._aufrufe.length = 0
assert.equal(await fehlerCode(ab.storniere(ctx, id, { grund: 'x', erstattungCent: 100, now: NOW })), '409 status_ungueltig')
assert.equal((await sp.ladeAnfrage(id)).status, 'gebucht')
const r = await ab.storniere(ctx, id, { grund: 'Wunsch', now: NOW })
assert.equal(r.anfrage.status, 'storniert')
assert.equal(r.stornoRechnung.nummer, 'RE-2026-0002')
assert.equal(r.stornoRechnung.inhalt.bezugNummer, rechnung.nummer)
assert.deepEqual(arten(), ['G22'])
assert.equal(await istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW }), true)
})
test('Storno bezahlt per Stripe: Erstattung, Obergrenze; freigegeben ohne Rechnung', async () => {
const sctx = { ...ctx, einstellungen: { ...ctx.einstellungen, onlineZahlung: true, stripeAccountId: 'acct_1' } }
const { id, token } = await freigegeben()
await ab.bucheVerbindlich(sctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })
await ab.markiereBezahlt(sctx, id, { via: 'stripe', now: NOW })
assert.equal(await fehlerCode(ab.storniere(sctx, id, { erstattungCent: 20001, now: NOW })), '400 ungueltig')
const r = await ab.storniere(sctx, id, { grund: 'g', erstattungCent: 20000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 20000)
const { id: id2 } = await (async () => {
const a = await anlegen('2026-12-10', '2026-12-12')
await ab.gibFrei(ctx, a.$id, { now: NOW })
return { id: a.$id }
})()
const r2 = await ab.storniere(ctx, id2, { grund: 'g', now: NOW })
assert.equal(r2.stornoRechnung, null)
})
test('Uebergaenge', () => {
assert.equal(ab.darfUebergang('neu', 'bezahlt'), false)
assert.equal(ab.darfUebergang('neu', 'freigegeben'), true)
assert.equal(ab.darfUebergang('bezahlt', 'storniert'), true)
assert.equal(ab.darfUebergang('abgelehnt', 'neu'), false)
})
test('Buchung: Wiederholung nach Fehler beim Anfrage-Update gibt genau eine Rechnung, gleiche Nummer', async () => {
const { id, token } = await freigegeben()
const opt = { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
await assert.rejects(ab.bucheVerbindlich(ctx, id, token, opt))
assert.equal(aw.docs('ukRechnungen').size, 1)
const nr = [...aw.docs('ukRechnungen').values()][0].nummer
const r = await ab.bucheVerbindlich(ctx, id, token, opt)
assert.equal(aw.docs('ukRechnungen').size, 1)
assert.equal(r.rechnung.nummer, nr)
assert.equal(r.anfrage.status, 'gebucht')
})
test('Buchung: unvollstaendige Einstellungen -> 409, nichts geschrieben', async () => {
const { id, token } = await freigegeben()
const bad = { ...ctx, einstellungen: { ...ctx.einstellungen, steuernummer: '', ustId: '' } }
assert.equal(await fehlerCode(ab.bucheVerbindlich(bad, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })), '409 einstellungen_unvollstaendig')
assert.equal(aw.docs('ukRechnungen').size, 0)
assert.equal((await sp.ladeAnfrage(id)).status, 'freigegeben')
})
async function bezahltOnline() {
const sctx = { ...ctx, einstellungen: { ...ctx.einstellungen, onlineZahlung: true, stripeAccountId: 'acct_1' } }
const { id, token } = await freigegeben()
await ab.bucheVerbindlich(sctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'online', now: NOW })
await ab.markiereBezahlt(sctx, id, { via: 'stripe', now: NOW })
mails._aufrufe.length = 0
return { sctx, id }
}
test('Storno: Erstattung scheitert -> storniert, eine Stornorechnung, 502; Wiederholung erstattet einmal, kein zweites G22', async () => {
const { sctx, id } = await bezahltOnline()
await assert.rejects(ab.storniere({ ...ctx }, id, { grund: ' g ', erstattungCent: 20000, now: NOW }), (e) => e.status === 502 && e.code === 'erstattung_fehlgeschlagen')
let a = await sp.ladeAnfrage(id)
assert.equal(a.status, 'storniert')
assert.equal(a.erstattetCent, 0)
assert.equal(aw.docs('ukRechnungen').size, 2)
assert.deepEqual(arten(), ['G22'])
assert.equal(mails._aufrufe[0].grund, 'g')
const r = await ab.storniere(sctx, id, { grund: 'g', erstattungCent: 20000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 20000)
assert.equal(aw.docs('ukRechnungen').size, 2)
assert.equal(r.stornoRechnung.nummer, 'RE-2026-0002')
assert.deepEqual(arten(), ['G22'])
assert.equal(zahlung._aufrufe.at(-1).idempotencyKey, `uk-erstattung-${id}-20000`)
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 1, now: NOW })), '400 ungueltig')
})
test('erstatteBetrag: Teilbetraege, Obergrenze, nur storniert, nur Stripe', async () => {
const { sctx, id } = await bezahltOnline()
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 100, now: NOW })), '409 status_ungueltig')
await ab.storniere(sctx, id, { grund: 'g', now: NOW })
assert.equal(zahlung._aufrufe.length, 0)
await ab.erstatteBetrag(sctx, id, { betragCent: 5000, now: NOW })
const a = await ab.erstatteBetrag(sctx, id, { betragCent: 15000, now: NOW })
assert.equal(a.erstattetCent, 20000)
assert.deepEqual(zahlung._aufrufe.map((x) => x.idempotencyKey), [`uk-erstattung-${id}-5000`, `uk-erstattung-${id}-20000`])
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id, { betragCent: 1, now: NOW })), '400 ungueltig')
const { id: id2, token } = await (async () => {
const x = await anlegen('2026-12-10', '2026-12-12')
const f = await ab.gibFrei(ctx, x.$id, { now: NOW })
await ab.bucheVerbindlich(ctx, x.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
return { id: x.$id, token: f.token }
})()
void token
await ab.storniere(ctx, id2, { grund: 'g', now: NOW })
assert.equal(await fehlerCode(ab.erstatteBetrag(sctx, id2, { betragCent: 100, now: NOW })), '409 status_ungueltig')
})
test('Storno: Schreibfehler -> weder Erstattung noch Statuswechsel; Wiederholung klappt', async () => {
const { sctx, id } = await bezahltOnline()
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
await assert.rejects(ab.storniere(sctx, id, { erstattungCent: 20000, now: NOW }))
assert.equal(zahlung._aufrufe.length, 0)
assert.equal((await sp.ladeAnfrage(id)).status, 'bezahlt')
const r = await ab.storniere(sctx, id, { erstattungCent: 20000, now: NOW })
assert.equal(r.anfrage.erstattetCent, 20000)
assert.equal(aw.docs('ukRechnungen').size, 2)
})
test('Buchung: Wiederholung behaelt Adresse und Zahlart der ersten Rechnung', async () => {
const { id, token } = await freigegeben()
aw.failNext({ collection: 'ukAnfragen', method: 'PATCH', status: 500 })
await assert.rejects(ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW }))
const r = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { ...ADRESSE, ort: 'Hamburg' }, zahlart: 'online', now: NOW }).catch((e) => e)
// online ohne Stripe-Konto wird weiterhin abgelehnt; mit vorort und anderer Adresse gilt die erste
assert.equal(r.code, 'online_aus')
const r2 = await ab.bucheVerbindlich(ctx, id, token, { rechnungsadresse: { ...ADRESSE, ort: 'Hamburg' }, zahlart: 'vorort', now: NOW })
assert.equal(r2.anfrage.rechnungsadresse.ort, 'Berlin')
assert.equal(aw.docs('ukRechnungen').size, 1)
})
test('Rechnung traegt die gespeicherte Steuerart (Kleinunternehmer; Regel mit 0 %)', async () => {
aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: 'f1', steuerart: 'kleinunternehmer' })
const a = await anlegen()
const { token } = await ab.gibFrei(ctx, a.$id, { posten: [{ text: 'X', menge: 1, einzelCent: 5000, ustProzent: 0 }], now: NOW })
const r = await ab.bucheVerbindlich(ctx, a.$id, token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
assert.equal(r.rechnung.inhalt.steuerart, 'kleinunternehmer')
const b = await anlegen('2026-12-10', '2026-12-12')
aw.seed('ukPreise', { $id: 'p1', unterkunftId: 'u1', featureId: 'f1', steuerart: 'regel' })
const f = await ab.gibFrei(ctx, b.$id, { posten: [{ text: 'X', menge: 1, einzelCent: 5000, ustProzent: 0 }], now: NOW })
const r2 = await ab.bucheVerbindlich(ctx, b.$id, f.token, { rechnungsadresse: ADRESSE, zahlart: 'vorort', now: NOW })
assert.equal(r2.rechnung.inhalt.steuerart, 'regel')
})