969 lines
49 KiB
JavaScript
969 lines
49 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createHash, randomUUID } from 'node:crypto'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
|
|
|
// Nachbau wie in den Termine-Tests: Schema, Datumsfelder und Appwrites Standard-Limit 25 (alles, was mehr als 25
|
|
// Dokumente liefern kann, muss seitenweise lesen)
|
|
const aw = await startAppwriteMock({
|
|
schema: { termineClaims: schemaKeys('termineClaims'), termineBookings: schemaKeys('termineBookings') },
|
|
datetimes: TERMINE_DATETIMES,
|
|
defaultLimit: 25,
|
|
})
|
|
after(() => aw.close())
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
|
|
|
const { config } = await import('../server/config.js')
|
|
const { deleteDocument, listDocuments } = await import('../server/services/appwriteAdmin.js')
|
|
const { withLocks } = await import('../server/services/keyedLock.js')
|
|
const { zellenFuer, zellenId, belegeZellen, gibZellenFrei, verwaisteZellen, raeumeVerwaisteZellen } = await import('../server/services/termine/zellen.js')
|
|
const { neuesToken, hashToken, verwaltungsToken, buchungsNummer, formToken, pruefeFormToken } = await import(
|
|
'../server/services/termine/token.js'
|
|
)
|
|
const B = await import('../server/services/termine/buchungen.js')
|
|
|
|
const F = 'f_feature01'
|
|
const R = 'r_aaaaaaa1'
|
|
const MIN = 60000
|
|
const STUNDE = 60 * MIN
|
|
const TAG = 24 * STUNDE
|
|
const HALB = 30 * MIN
|
|
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
|
/** Uhrzeit am Termintag 2026-10-12 (UTC) */
|
|
const T = (hhmm) => new Date(`2026-10-12T${hhmm}:00.000Z`)
|
|
const iso = (date) => new Date(date).toISOString()
|
|
const hhmm = (wert) => new Date(wert).toISOString().slice(11, 16)
|
|
|
|
beforeEach(async () => {
|
|
for (const collection of ['termineClaims', 'termineBookings']) {
|
|
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
|
}
|
|
})
|
|
|
|
const buchung = (id, over = {}) => ({
|
|
$id: id,
|
|
featureId: F,
|
|
customerId: 'c_kunde01',
|
|
projectId: 'p_projekt01',
|
|
leistungId: 's_aaaaaaa1',
|
|
leistungName: 'Damenschnitt',
|
|
ressourceId: R,
|
|
ressourceName: 'Anna',
|
|
startsAt: iso(T('10:00')),
|
|
endsAt: iso(T('10:45')),
|
|
blockEndsAt: iso(T('10:50')),
|
|
status: 'bestaetigt',
|
|
quelle: 'online',
|
|
gastEmail: 'max@gmail.com',
|
|
createdAt: iso(JETZT),
|
|
updatedAt: iso(JETZT),
|
|
...over,
|
|
})
|
|
const seedBuchung = (id, over) => aw.seed('termineBookings', buchung(id, over))
|
|
|
|
/** Buchung anlegen (Dokument zuerst, dann Zellen) und die Zellen von von bis bis belegen. */
|
|
async function belege(id, von, bis, over = {}) {
|
|
seedBuchung(id, { startsAt: iso(von), endsAt: iso(bis), blockEndsAt: iso(bis), ...over })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: von, blockEnde: bis, now: JETZT })
|
|
}
|
|
|
|
const claimsOf = (bookingId) => [...aw.docs('termineClaims').values()].filter((doc) => doc.bookingId === bookingId)
|
|
const zeitenOf = (bookingId) => claimsOf(bookingId).map((doc) => hhmm(doc.zelle)).sort()
|
|
const alleClaims = () => aw.docs('termineClaims').size
|
|
const schlafe = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
/** Die ersten n DELETE-Anfragen an Appwrite brauchen ms laenger (ein langsamer Aufraeum-Lauf). */
|
|
async function mitVerzoegertemLoeschen(n, ms, fn) {
|
|
const echt = globalThis.fetch
|
|
let gezaehlt = 0
|
|
globalThis.fetch = async (url, optionen) => {
|
|
if (optionen?.method === 'DELETE' && gezaehlt++ < n) await schlafe(ms)
|
|
return echt(url, optionen)
|
|
}
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
globalThis.fetch = echt
|
|
}
|
|
}
|
|
|
|
/** Haelt die Ressourcensperre, bis freigeben() gerufen wird. */
|
|
function haltePerSperre(featureId = F, ressourceId = R) {
|
|
let freigeben
|
|
const gehalten = new Promise((resolve) => (freigeben = resolve))
|
|
const fertig = withLocks([`termine-res:${featureId}:${ressourceId}`], () => gehalten)
|
|
return { freigeben, fertig }
|
|
}
|
|
const vergeben = (err) => err.status === 409 && err.code === 'SLOT_VERGEBEN'
|
|
|
|
// ---------------------------------------------------------------- Tokens
|
|
|
|
test('neuesToken: 32 Zufallsbytes base64url, gespeichert wird nur der SHA-256-Hex', () => {
|
|
const a = neuesToken()
|
|
const b = neuesToken()
|
|
assert.match(a.token, /^[A-Za-z0-9_-]{43}$/)
|
|
assert.equal(Buffer.from(a.token, 'base64url').length, 32)
|
|
assert.match(a.hash, /^[0-9a-f]{64}$/)
|
|
assert.equal(a.hash, createHash('sha256').update(a.token).digest('hex'))
|
|
assert.equal(a.hash, hashToken(a.token))
|
|
assert.notEqual(a.token, b.token)
|
|
assert.notEqual(a.hash, b.hash)
|
|
})
|
|
|
|
test('hashToken: fremde Eingaben (kein Text) werfen nicht und ergeben den Hash des leeren Textes', () => {
|
|
assert.equal(hashToken(undefined), hashToken(''))
|
|
assert.equal(hashToken({ a: 1 }), hashToken(''))
|
|
assert.notEqual(hashToken('abc'), hashToken('abd'))
|
|
})
|
|
|
|
test('Verwaltungstoken ist stabil, je Buchung verschieden und vom Sitzungsgeheimnis abhaengig', () => {
|
|
const id = randomUUID()
|
|
const token = verwaltungsToken(id)
|
|
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
|
assert.equal(verwaltungsToken(id), token)
|
|
assert.notEqual(verwaltungsToken(randomUUID()), token)
|
|
assert.notEqual(token, hashToken(id))
|
|
const geheimnis = config.sessionSecret
|
|
try {
|
|
config.sessionSecret = `${geheimnis}-anders`
|
|
assert.notEqual(verwaltungsToken(id), token)
|
|
} finally {
|
|
config.sessionSecret = geheimnis
|
|
}
|
|
assert.equal(verwaltungsToken(id), token)
|
|
assert.throws(() => verwaltungsToken(''), TypeError)
|
|
assert.throws(() => verwaltungsToken(undefined), TypeError)
|
|
})
|
|
|
|
test('ohne Sitzungsgeheimnis gibt es kein Token (kein Schluessel aus dem leeren Text)', () => {
|
|
const geheimnis = config.sessionSecret
|
|
try {
|
|
config.sessionSecret = ''
|
|
assert.throws(() => verwaltungsToken(randomUUID()), /SESSION_SECRET/)
|
|
assert.throws(() => formToken('abc', JETZT), /SESSION_SECRET/)
|
|
} finally {
|
|
config.sessionSecret = geheimnis
|
|
}
|
|
})
|
|
|
|
test('Buchungsnummer: 8 Zeichen A-Z0-9, stabil, praktisch eindeutig', () => {
|
|
const id = randomUUID()
|
|
assert.match(buchungsNummer(id), /^[A-Z0-9]{8}$/)
|
|
assert.equal(buchungsNummer(id), buchungsNummer(id))
|
|
const nummern = new Set(Array.from({ length: 500 }, () => buchungsNummer(randomUUID())))
|
|
assert.equal(nummern.size, 500)
|
|
for (const nummer of nummern) assert.match(nummer, /^[A-Z0-9]{8}$/)
|
|
assert.throws(() => buchungsNummer(''), TypeError)
|
|
})
|
|
|
|
test('Formular-Token: zu jung, passend, zu alt, gefaelscht', () => {
|
|
const vorher = config.termine.formMinSekunden
|
|
config.termine.formMinSekunden = 3
|
|
try {
|
|
const token = formToken('site1', JETZT)
|
|
const nach = (ms) => new Date(JETZT.getTime() + ms)
|
|
assert.equal(pruefeFormToken('site1', token, JETZT), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2999)), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, nach(3000)), 'ok')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2 * STUNDE)), 'ok')
|
|
assert.equal(pruefeFormToken('site1', token, nach(2 * STUNDE + 1)), 'abgelaufen')
|
|
assert.equal(pruefeFormToken('site1', token, nach(30 * TAG)), 'abgelaufen')
|
|
|
|
// anderer Betrieb, veraenderter Zeitstempel, veraenderte Pruefsumme, Muell
|
|
assert.equal(pruefeFormToken('site2', token, nach(10000)), 'ungueltig')
|
|
const [zeit, mac] = token.split('.')
|
|
const juenger = (parseInt(zeit, 36) + 60000).toString(36)
|
|
assert.equal(pruefeFormToken('site1', `${juenger}.${mac}`, nach(120000)), 'ungueltig')
|
|
const kaputt = `${zeit}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`
|
|
assert.equal(pruefeFormToken('site1', kaputt, nach(10000)), 'ungueltig')
|
|
assert.equal(pruefeFormToken('site1', `${zeit}.`, nach(10000)), 'ungueltig')
|
|
for (const muell of ['', 'abc', '.', 'a.b.c', undefined, null, 42, {}, 'x'.repeat(5000), `${zeit}.${'A'.repeat(43)}`]) {
|
|
assert.equal(pruefeFormToken('site1', muell, nach(10000)), 'ungueltig', String(muell).slice(0, 20))
|
|
}
|
|
assert.equal(pruefeFormToken(undefined, token, nach(10000)), 'ungueltig')
|
|
assert.equal(pruefeFormToken('', token, nach(10000)), 'ungueltig')
|
|
} finally {
|
|
config.termine.formMinSekunden = vorher
|
|
}
|
|
})
|
|
|
|
test('Formular-Token: die Mindestzeit wird bei jedem Aufruf aus config.termine gelesen', () => {
|
|
const vorher = config.termine.formMinSekunden
|
|
try {
|
|
const token = formToken('site1', JETZT)
|
|
config.termine.formMinSekunden = 10
|
|
assert.equal(pruefeFormToken('site1', token, new Date(JETZT.getTime() + 5000)), 'zu_schnell')
|
|
assert.equal(pruefeFormToken('site1', token, new Date(JETZT.getTime() + 10000)), 'ok')
|
|
config.termine.formMinSekunden = 0
|
|
assert.equal(pruefeFormToken('site1', token, JETZT), 'ok')
|
|
} finally {
|
|
config.termine.formMinSekunden = vorher
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen: reine Funktionen
|
|
|
|
test('zellenFuer: 5-Minuten-Zellen von Beginn bis Blockende (Ende ausgeschlossen)', () => {
|
|
const zellen = zellenFuer(T('10:00'), T('10:50'))
|
|
assert.equal(zellen.length, 10)
|
|
assert.ok(zellen.every((zelle) => zelle instanceof Date))
|
|
assert.equal(hhmm(zellen[0]), '10:00')
|
|
assert.equal(hhmm(zellen.at(-1)), '10:45')
|
|
assert.equal(zellenFuer(T('10:00'), T('10:05')).length, 1)
|
|
// ueber Mitternacht, ISO-Text mit +00:00 wie von Appwrite
|
|
const nacht = zellenFuer('2026-10-12T23:50:00.000+00:00', '2026-10-13T00:10:00.000+00:00')
|
|
assert.deepEqual(nacht.map((zelle) => zelle.toISOString()), [
|
|
'2026-10-12T23:50:00.000Z',
|
|
'2026-10-12T23:55:00.000Z',
|
|
'2026-10-13T00:00:00.000Z',
|
|
'2026-10-13T00:05:00.000Z',
|
|
])
|
|
})
|
|
|
|
test('zellenFuer: krumme Zeiten werden auf das 5-Minuten-Raster erweitert, Unsinn wirft', () => {
|
|
const zellen = zellenFuer(new Date('2026-10-12T10:02:00Z'), new Date('2026-10-12T10:11:00Z'))
|
|
assert.deepEqual(zellen.map(hhmm), ['10:00', '10:05', '10:10'])
|
|
assert.throws(() => zellenFuer(T('10:00'), T('10:00')), RangeError)
|
|
assert.throws(() => zellenFuer(T('11:00'), T('10:00')), RangeError)
|
|
assert.throws(() => zellenFuer('kaputt', T('10:00')), RangeError)
|
|
assert.equal(zellenFuer(T('08:00'), T('16:20')).length, 100)
|
|
assert.throws(() => zellenFuer(T('08:00'), T('16:25')), /100 Zellen/)
|
|
})
|
|
|
|
test('zellenFuer richtet sich nach config.termine.zelleMin', () => {
|
|
const vorher = config.termine.zelleMin
|
|
try {
|
|
config.termine.zelleMin = 15
|
|
assert.deepEqual(zellenFuer(T('10:00'), T('10:50')).map(hhmm), ['10:00', '10:15', '10:30', '10:45'])
|
|
} finally {
|
|
config.termine.zelleMin = vorher
|
|
}
|
|
})
|
|
|
|
test('zellenId: c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso), 36 Zeichen', () => {
|
|
const id = zellenId(F, R, T('10:00'))
|
|
assert.equal(id.length, 36)
|
|
assert.match(id, /^c_[0-9a-f]{34}$/)
|
|
const erwartet = `c_${createHash('sha1').update(`${F}|${R}|2026-10-12T10:00:00.000Z`).digest('hex').slice(0, 34)}`
|
|
assert.equal(id, erwartet)
|
|
// Date und Appwrite-Text sind dieselbe Zelle
|
|
assert.equal(zellenId(F, R, '2026-10-12T10:00:00.000+00:00'), id)
|
|
assert.notEqual(zellenId(F, R, T('10:05')), id)
|
|
assert.notEqual(zellenId(F, 'r_aaaaaaa2', T('10:00')), id)
|
|
assert.notEqual(zellenId('f_feature02', R, T('10:00')), id)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen belegen
|
|
|
|
test('belegeZellen legt je Zelle ein Dokument mit festem Schluessel und bis = Blockende an', async () => {
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_eins', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
const claims = [...aw.docs('termineClaims').values()]
|
|
assert.equal(claims.length, 10)
|
|
for (const claim of claims) {
|
|
assert.equal(claim.featureId, F)
|
|
assert.equal(claim.ressourceId, R)
|
|
assert.equal(claim.bookingId, 'b_eins')
|
|
assert.equal(new Date(claim.bis).getTime(), T('10:50').getTime())
|
|
assert.equal(claim.$id, zellenId(F, R, claim.zelle))
|
|
}
|
|
assert.deepEqual(zeitenOf('b_eins'), zellenFuer(T('10:00'), T('10:50')).map(hhmm))
|
|
})
|
|
|
|
test('zweite Buchung derselben Zeit: SLOT_VERGEBEN, die erste bleibt unberuehrt', async () => {
|
|
await belege('b_eins', T('10:00'), T('10:50'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
seedBuchung('b_zwei', { startsAt: iso(T('10:00')), blockEndsAt: iso(T('10:50')) })
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('10:00'), blockEnde: T('10:50'), now: JETZT }),
|
|
(err) => vergeben(err) && /gerade vergeben/.test(err.message)
|
|
)
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
assert.equal(claimsOf('b_zwei').length, 0)
|
|
})
|
|
|
|
test('teilweise Ueberlappung: SLOT_VERGEBEN ohne Teil-Schreibung', async () => {
|
|
await belege('b_eins', T('10:00'), T('11:00'))
|
|
seedBuchung('b_zwei')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('10:30'), blockEnde: T('11:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_zwei').length, 0)
|
|
assert.equal(claimsOf('b_eins').length, 12)
|
|
})
|
|
|
|
test('angrenzende Zeiten, andere Ressource und andere Funktion stoeren sich nicht', async () => {
|
|
await belege('b_eins', T('10:00'), T('11:00'))
|
|
seedBuchung('b_zwei')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_zwei', start: T('11:00'), blockEnde: T('12:00'), now: JETZT })
|
|
seedBuchung('b_drei', { ressourceId: 'r_aaaaaaa2' })
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_drei', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
seedBuchung('b_vier', { featureId: 'f_feature02' })
|
|
await belegeZellen({ featureId: 'f_feature02', ressourceId: R, bookingId: 'b_vier', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
assert.equal(alleClaims(), 48)
|
|
})
|
|
|
|
test('10 parallele Buchungen derselben Zeit: genau eine gewinnt', async () => {
|
|
const ids = Array.from({ length: 10 }, (_, i) => `b_par${i}`)
|
|
for (const id of ids) seedBuchung(id, { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + 30 * MIN)) })
|
|
const ergebnisse = await Promise.allSettled(
|
|
ids.map((id) => belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: T('10:00'), blockEnde: T('10:50'), now: JETZT }))
|
|
)
|
|
const gewonnen = ids.filter((_, i) => ergebnisse[i].status === 'fulfilled')
|
|
assert.equal(gewonnen.length, 1)
|
|
for (const ergebnis of ergebnisse.filter((e) => e.status === 'rejected')) assert.ok(vergeben(ergebnis.reason), ergebnis.reason?.message)
|
|
assert.equal(alleClaims(), 10)
|
|
assert.equal(claimsOf(gewonnen[0]).length, 10)
|
|
})
|
|
|
|
test('10 parallele Buchungen ueberlappender Zeiten: die Zellen gehoeren nie zwei Buchungen', async () => {
|
|
const ids = Array.from({ length: 10 }, (_, i) => `b_ue${i}`)
|
|
for (const id of ids) seedBuchung(id)
|
|
await Promise.allSettled(
|
|
ids.map((id, i) =>
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: id, start: new Date(T('10:00').getTime() + i * 15 * MIN), blockEnde: new Date(T('11:00').getTime() + i * 15 * MIN), now: JETZT })
|
|
)
|
|
)
|
|
const gesehen = new Set()
|
|
for (const claim of aw.docs('termineClaims').values()) {
|
|
assert.ok(!gesehen.has(claim.zelle), `Zelle ${claim.zelle} doppelt`)
|
|
gesehen.add(claim.zelle)
|
|
}
|
|
// jede Buchung hat ganz oder gar nicht belegt
|
|
for (const id of ids) assert.ok([0, 12].includes(claimsOf(id).length), `${id}: ${claimsOf(id).length}`)
|
|
})
|
|
|
|
test('abgelaufene Haltung blockiert nicht, gueltige schon (heldUntil == jetzt ist abgelaufen)', async () => {
|
|
const haltung = (ms) => ({ status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + ms)) })
|
|
await belege('b_alt', T('10:00'), T('11:00'), haltung(-1))
|
|
seedBuchung('b_neu')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu').length, 12)
|
|
assert.equal(claimsOf('b_alt').length, 0, 'die ungueltigen Zellen sind geloescht, nicht nur uebergangen')
|
|
|
|
await belege('b_genau', T('12:00'), T('13:00'), haltung(0))
|
|
seedBuchung('b_neu2')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu2', start: T('12:00'), blockEnde: T('13:00'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu2').length, 12)
|
|
|
|
await belege('b_gueltig', T('14:00'), T('15:00'), haltung(1))
|
|
seedBuchung('b_neu3')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu3', start: T('14:00'), blockEnde: T('15:00'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_gueltig').length, 12)
|
|
})
|
|
|
|
test('Zellen ohne Buchung blockieren nicht; nicht blockierende Altlasten bleiben stehen', async () => {
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg', start: T('10:00'), blockEnde: T('11:00'), now: JETZT })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg2', start: T('16:00'), blockEnde: T('17:00'), now: JETZT })
|
|
seedBuchung('b_neu')
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:30'), blockEnde: T('11:30'), now: JETZT })
|
|
assert.equal(claimsOf('b_neu').length, 12)
|
|
// nur die blockierenden Zellen (10:30 bis 11:00) wurden von b_weg geraeumt; der Rest und b_weg2 bleiben
|
|
assert.equal(claimsOf('b_weg').length, 6)
|
|
assert.equal(claimsOf('b_weg2').length, 12)
|
|
})
|
|
|
|
test('nur angefragt und bestaetigt belegen; alle anderen Status geben die Zellen frei', async () => {
|
|
let n = 0
|
|
for (const [status, blockiert] of [
|
|
['bestaetigt', true],
|
|
['angefragt', true],
|
|
['abgesagt', false],
|
|
['abgelehnt', false],
|
|
['verfallen', false],
|
|
['erledigt', false],
|
|
['nichterschienen', false],
|
|
]) {
|
|
n += 1
|
|
const alt = `b_alt${n}`
|
|
const neu = `b_neu${n}`
|
|
await belege(alt, T('10:00'), T('10:30'), { status })
|
|
seedBuchung(neu)
|
|
const versuch = belegeZellen({ featureId: F, ressourceId: R, bookingId: neu, start: T('10:00'), blockEnde: T('10:30'), now: JETZT })
|
|
if (blockiert) await assert.rejects(versuch, vergeben, status)
|
|
else await versuch
|
|
await gibZellenFrei(alt)
|
|
await gibZellenFrei(neu)
|
|
}
|
|
assert.equal(alleClaims(), 0)
|
|
})
|
|
|
|
test('Sperre (quelle sperre) belegt wie eine Buchung', async () => {
|
|
await belege('b_sperre', T('12:00'), T('13:00'), { quelle: 'sperre', status: 'bestaetigt', gastEmail: '' })
|
|
seedBuchung('b_gast')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_gast', start: T('12:30'), blockEnde: T('13:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
})
|
|
|
|
test('gueltige und ungueltige Blockierer gemischt: SLOT_VERGEBEN nach genau einem zweiten Versuch, gueltige bleiben', async () => {
|
|
await belege('b_gueltig', T('10:00'), T('10:30'))
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_weg', start: T('10:30'), blockEnde: T('11:00'), now: JETZT })
|
|
seedBuchung('b_neu')
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_gueltig').length, 6)
|
|
assert.equal(claimsOf('b_neu').length, 0)
|
|
})
|
|
|
|
test('belegeZellen wartet auf die Sperre seiner Ressource, andere Ressourcen laufen weiter', async () => {
|
|
let freigeben
|
|
const gehalten = new Promise((resolve) => (freigeben = resolve))
|
|
const sperre = withLocks([`termine-res:${F}:${R}`], () => gehalten)
|
|
let fertig = false
|
|
const versuch = belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_wartet', start: T('10:00'), blockEnde: T('10:30'), now: JETZT }).then(() => {
|
|
fertig = true
|
|
})
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_frei', start: T('10:00'), blockEnde: T('10:30'), now: JETZT })
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
assert.equal(fertig, false)
|
|
assert.equal(claimsOf('b_wartet').length, 0)
|
|
freigeben()
|
|
await Promise.all([sperre, versuch])
|
|
assert.equal(fertig, true)
|
|
assert.equal(claimsOf('b_wartet').length, 6)
|
|
assert.equal(claimsOf('b_frei').length, 6)
|
|
})
|
|
|
|
test('Verschieben 10:00 nach 10:30 mit ausser: nur die fehlenden Zellen entstehen, dann raeumt gibZellenFrei auf', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const alt = new Map(claimsOf('b_x').map((doc) => [doc.$id, doc.$createdAt]))
|
|
assert.equal(alt.size, 12)
|
|
|
|
// ohne ausser blockiert sich die Buchung selbst
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT }),
|
|
vergeben
|
|
)
|
|
assert.equal(claimsOf('b_x').length, 12)
|
|
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT, ausser: 'b_x' })
|
|
assert.deepEqual(zeitenOf('b_x'), zellenFuer(T('10:00'), T('11:30')).map(hhmm))
|
|
for (const claim of claimsOf('b_x')) {
|
|
if (alt.has(claim.$id)) assert.equal(claim.$createdAt, alt.get(claim.$id), 'bestehende Zellen werden nicht neu angelegt')
|
|
}
|
|
assert.equal(new Date(claimsOf('b_x').find((doc) => hhmm(doc.zelle) === '11:25').bis).getTime(), T('11:30').getTime())
|
|
|
|
const geloescht = await gibZellenFrei('b_x', { behalte: zellenFuer(T('10:30'), T('11:30')) })
|
|
assert.equal(geloescht, 6)
|
|
assert.deepEqual(zeitenOf('b_x'), zellenFuer(T('10:30'), T('11:30')).map(hhmm))
|
|
})
|
|
|
|
test('Verschieben in einen fremden Bereich: SLOT_VERGEBEN, alte Zellen unveraendert', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
await belege('b_y', T('11:00'), T('12:00'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:30'), blockEnde: T('11:30'), now: JETZT, ausser: 'b_x' }),
|
|
vergeben
|
|
)
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
})
|
|
|
|
test('Verschieben auf eine andere Ressource zur selben Zeit: gibZellenFrei mit ressourceId raeumt die alte Ressource', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT, ausser: 'b_x' })
|
|
assert.equal(claimsOf('b_x').length, 24, 'andere Ressource = andere Zellen, die alten bleiben bis zum Aufraeumen')
|
|
const neu = zellenFuer(T('10:00'), T('11:00'))
|
|
// nur nach Zeit: beide Ressourcen haben dieselben Zeiten, nichts wird geloescht
|
|
assert.equal(await gibZellenFrei('b_x', { behalte: neu }), 0)
|
|
assert.equal(await gibZellenFrei('b_x', { behalte: neu, ressourceId: 'r_aaaaaaa2' }), 12)
|
|
assert.deepEqual([...new Set(claimsOf('b_x').map((doc) => doc.ressourceId))], ['r_aaaaaaa2'])
|
|
assert.equal(claimsOf('b_x').length, 12)
|
|
await gibZellenFrei('b_x')
|
|
assert.equal(claimsOf('b_x').length, 0)
|
|
})
|
|
|
|
test('ausser: ist schon alles belegt, entsteht nichts (keine Anfrage)', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const vorher = [...aw.docs('termineClaims').entries()]
|
|
aw.failNext({ collection: 'termineClaims', method: 'POST', status: 500 })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:15'), blockEnde: T('10:45'), now: JETZT, ausser: 'b_x' })
|
|
assert.deepEqual([...aw.docs('termineClaims').entries()], vorher)
|
|
// den eingeplanten Fehler wieder verbrauchen
|
|
await assert.rejects(belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_y', start: T('13:00'), blockEnde: T('13:30'), now: JETZT }), (err) => err.status === 500)
|
|
})
|
|
|
|
test('andere Fehler als 409 gehen unveraendert durch und schreiben nichts', async () => {
|
|
aw.failNext({ collection: 'termineClaims', method: 'POST', status: 503 })
|
|
await assert.rejects(
|
|
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }),
|
|
(err) => err.status === 500 && err.code !== 'SLOT_VERGEBEN'
|
|
)
|
|
assert.equal(alleClaims(), 0)
|
|
})
|
|
|
|
test('belegeZellen prueft die Eingaben: Textfelder, now, Reihenfolge, mehr als 100 Zellen', async () => {
|
|
const gut = { featureId: F, ressourceId: R, bookingId: 'b_x', start: T('10:00'), blockEnde: T('11:00'), now: JETZT }
|
|
await assert.rejects(belegeZellen({ ...gut, featureId: '' }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, ressourceId: undefined }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, bookingId: 5 }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, now: undefined }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, now: 'heute' }), TypeError)
|
|
await assert.rejects(belegeZellen({ ...gut, blockEnde: T('10:00') }), RangeError)
|
|
await assert.rejects(belegeZellen({ ...gut, start: T('08:00'), blockEnde: T('16:25') }), /100 Zellen/)
|
|
assert.equal(alleClaims(), 0)
|
|
// genau 100 Zellen (500 Minuten) gehen in einem Bulk
|
|
await belegeZellen({ ...gut, start: T('08:00'), blockEnde: T('16:20') })
|
|
assert.equal(alleClaims(), 100)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Zellen freigeben und aufraeumen
|
|
|
|
test('gibZellenFrei loescht 40 Zellen (mehr als eine 25er-Seite) und nur die der Buchung', async () => {
|
|
await belege('b_gross', T('08:00'), T('11:20')) // 200 Minuten = 40 Zellen
|
|
await belege('b_andere', T('12:00'), T('12:30'))
|
|
assert.equal(claimsOf('b_gross').length, 40)
|
|
// Gegenprobe: ohne limit liefert der Nachbau wie Appwrite nur 25
|
|
const unbegrenzt = await listDocuments('termineClaims', [])
|
|
assert.equal(unbegrenzt.length, 25)
|
|
|
|
const geloescht = await gibZellenFrei('b_gross')
|
|
assert.equal(geloescht, 40)
|
|
assert.equal(claimsOf('b_gross').length, 0)
|
|
assert.equal(claimsOf('b_andere').length, 6)
|
|
assert.equal(await gibZellenFrei('b_gross'), 0)
|
|
assert.equal(await gibZellenFrei('b_gibt_es_nicht'), 0)
|
|
})
|
|
|
|
test('gibZellenFrei behaelt die genannten Zellzeiten (Date oder Text)', async () => {
|
|
await belege('b_x', T('10:00'), T('11:00'))
|
|
const behalte = [T('10:00'), iso(T('10:05')), '2026-10-12T10:10:00.000+00:00']
|
|
assert.equal(await gibZellenFrei('b_x', { behalte }), 9)
|
|
assert.deepEqual(zeitenOf('b_x'), ['10:00', '10:05', '10:10'])
|
|
})
|
|
|
|
test('gibZellenFrei: ein Fehler bleibt nicht unbemerkt, die uebrigen Zellen werden trotzdem geloescht', async () => {
|
|
await belege('b_x', T('10:00'), T('10:30'))
|
|
aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 })
|
|
await assert.rejects(gibZellenFrei('b_x'), (err) => err.status === 500)
|
|
assert.equal(claimsOf('b_x').length, 1)
|
|
})
|
|
|
|
test('spaetes Aufraeumen einer alten Buchung loescht nicht die frisch belegten Zellen einer neuen (verzoegertes DELETE)', async () => {
|
|
// X: abgelaufene Haltung mit Zellen; W belegt dieselbe Zeit, waehrend der Aufraeum-Lauf von X noch loescht
|
|
seedBuchung('b_X', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - 1000)) })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:50'), now: new Date(JETZT.getTime() - 60000) })
|
|
seedBuchung('b_W')
|
|
await mitVerzoegertemLoeschen(10, 400, async () => {
|
|
const lauf = gibZellenFrei('b_X')
|
|
await schlafe(20)
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
await lauf
|
|
})
|
|
assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen')
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
test('gibZellenFrei loescht nur, was nach dem Warten auf die Sperre noch zur Buchung gehoert', async () => {
|
|
await belege('b_X', T('10:00'), T('10:30'))
|
|
seedBuchung('b_W')
|
|
const sperre = haltePerSperre()
|
|
const lauf = gibZellenFrei('b_X')
|
|
await schlafe(100) // der Lauf hat gelesen und wartet auf die Sperre
|
|
// in der Zwischenzeit gehen die Zellen an W (X abgesagt, W belegt dieselben IDs neu)
|
|
seedBuchung('b_X', { status: 'abgesagt' })
|
|
for (const claim of claimsOf('b_X')) aw.seed('termineClaims', { ...claim, bookingId: 'b_W' })
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.equal(await lauf, 0)
|
|
assert.equal(claimsOf('b_W').length, 6)
|
|
})
|
|
|
|
test('gibZellenFrei sperrt jede Ressource der Buchung, auch mehrere zugleich', async () => {
|
|
await belege('b_X', T('10:00'), T('10:30'))
|
|
await belegeZellen({ featureId: F, ressourceId: 'r_aaaaaaa2', bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:30'), now: JETZT, ausser: 'b_X' })
|
|
const sperre = haltePerSperre(F, 'r_aaaaaaa2')
|
|
let fertig = false
|
|
const lauf = gibZellenFrei('b_X').then((n) => {
|
|
fertig = true
|
|
return n
|
|
})
|
|
await schlafe(100)
|
|
assert.equal(fertig, false, 'wartet auf die Sperre der zweiten Ressource')
|
|
assert.equal(claimsOf('b_X').length, 12)
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.equal(await lauf, 12)
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
test('verwaisteZellen: Buchung fehlt, nicht mehr belegend oder Zelle laenger als einen Tag vorbei', async () => {
|
|
const claim = (id, bookingId, bis, extra = {}) =>
|
|
aw.seed('termineClaims', {
|
|
$id: id,
|
|
featureId: F,
|
|
ressourceId: R,
|
|
bookingId,
|
|
zelle: iso(new Date(new Date(bis).getTime() - 5 * MIN)),
|
|
bis: iso(bis),
|
|
...extra,
|
|
})
|
|
const bald = new Date(JETZT.getTime() + 2 * TAG)
|
|
seedBuchung('b_ok', { status: 'bestaetigt' })
|
|
seedBuchung('b_anfrage', { status: 'angefragt' })
|
|
seedBuchung('b_haltung', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() + MIN)) })
|
|
seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) })
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt' })
|
|
seedBuchung('b_erledigt', { status: 'erledigt' })
|
|
seedBuchung('b_alt', { status: 'bestaetigt' })
|
|
claim('c_ok', 'b_ok', bald)
|
|
claim('c_anfrage', 'b_anfrage', bald)
|
|
claim('c_haltung', 'b_haltung', bald)
|
|
claim('c_haltung_alt', 'b_haltung_alt', bald)
|
|
claim('c_abgesagt', 'b_abgesagt', bald)
|
|
claim('c_erledigt', 'b_erledigt', bald)
|
|
claim('c_fehlt', 'b_gibt_es_nicht', bald)
|
|
claim('c_ohne', '', bald)
|
|
claim('c_alt', 'b_alt', new Date(JETZT.getTime() - TAG - MIN)) // Buchung laeuft noch, die Zelle ist aber ueber einen Tag vorbei
|
|
claim('c_gestern', 'b_ok', new Date(JETZT.getTime() - TAG + MIN)) // noch keinen vollen Tag vorbei, Buchung belegt
|
|
const ids = await verwaisteZellen(JETZT)
|
|
assert.deepEqual(
|
|
[...ids].sort(),
|
|
['c_abgesagt', 'c_alt', 'c_erledigt', 'c_fehlt', 'c_haltung_alt', 'c_ohne'].sort()
|
|
)
|
|
})
|
|
|
|
test('verwaisteZellen liest alle Zellen (mehr als eine Seite) und prueft ihre Buchungen in Bloecken', async () => {
|
|
// 260 Zellen, je 2 pro Buchung (130 Buchungen, mehr als ein Block); jede zweite Buchung existiert nicht
|
|
for (let i = 0; i < 260; i += 1) {
|
|
const bookingId = `b_viele${Math.floor(i / 2)}`
|
|
if (i % 4 < 2) seedBuchung(bookingId)
|
|
aw.seed('termineClaims', {
|
|
$id: `c_viele${String(i).padStart(3, '0')}`,
|
|
featureId: F,
|
|
ressourceId: R,
|
|
bookingId,
|
|
zelle: iso(new Date(T('10:00').getTime() + i * 5 * MIN)),
|
|
bis: iso(T('23:00')),
|
|
})
|
|
}
|
|
const ids = await verwaisteZellen(JETZT)
|
|
const erwartet = []
|
|
for (let i = 0; i < 260; i += 1) if (i % 4 >= 2) erwartet.push(`c_viele${String(i).padStart(3, '0')}`)
|
|
assert.equal(erwartet.length, 130)
|
|
assert.deepEqual([...ids].sort(), erwartet.sort())
|
|
})
|
|
|
|
/** Zellen mit eigener ID in der Datenbank anlegen (ohne belegeZellen, damit auch ungueltige Altlasten entstehen). */
|
|
function seedZelle(id, bookingId, bis, extra = {}) {
|
|
aw.seed('termineClaims', {
|
|
$id: id,
|
|
featureId: F,
|
|
ressourceId: R,
|
|
bookingId,
|
|
zelle: iso(new Date(new Date(bis).getTime() - 5 * MIN)),
|
|
bis: iso(bis),
|
|
...extra,
|
|
})
|
|
}
|
|
|
|
test('raeumeVerwaisteZellen loescht genau die verwaisten Zellen und zaehlt sie', async () => {
|
|
const bald = new Date(JETZT.getTime() + 2 * TAG)
|
|
seedBuchung('b_ok')
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt' })
|
|
seedBuchung('b_haltung_alt', { status: 'gehalten', heldUntil: iso(new Date(JETZT.getTime() - MIN)) })
|
|
seedBuchung('b_alt')
|
|
seedZelle('c_ok', 'b_ok', bald)
|
|
seedZelle('c_abgesagt', 'b_abgesagt', bald, { ressourceId: 'r_aaaaaaa2' })
|
|
seedZelle('c_haltung_alt', 'b_haltung_alt', bald)
|
|
seedZelle('c_fehlt', 'b_gibt_es_nicht', bald, { featureId: 'f_feature02' })
|
|
seedZelle('c_alt', 'b_alt', new Date(JETZT.getTime() - TAG - MIN))
|
|
assert.equal(await raeumeVerwaisteZellen(JETZT), 4)
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
|
|
assert.equal(await raeumeVerwaisteZellen(JETZT), 0)
|
|
await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError)
|
|
})
|
|
|
|
test('raeumeVerwaisteZellen raeumt mehr als einen Block (260 Zellen auf zwei Ressourcen)', async () => {
|
|
for (let i = 0; i < 260; i += 1) {
|
|
seedZelle(`c_viele${String(i).padStart(3, '0')}`, `b_nie${i}`, T('23:00'), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R })
|
|
}
|
|
seedBuchung('b_ok')
|
|
seedZelle('c_ok', 'b_ok', T('23:00'))
|
|
assert.equal(await raeumeVerwaisteZellen(JETZT), 260)
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
|
|
})
|
|
|
|
test('raeumeVerwaisteZellen: ein verzoegertes DELETE trifft nicht die frisch belegten Zellen einer neuen Buchung', async () => {
|
|
seedBuchung('b_X', { status: 'abgesagt' })
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_X', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
seedBuchung('b_W')
|
|
await mitVerzoegertemLoeschen(10, 400, async () => {
|
|
const lauf = raeumeVerwaisteZellen(JETZT)
|
|
await schlafe(20)
|
|
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_W', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
|
|
await lauf
|
|
})
|
|
assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen')
|
|
assert.equal(claimsOf('b_X').length, 0)
|
|
})
|
|
|
|
test('raeumeVerwaisteZellen prueft nach dem Warten auf die Sperre erneut: wieder gueltige oder neu vergebene Zellen bleiben', async () => {
|
|
const bald = new Date(JETZT.getTime() + 2 * TAG)
|
|
seedBuchung('b_X1', { status: 'abgesagt' })
|
|
seedBuchung('b_X2', { status: 'abgesagt' })
|
|
seedBuchung('b_X3', { status: 'abgesagt' })
|
|
seedBuchung('b_W')
|
|
seedZelle('c_x1', 'b_X1', bald)
|
|
seedZelle('c_x2', 'b_X2', bald)
|
|
seedZelle('c_x3', 'b_X3', bald)
|
|
seedZelle('c_x4', 'b_X1', bald)
|
|
const sperre = haltePerSperre()
|
|
const lauf = raeumeVerwaisteZellen(JETZT)
|
|
await schlafe(100) // der Lauf hat die Kandidaten gelesen und wartet auf die Sperre
|
|
seedBuchung('b_X1', { status: 'bestaetigt' }) // wieder gueltig
|
|
aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x2'), bookingId: 'b_W' }) // neu vergeben
|
|
await deleteDocument('termineClaims', 'c_x3') // schon weg
|
|
aw.seed('termineClaims', { ...aw.docs('termineClaims').get('c_x4'), bookingId: 'b_neu_ohne_buchung' }) // neu vergeben, Buchung folgt erst noch
|
|
sperre.freigeben()
|
|
await sperre.fertig
|
|
assert.equal(await lauf, 0)
|
|
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x1', 'c_x2', 'c_x4'])
|
|
assert.equal(aw.docs('termineClaims').get('c_x2').bookingId, 'b_W')
|
|
assert.equal(aw.docs('termineClaims').get('c_x4').bookingId, 'b_neu_ohne_buchung')
|
|
})
|
|
|
|
test('raeumeVerwaisteZellen: ein Fehler in einem Block stoppt die uebrigen nicht und wird am Ende geworfen', async () => {
|
|
seedZelle('c_a1', 'b_nie1', T('23:00'))
|
|
seedZelle('c_b1', 'b_nie2', T('23:00'), { ressourceId: 'r_aaaaaaa2' })
|
|
aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 503 })
|
|
await assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
|
|
assert.equal(aw.docs('termineClaims').size, 1, 'die andere Ressource ist trotzdem geraeumt')
|
|
assert.equal(await raeumeVerwaisteZellen(JETZT), 1)
|
|
})
|
|
|
|
// ---------------------------------------------------------------- Buchungsspeicher
|
|
|
|
test('legeAn, hole, aendere: Anlegen mit fester ID, Lesen (fehlt => null), Aendern setzt updatedAt', async () => {
|
|
const id = randomUUID()
|
|
const vorLegeAn = Date.now()
|
|
const angelegt = await B.legeAn({ featureId: F, status: 'gehalten', gastEmail: 'max@gmail.com' }, id)
|
|
assert.equal(angelegt.$id, id)
|
|
assert.equal(angelegt.status, 'gehalten')
|
|
const geladen = await B.hole(id)
|
|
assert.equal(geladen.gastEmail, 'max@gmail.com')
|
|
for (const feld of ['createdAt', 'updatedAt']) {
|
|
const ms = new Date(geladen[feld]).getTime()
|
|
assert.ok(ms >= vorLegeAn - 1000 && ms <= Date.now() + 1000, `${feld}: ${geladen[feld]}`)
|
|
}
|
|
assert.equal(await B.hole('b_gibt_es_nicht'), null)
|
|
|
|
const vorAendere = Date.now()
|
|
aw.seed('termineBookings', { ...geladen, updatedAt: '2020-01-01T00:00:00.000Z' })
|
|
const geaendert = await B.aendere(id, { status: 'bestaetigt', bestaetigtAt: iso(JETZT) })
|
|
assert.equal(geaendert.status, 'bestaetigt')
|
|
assert.equal(geaendert.gastEmail, 'max@gmail.com', 'andere Felder bleiben')
|
|
assert.ok(new Date(geaendert.updatedAt).getTime() >= vorAendere - 1000)
|
|
assert.equal((await B.hole(id)).status, 'bestaetigt')
|
|
// ein ganzes gelesenes Dokument darf zurueckgegeben werden: $-Felder werden nicht mitgeschickt
|
|
const wieder = await B.aendere(id, { ...(await B.hole(id)), status: 'abgesagt' })
|
|
assert.equal(wieder.status, 'abgesagt')
|
|
await assert.rejects(B.aendere('b_gibt_es_nicht', { status: 'abgesagt' }), (err) => err.status === 404)
|
|
})
|
|
|
|
test('legeAn: eigene Zeitstempel bleiben, die ID darf auch als $id im Dokument stehen', async () => {
|
|
const angelegt = await B.legeAn(buchung('b_mit_id', { createdAt: '2026-10-05T08:00:00.000Z', updatedAt: '2026-10-05T08:00:00.000Z' }))
|
|
assert.equal(angelegt.$id, 'b_mit_id')
|
|
const geladen = await B.hole('b_mit_id')
|
|
assert.equal(new Date(geladen.createdAt).getTime(), JETZT.getTime())
|
|
assert.equal(new Date(geladen.updatedAt).getTime(), JETZT.getTime())
|
|
})
|
|
|
|
test('legeAn vergibt ohne ID eine neue, nimmt keine doppelte ID an und schreibt keine unbekannten Felder', async () => {
|
|
const a = await B.legeAn({ featureId: F, status: 'gehalten' })
|
|
assert.match(a.$id, /^[0-9a-f-]{36}$/)
|
|
await assert.rejects(B.legeAn({ featureId: F }, a.$id), (err) => err.status === 409)
|
|
await assert.rejects(B.legeAn({ featureId: F, bogus: 1 }), (err) => err.status === 400)
|
|
})
|
|
|
|
test('imZeitraum findet die Belegung vom Vortag und auch mehr als 25 Treffer', async () => {
|
|
// 22:00 bis 06:00 Berlin (Sommerzeit): 20:00Z bis 04:00Z des Folgetags
|
|
seedBuchung('b_nacht', { startsAt: '2026-10-11T20:00:00Z', endsAt: '2026-10-12T04:00:00Z', blockEndsAt: '2026-10-12T04:00:00Z' })
|
|
seedBuchung('b_vorher', { startsAt: '2026-10-11T08:00:00Z', endsAt: '2026-10-11T09:00:00Z', blockEndsAt: '2026-10-11T09:00:00Z' })
|
|
seedBuchung('b_endet_genau', { startsAt: '2026-10-11T21:00:00Z', endsAt: '2026-10-11T22:00:00Z', blockEndsAt: '2026-10-11T22:00:00Z' })
|
|
seedBuchung('b_beginnt_genau', { startsAt: '2026-10-12T22:00:00Z', endsAt: '2026-10-12T23:00:00Z', blockEndsAt: '2026-10-12T23:00:00Z' })
|
|
seedBuchung('b_nachher', { startsAt: '2026-10-13T08:00:00Z', endsAt: '2026-10-13T09:00:00Z', blockEndsAt: '2026-10-13T09:00:00Z' })
|
|
seedBuchung('b_fremd', { featureId: 'f_feature02', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' })
|
|
seedBuchung('b_abgesagt', { status: 'abgesagt', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' })
|
|
const von = new Date('2026-10-11T22:00:00Z') // Beginn des 12.10. in Berlin
|
|
const bis = new Date('2026-10-12T22:00:00Z') // Ende des 12.10. in Berlin
|
|
const treffer = await B.imZeitraum(F, von, bis)
|
|
assert.deepEqual(treffer.map((doc) => doc.$id).sort(), ['b_abgesagt', 'b_nacht'])
|
|
|
|
for (let i = 0; i < 30; i += 1) {
|
|
seedBuchung(`b_tag${String(i).padStart(2, '0')}`, { startsAt: iso(new Date(T('06:00').getTime() + i * 5 * MIN)), blockEndsAt: iso(new Date(T('06:00').getTime() + (i + 1) * 5 * MIN)) })
|
|
}
|
|
const viele = await B.imZeitraum(F, von, bis)
|
|
assert.equal(viele.length, 32)
|
|
// Text- und Date-Grenzen sind gleichwertig
|
|
assert.equal((await B.imZeitraum(F, von.toISOString(), bis.toISOString())).length, 32)
|
|
})
|
|
|
|
test('perHash findet ueber verifyHash oder manageHash, nie ueber leere oder fremde Werte', async () => {
|
|
const t = neuesToken()
|
|
const m = hashToken(verwaltungsToken('b_eins'))
|
|
seedBuchung('b_eins', { verifyHash: t.hash, manageHash: m })
|
|
seedBuchung('b_leer', { verifyHash: '', manageHash: '' })
|
|
assert.equal((await B.perHash('verifyHash', t.hash)).$id, 'b_eins')
|
|
assert.equal((await B.perHash('manageHash', m)).$id, 'b_eins')
|
|
assert.equal(await B.perHash('verifyHash', m), null)
|
|
assert.equal(await B.perHash('verifyHash', hashToken('anderes')), null)
|
|
assert.equal(await B.perHash('verifyHash', ''), null)
|
|
assert.equal(await B.perHash('verifyHash', undefined), null)
|
|
assert.equal(await B.perHash('verifyHash', 'abc'), null)
|
|
await assert.rejects(B.perHash('gastEmail', t.hash), TypeError)
|
|
})
|
|
|
|
test('offeneFuerEmail: gueltige Haltungen, Anfragen und kuenftige bestaetigte Termine dieser Adresse', async () => {
|
|
const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const termin = (ms) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', ...termin(2 * TAG) })
|
|
seedBuchung('b_anfr', { status: 'angefragt', ...termin(3 * TAG) })
|
|
seedBuchung('b_halt', { status: 'gehalten', heldUntil: spaeter(10 * MIN), ...termin(4 * TAG) })
|
|
seedBuchung('b_halt_alt', { status: 'gehalten', heldUntil: spaeter(-MIN), ...termin(4 * TAG) })
|
|
seedBuchung('b_abg', { status: 'abgesagt', ...termin(2 * TAG) })
|
|
seedBuchung('b_verg', { status: 'bestaetigt', ...termin(-2 * TAG) })
|
|
seedBuchung('b_laeuft', { status: 'bestaetigt', ...termin(-10 * MIN) })
|
|
seedBuchung('b_fremd_mail', { status: 'bestaetigt', gastEmail: 'anna@gmail.com', ...termin(2 * TAG) })
|
|
seedBuchung('b_fremd_f', { status: 'bestaetigt', featureId: 'f_feature02', ...termin(2 * TAG) })
|
|
const offen = await B.offeneFuerEmail(F, 'max@gmail.com', JETZT)
|
|
assert.deepEqual(offen.map((doc) => doc.$id).sort(), ['b_anfr', 'b_best', 'b_halt', 'b_laeuft'])
|
|
// dieselbe Adresse in anderer Schreibweise
|
|
assert.equal((await B.offeneFuerEmail(F, ' Max@GMail.com ', JETZT)).length, 4)
|
|
assert.deepEqual(await B.offeneFuerEmail(F, 'niemand@gmail.com', JETZT), [])
|
|
})
|
|
|
|
test('perEmailUndNummer: Adresse und Buchungsnummer muessen zusammen passen', async () => {
|
|
seedBuchung('b_eins')
|
|
seedBuchung('b_zwei')
|
|
seedBuchung('b_drei', { gastEmail: 'anna@gmail.com' })
|
|
seedBuchung('b_vier', { featureId: 'f_feature02' })
|
|
const nummer = buchungsNummer('b_zwei')
|
|
assert.equal((await B.perEmailUndNummer(F, 'max@gmail.com', nummer)).$id, 'b_zwei')
|
|
assert.equal((await B.perEmailUndNummer(F, ' MAX@gmail.com ', nummer.toLowerCase())).$id, 'b_zwei')
|
|
assert.equal(await B.perEmailUndNummer(F, 'anna@gmail.com', nummer), null)
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_drei')), null)
|
|
assert.equal(await B.perEmailUndNummer('f_feature02', 'max@gmail.com', buchungsNummer('b_vier')).then((d) => d.$id), 'b_vier')
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_vier')), null)
|
|
assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', ''), null)
|
|
assert.equal(await B.perEmailUndNummer(F, '', nummer), null)
|
|
})
|
|
|
|
test('zaehleKuenftige: belegende Termine je Leistung und Ressource, ohne Sperren und Vergangenes', async () => {
|
|
const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const termin = (ms, over = {}) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN), ...over })
|
|
seedBuchung('b_1', termin(TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa1' }))
|
|
seedBuchung('b_2', termin(2 * TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa2', status: 'angefragt' }))
|
|
seedBuchung('b_3', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(MIN) }))
|
|
seedBuchung('b_4', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(-MIN) }))
|
|
seedBuchung('b_5', termin(TAG, { status: 'abgesagt' }))
|
|
seedBuchung('b_6', termin(-TAG))
|
|
seedBuchung('b_7', termin(TAG, { quelle: 'sperre', leistungId: '', ressourceId: 'r_aaaaaaa1' }))
|
|
seedBuchung('b_8', termin(TAG, { featureId: 'f_feature02' }))
|
|
const zahlen = await B.zaehleKuenftige(F, JETZT)
|
|
assert.deepEqual(zahlen, {
|
|
leistungen: { s_aaaaaaa1: 2, s_aaaaaaa2: 1 },
|
|
ressourcen: { r_aaaaaaa1: 2, r_aaaaaaa2: 1 },
|
|
})
|
|
assert.deepEqual(await B.zaehleKuenftige('f_leer', JETZT), { leistungen: {}, ressourcen: {} })
|
|
})
|
|
|
|
test('abgelaufeneHaltungen: gehalten mit heldUntil <= jetzt, ueber alle Funktionen', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
seedBuchung('b_alt', { status: 'gehalten', heldUntil: mit(-HALB) })
|
|
seedBuchung('b_genau', { status: 'gehalten', heldUntil: mit(0), featureId: 'f_feature02' })
|
|
seedBuchung('b_noch', { status: 'gehalten', heldUntil: mit(1) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-HALB) })
|
|
seedBuchung('b_verf', { status: 'verfallen', heldUntil: mit(-HALB) })
|
|
const liste = await B.abgelaufeneHaltungen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_genau'])
|
|
})
|
|
|
|
test('erinnerungsKandidaten: bestaetigt, Erinnerung gewuenscht, noch nicht gesendet, Beginn in (jetzt + 1 h, jetzt + 48 h]', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const k = (id, ms, over = {}) => seedBuchung(id, { status: 'bestaetigt', erinnerung: true, startsAt: mit(ms), endsAt: mit(ms + 45 * MIN), blockEndsAt: mit(ms + 50 * MIN), ...over })
|
|
k('b_ok1', 2 * STUNDE)
|
|
k('b_ok2', 24 * STUNDE, { featureId: 'f_feature02' })
|
|
k('b_ok3', 48 * STUNDE)
|
|
k('b_ok4', STUNDE + 1)
|
|
k('b_zu_nah', STUNDE)
|
|
k('b_zu_fern', 48 * STUNDE + 1)
|
|
k('b_ohne_haken', 5 * STUNDE, { erinnerung: false })
|
|
k('b_gesendet', 5 * STUNDE, { erinnerungGesendetAt: mit(-MIN) })
|
|
k('b_anfrage', 5 * STUNDE, { status: 'angefragt' })
|
|
k('b_abgesagt', 5 * STUNDE, { status: 'abgesagt' })
|
|
const liste = await B.erinnerungsKandidaten(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_ok1', 'b_ok2', 'b_ok3', 'b_ok4'])
|
|
})
|
|
|
|
test('zuErledigen: bestaetigt mit Ende vor jetzt - 1 h', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const k = (id, endeMs, over = {}) => seedBuchung(id, { status: 'bestaetigt', startsAt: mit(endeMs - 45 * MIN), endsAt: mit(endeMs), blockEndsAt: mit(endeMs + 5 * MIN), ...over })
|
|
k('b_alt', -STUNDE - 1, { featureId: 'f_feature02' })
|
|
k('b_alt2', -3 * TAG)
|
|
k('b_genau', -STUNDE)
|
|
k('b_laeuft', 10 * MIN)
|
|
k('b_angefragt', -3 * TAG, { status: 'angefragt' })
|
|
k('b_erledigt', -3 * TAG, { status: 'erledigt' })
|
|
const liste = await B.zuErledigen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2'])
|
|
})
|
|
|
|
test('vergangeneAnfragen: angefragt mit Beginn vor jetzt', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
seedBuchung('b_alt', { status: 'angefragt', startsAt: mit(-MIN) })
|
|
seedBuchung('b_alt2', { status: 'angefragt', startsAt: mit(-5 * TAG), featureId: 'f_feature02' })
|
|
seedBuchung('b_genau', { status: 'angefragt', startsAt: mit(0) })
|
|
seedBuchung('b_spaeter', { status: 'angefragt', startsAt: mit(MIN) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', startsAt: mit(-TAG) })
|
|
const liste = await B.vergangeneAnfragen(JETZT)
|
|
assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2'])
|
|
})
|
|
|
|
test('loeschKandidaten: Bezugszeit je Status (verfallen heldUntil, erledigt Terminende, abgesagt letzte Aenderung)', async () => {
|
|
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
|
|
const grenze = new Date(JETZT.getTime() - 48 * STUNDE)
|
|
seedBuchung('b_verf_alt', { status: 'verfallen', heldUntil: mit(-49 * STUNDE) })
|
|
seedBuchung('b_verf_neu', { status: 'verfallen', heldUntil: mit(-47 * STUNDE) })
|
|
seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-49 * STUNDE) })
|
|
assert.deepEqual((await B.loeschKandidaten('verfallen', grenze)).map((d) => d.$id), ['b_verf_alt'])
|
|
|
|
const dreissig = new Date(JETZT.getTime() - 30 * TAG)
|
|
seedBuchung('b_abg_alt', { status: 'abgesagt', updatedAt: mit(-31 * TAG) })
|
|
seedBuchung('b_abg_neu', { status: 'abgesagt', updatedAt: mit(-29 * TAG) })
|
|
seedBuchung('b_abl_alt', { status: 'abgelehnt', updatedAt: mit(-31 * TAG), featureId: 'f_feature02' })
|
|
assert.deepEqual((await B.loeschKandidaten('abgesagt', dreissig)).map((d) => d.$id), ['b_abg_alt'])
|
|
assert.deepEqual((await B.loeschKandidaten('abgelehnt', dreissig)).map((d) => d.$id), ['b_abl_alt'])
|
|
|
|
const neunzig = new Date(JETZT.getTime() - 90 * TAG)
|
|
seedBuchung('b_erl_alt', { status: 'erledigt', endsAt: mit(-91 * TAG) })
|
|
seedBuchung('b_erl_neu', { status: 'erledigt', endsAt: mit(-89 * TAG) })
|
|
seedBuchung('b_nie_alt', { status: 'nichterschienen', endsAt: mit(-100 * TAG) })
|
|
assert.deepEqual((await B.loeschKandidaten('erledigt', neunzig)).map((d) => d.$id), ['b_erl_alt'])
|
|
assert.deepEqual((await B.loeschKandidaten('nichterschienen', neunzig)).map((d) => d.$id), ['b_nie_alt'])
|
|
|
|
for (let i = 0; i < 30; i += 1) seedBuchung(`b_viele${String(i).padStart(2, '0')}`, { status: 'erledigt', endsAt: mit(-200 * TAG) })
|
|
assert.equal((await B.loeschKandidaten('erledigt', neunzig)).length, 31)
|
|
await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig), TypeError)
|
|
await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError)
|
|
})
|
|
|
|
test('zaehleAnfragen: offene Anfragen einer Funktion (auch ueber 25)', async () => {
|
|
for (let i = 0; i < 27; i += 1) seedBuchung(`b_anf${String(i).padStart(2, '0')}`, { status: 'angefragt' })
|
|
seedBuchung('b_best', { status: 'bestaetigt' })
|
|
seedBuchung('b_fremd', { status: 'angefragt', featureId: 'f_feature02' })
|
|
assert.equal(await B.zaehleAnfragen(F), 27)
|
|
assert.equal(await B.zaehleAnfragen('f_feature02'), 1)
|
|
assert.equal(await B.zaehleAnfragen('f_leer'), 0)
|
|
})
|