Files
ClaudeandClaude Sonnet 5.5 978722023f Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel
raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:44:47 +00:00

1200 lines
64 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, 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)
})
// ---------------------------------------------------------------- Zellen-Sweep (raeumeVerwaisteZellen) und Zeitfenster
/** 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,
})
}
/** Ende einer alten Zelle: mehr als einen Tag vor JETZT (n ms weiter zurueck, damit die Sortierung nach bis eindeutig ist). */
const alt = (n = 0) => new Date(JETZT.getTime() - TAG - 1 - n)
const nr = (i, stellen = 3) => String(i).padStart(stellen, '0')
/** Alle Anfragen (Methode und Adresse) aufzeichnen, die fn an Appwrite stellt. */
async function mitAnfragen(fn) {
const echt = globalThis.fetch
const anfragen = []
globalThis.fetch = async (url, optionen) => {
anfragen.push({ methode: optionen?.method ?? 'GET', url: String(url) })
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
return anfragen
}
const anBuchungen = (anfragen) => anfragen.filter((anfrage) => anfrage.url.includes('/collections/termineBookings/'))
/** DELETE-Anfragen an termineClaims scheitern, solange scheitert(id) wahr ist (503); alles andere geht durch. */
async function mitScheiternderZellenLoeschung(scheitert, fn) {
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
const treffer = optionen?.method === 'DELETE' && String(url).match(/\/collections\/termineClaims\/documents\/([^/?]+)$/)
if (treffer && scheitert(treffer[1])) {
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 503, type: 'general_unknown' }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
}
return echt(url, optionen)
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
/** Abfragen des Sweeps (die Seiten ueber bis), nicht die ID-Abfragen unter der Sperre. */
const seitenAbfragen = () => aw.listCalls('termineClaims').filter((aufruf) => aufruf.queries.some((q) => q.method === 'lessThan'))
const limitVon = (aufruf) => aufruf.queries.find((q) => q.method === 'limit')?.values[0]
test('Zellen-Sweep: bis genau jetzt - 1 Tag bleibt, eine Millisekunde aelter geht, die Buchungen werden nie abgefragt', async () => {
const grenze = new Date(JETZT.getTime() - TAG)
seedBuchung('b_ok') // bestaetigt: die Buchung belegt noch
seedZelle('c_genau', 'b_ok', grenze)
seedZelle('c_aelter', 'b_ok', new Date(grenze.getTime() - 1)) // ueber einen Tag vorbei: ungueltig, egal was die Buchung sagt
seedZelle('c_fehlt_genau', 'b_gibt_es_nicht', grenze) // Buchung fehlt, aber das Zeitfenster zaehlt: bleibt
seedZelle('c_fehlt_kuenftig', 'b_gibt_es_nicht', new Date(JETZT.getTime() + 2 * TAG))
let erg
const anfragen = await mitAnfragen(async () => {
erg = await raeumeVerwaisteZellen(JETZT)
})
assert.deepEqual(erg, { geloescht: 1, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_fehlt_genau', 'c_fehlt_kuenftig', 'c_genau'])
assert.deepEqual(anBuchungen(anfragen), [], 'keine einzige Anfrage an termineBookings')
// eine Millisekunde spaeter sind auch die beiden Zellen mit bis == jetzt - 1 Tag ueber einen Tag vorbei
assert.deepEqual(await raeumeVerwaisteZellen(new Date(JETZT.getTime() + 1)), { geloescht: 2, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_fehlt_kuenftig'])
await assert.rejects(raeumeVerwaisteZellen(undefined), TypeError)
})
test('Zellen-Sweep loescht genau die alten Zellen (jeder Funktion und Ressource) und zaehlt sie; kuenftige bleiben, auch ohne gueltige Buchung', 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)) })
// kuenftiges Ende: bleibt, auch wenn die Buchung fehlt oder nichts belegt (siehe die Waisen-Regel im Kopfkommentar)
seedZelle('c_ok', 'b_ok', bald)
seedZelle('c_abgesagt', 'b_abgesagt', bald, { ressourceId: 'r_aaaaaaa2' })
seedZelle('c_fehlt', 'b_gibt_es_nicht', bald, { featureId: 'f_feature02' })
// Ende mehr als einen Tag vorbei: weg, egal was die Buchung sagt
seedZelle('c_alt_ok', 'b_ok', alt(1000))
seedZelle('c_alt_abgesagt', 'b_abgesagt', alt(2000), { ressourceId: 'r_aaaaaaa2' })
seedZelle('c_alt_haltung', 'b_haltung_alt', alt(3000))
seedZelle('c_alt_fehlt', 'b_gibt_es_nicht', alt(4000), { featureId: 'f_feature02' })
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 4, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_abgesagt', 'c_fehlt', 'c_ok'])
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
})
test('Zellen-Sweep: 260 alte Zellen auf zwei Ressourcen in Seiten zu hoechstens 100 ueber bis (Standard-Deckel liegt darueber)', async () => {
assert.equal(config.termine.zellenDeckel, 5000)
for (let i = 0; i < 260; i += 1) {
seedZelle(`c_viele${nr(i)}`, `b_nie${i}`, alt(i * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R })
}
seedBuchung('b_ok')
seedZelle('c_ok', 'b_ok', T('23:00'))
aw.resetListCalls()
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 260, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
const abfragen = aw.listCalls('termineClaims')
assert.ok(abfragen.length >= 4, `drei Seiten, eine leere Seite und die ID-Abfragen: ${abfragen.length}`)
assert.ok(abfragen.every((aufruf) => Number.isInteger(limitVon(aufruf)) && limitVon(aufruf) <= 100), abfragen.map(limitVon).join(','))
const seiten = seitenAbfragen()
assert.equal(seiten.length, 4, '100 + 100 + 60 Zellen, dann eine leere Seite')
const erste = seiten[0].queries
assert.deepEqual(erste.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'bis', values: [iso(new Date(JETZT.getTime() - TAG))] })
assert.equal(erste.find((q) => q.method === 'orderAsc')?.attribute, 'bis')
assert.deepEqual(erste.find((q) => q.method === 'select')?.values, ['$id', 'featureId', 'ressourceId', 'bookingId', 'bis'])
assert.equal(erste.find((q) => q.method === 'cursorAfter'), undefined, 'ohne Cursor, die Liste wird neu gelesen')
assert.deepEqual(seiten.map(limitVon), [100, 100, 100, 100])
})
test('Zellen-Sweep: der Deckel zaehlt Zellen genau, der Rest folgt im naechsten Lauf', async () => {
// c_deckel000 ist die aelteste
for (let i = 0; i < 600; i += 1) seedZelle(`c_deckel${nr(i)}`, `b_nie${i}`, alt((600 - i) * 1000), { ressourceId: i % 3 ? 'r_aaaaaaa2' : R })
const vorher = config.termine.zellenDeckel
config.termine.zellenDeckel = 250
try {
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true })
assert.equal(aw.docs('termineClaims').size, 350)
assert.ok(!aw.docs('termineClaims').has('c_deckel249') && aw.docs('termineClaims').has('c_deckel250'), 'die aeltesten zuerst')
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 250, gedeckelt: true })
assert.equal(aw.docs('termineClaims').size, 100)
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 100, gedeckelt: false })
assert.equal(alleClaims(), 0)
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
} finally {
config.termine.zellenDeckel = vorher
}
})
test('Zellen-Sweep: deckel als Option gilt vor config.termine.zellenDeckel; genau so viele Zellen wie der Deckel sind nicht gedeckelt', async () => {
for (let i = 0; i < 10; i += 1) seedZelle(`c_opt${nr(i)}`, `b_nie${i}`, alt((10 - i) * 1000))
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 4 }), { geloescht: 4, gedeckelt: true })
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: true }, 'Deckel 0: nur nachsehen, ob etwas ansteht')
assert.equal(alleClaims(), 6)
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 6 }), { geloescht: 6, gedeckelt: false }, 'der Deckel reicht genau')
assert.deepEqual(await raeumeVerwaisteZellen(JETZT, { deckel: 0 }), { geloescht: 0, gedeckelt: false })
for (const falsch of [-1, 1.5, '5', null, NaN]) {
await assert.rejects(raeumeVerwaisteZellen(JETZT, { deckel: falsch }), TypeError, String(falsch))
}
})
test('Zellen-Sweep: 3000 alte Zellen einer einzigen Ressource (ein Sperrschluessel) werden alle geloescht', async () => {
for (let i = 0; i < 3000; i += 1) seedZelle(`c_eine${nr(i, 4)}`, `b_nie${i % 500}`, alt((3000 - i) * 1000))
seedBuchung('b_ok')
seedZelle('c_ok', 'b_ok', T('23:00'))
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 3000, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_ok'])
})
test('Zellen-Sweep: ein verzoegertes DELETE trifft nicht die Zellen einer gleichzeitig belegten Zeit derselben Ressource', async () => {
seedBuchung('b_X', { status: 'abgesagt' })
for (let i = 0; i < 10; i += 1) seedZelle(`c_x${nr(i, 2)}`, 'b_X', alt(i * 1000))
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 })
assert.deepEqual(await lauf, { geloescht: 10, gedeckelt: false })
})
assert.equal(claimsOf('b_W').length, 10, 'W behaelt alle Zellen')
assert.equal(claimsOf('b_X').length, 0)
})
test('Zellen-Sweep prueft nach dem Warten auf die Sperre erneut: nur dieselbe Buchung und noch alt wird geloescht', async () => {
const bald = new Date(JETZT.getTime() + 2 * TAG)
seedBuchung('b_W')
seedZelle('c_x1', 'b_X1', alt(1000)) // unveraendert: weg
seedZelle('c_x2', 'b_X2', alt(2000)) // wird einer neuen Buchung mit kuenftigem Ende
seedZelle('c_x3', 'b_X3', alt(3000)) // ist inzwischen schon weg
seedZelle('c_x4', 'b_X1', alt(4000)) // andere Buchung, deren Dokument erst noch folgt
seedZelle('c_x5', 'b_X5', alt(5000)) // gleiche Buchung, aber das Ende liegt jetzt in der Zukunft
seedZelle('c_x6', 'b_X6', alt(6000)) // andere Buchung, das Ende ist noch alt: bleibt bis zum naechsten Lauf
const sperre = haltePerSperre()
aw.resetListCalls()
const lauf = raeumeVerwaisteZellen(JETZT)
await schlafe(100) // der Lauf hat die Seite gelesen und wartet auf die Sperre
const zelle = (id) => aw.docs('termineClaims').get(id)
aw.seed('termineClaims', { ...zelle('c_x2'), bookingId: 'b_W', bis: iso(bald) })
await deleteDocument('termineClaims', 'c_x3')
aw.seed('termineClaims', { ...zelle('c_x4'), bookingId: 'b_neu_ohne_buchung', bis: iso(bald) })
aw.seed('termineClaims', { ...zelle('c_x5'), bis: iso(bald) })
aw.seed('termineClaims', { ...zelle('c_x6'), bookingId: 'b_W' })
sperre.freigeben()
await sperre.fertig
assert.deepEqual(await lauf, { geloescht: 1, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5', 'c_x6'])
assert.equal(zelle('c_x2').bookingId, 'b_W')
assert.equal(zelle('c_x4').bookingId, 'b_neu_ohne_buchung')
assert.equal(seitenAbfragen().length <= 3, true, 'keine Endlosschleife ueber die unveraenderte c_x6')
// im naechsten Lauf gehoert c_x6 der Buchung, die der Sweep liest: weg
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
assert.deepEqual([...aw.docs('termineClaims').keys()].sort(), ['c_x2', 'c_x4', 'c_x5'])
})
test('Zellen-Sweep: eine Waise mit kuenftigem Ende bleibt stehen, blockiert aber keine Neubuchung', async () => {
seedBuchung('b_abgesagt', { status: 'abgesagt' })
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_abgesagt', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
seedZelle('c_fehlt', 'b_gibt_es_nicht', T('23:00'))
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 0, gedeckelt: false })
assert.equal(alleClaims(), 11, 'der Sweep findet Waisen mit kuenftigem Ende nicht mehr')
seedBuchung('b_neu')
await belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('10:50'), now: JETZT })
assert.equal(claimsOf('b_neu').length, 10, 'belegeZellen raeumt den Blockierer')
assert.equal(claimsOf('b_abgesagt').length, 0)
// spaetestens einen Tag nach bis faellt alles in den Sweep
assert.deepEqual(await raeumeVerwaisteZellen(new Date(T('23:00').getTime() + TAG + MIN)), { geloescht: 11, gedeckelt: false })
assert.equal(alleClaims(), 0)
})
test('Zellen-Sweep: ein Fehler beim Loeschen stoppt die uebrigen Ressourcen nicht und wird am Ende geworfen', async () => {
seedZelle('c_a1', 'b_nie1', alt(2000))
seedZelle('c_b1', 'b_nie2', alt(1000), { ressourceId: 'r_aaaaaaa2' })
aw.resetListCalls()
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.ok(seitenAbfragen().length <= 2, `kein Endlosdurchlauf: ${seitenAbfragen().length} Seiten`)
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
})
test('Zellen-Sweep: scheitert jedes DELETE, endet der Lauf nach einer Seite und wirft den ersten Fehler', async () => {
for (let i = 0; i < 150; i += 1) seedZelle(`c_kaputt${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000), { ressourceId: i % 2 ? 'r_aaaaaaa2' : R })
aw.resetListCalls()
await mitScheiternderZellenLoeschung(
() => true,
() => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
)
assert.equal(alleClaims(), 150)
assert.equal(seitenAbfragen().length, 1, 'eine Seite ohne Erfolg beendet den Lauf')
})
test('Zellen-Sweep: eine Zelle, die sich nicht loeschen laesst, haelt den Rest nicht auf; was geloescht ist, zaehlt fuer den naechsten Lauf', async () => {
// c_stur000 ist die aelteste und steht in jeder Liste vorn
for (let i = 0; i < 150; i += 1) seedZelle(`c_stur${nr(i)}`, `b_nie${i}`, alt((150 - i) * 1000))
aw.resetListCalls()
await mitScheiternderZellenLoeschung(
(id) => id === 'c_stur000',
() => assert.rejects(raeumeVerwaisteZellen(JETZT), (err) => err.status === 500)
)
assert.deepEqual([...aw.docs('termineClaims').keys()], ['c_stur000'])
assert.ok(seitenAbfragen().length <= 4, `keine Endlosschleife: ${seitenAbfragen().length} Seiten`)
assert.deepEqual(await raeumeVerwaisteZellen(JETZT), { geloescht: 1, gedeckelt: false })
})
test('Blockierer mit Ende vor mehr als einem Tag sind ohne Buchungsabfrage ungueltig (dieselbe Regel wie im Sweep)', async () => {
await belege('b_alt', T('10:00'), T('11:00')) // bestaetigt: belegt
seedBuchung('b_neu')
const spaet = new Date(T('11:00').getTime() + TAG + MIN)
const anfragen = await mitAnfragen(() =>
belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu', start: T('10:00'), blockEnde: T('11:00'), now: spaet })
)
assert.equal(claimsOf('b_neu').length, 12)
assert.equal(claimsOf('b_alt').length, 0)
assert.deepEqual(anBuchungen(anfragen), [], 'das Zeitfenster entscheidet, die Buchung wird nicht gelesen')
// genau einen Tag nach bis ist noch nichts ueberfaellig: die Buchung entscheidet und belegt
await belege('b_alt2', T('12:00'), T('13:00'))
seedBuchung('b_neu2')
const knapp = new Date(T('13:00').getTime() + TAG)
const anfragen2 = await mitAnfragen(() =>
assert.rejects(belegeZellen({ featureId: F, ressourceId: R, bookingId: 'b_neu2', start: T('12:00'), blockEnde: T('13:00'), now: knapp }), vergeben)
)
assert.ok(anBuchungen(anfragen2).length > 0)
assert.equal(claimsOf('b_alt2').length, 12)
})
// ---------------------------------------------------------------- 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('loeschKandidaten: hoechstens limit (Standard 100), aelteste Bezugszeit zuerst, nach Funktion filterbar, ohne Gastfelder', async () => {
const mit = (ms) => iso(new Date(JETZT.getTime() + ms))
const neunzig = new Date(JETZT.getTime() - 90 * TAG)
const nr = (i) => String(i).padStart(3, '0')
// 150 alte erledigt; je groesser i, desto spaeter das Terminende; in umgekehrter Reihenfolge angelegt, jede dritte in der Funktion 2
for (let i = 149; i >= 0; i -= 1) {
seedBuchung(`b_e${nr(i)}`, { status: 'erledigt', endsAt: mit(-91 * TAG + i * MIN), featureId: i % 3 === 0 ? 'f_feature02' : F })
}
seedBuchung('b_jung', { status: 'erledigt', endsAt: mit(-89 * TAG) })
const ids = (liste) => liste.map((doc) => doc.$id)
const alle = Array.from({ length: 150 }, (_, i) => `b_e${nr(i)}`)
aw.resetListCalls()
const standard = await B.loeschKandidaten('erledigt', neunzig)
assert.deepEqual(ids(standard), alle.slice(0, 100), 'Standard 100, aelteste zuerst')
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 10 })), alle.slice(0, 10))
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 1 })), [alle[0]])
const zwei = await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02' })
assert.deepEqual(ids(zwei), alle.filter((_, i) => i % 3 === 0), 'nur die Funktion 2, aelteste zuerst')
assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02', limit: 5 })), alle.filter((_, i) => i % 3 === 0).slice(0, 5))
assert.deepEqual(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_gibt_es_nicht' }), [])
// keine Gastfelder: weder Name noch Adresse, nur Kennung, Funktion, Status und die drei Bezugszeiten
const erlaubt = new Set(['featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil'])
for (const doc of standard) {
assert.ok(doc.$id)
assert.equal(doc.status, 'erledigt')
assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$') && !erlaubt.has(key)), [], `${doc.$id}: nur erlaubte Felder`)
assert.equal('gastName' in doc, false)
assert.equal('gastEmail' in doc, false)
}
// die Abfrage sagt es selbst: select ohne Gastfelder, limit, Reihenfolge nach der Bezugszeit
const abfrage = aw.listCalls('termineBookings')[0].queries
assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil'])
assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100])
assert.deepEqual(abfrage.find((q) => q.method === 'orderAsc'), { method: 'orderAsc', attribute: 'endsAt' })
assert.deepEqual(abfrage.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'endsAt', values: [iso(neunzig)] })
// Reihenfolge nach der Bezugszeit des Status: verfallen heldUntil, abgesagt updatedAt (Einfuegereihenfolge ist egal)
seedBuchung('b_v2', { status: 'verfallen', heldUntil: mit(-60 * STUNDE) })
seedBuchung('b_v1', { status: 'verfallen', heldUntil: mit(-70 * STUNDE) })
seedBuchung('b_v3', { status: 'verfallen', heldUntil: mit(-50 * STUNDE) })
assert.deepEqual(ids(await B.loeschKandidaten('verfallen', new Date(JETZT.getTime() - 48 * STUNDE))), ['b_v1', 'b_v2', 'b_v3'])
seedBuchung('b_a2', { status: 'abgesagt', updatedAt: mit(-40 * TAG) })
seedBuchung('b_a1', { status: 'abgesagt', updatedAt: mit(-50 * TAG) })
assert.deepEqual(ids(await B.loeschKandidaten('abgesagt', new Date(JETZT.getTime() - 30 * TAG))), ['b_a1', 'b_a2'])
// wie bisher: nicht loeschbare Status und ungueltige limit
await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig, { limit: 10 }), TypeError)
await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError)
for (const limit of [0, -1, 1.5, NaN, '10']) await assert.rejects(B.loeschKandidaten('erledigt', neunzig, { limit }), TypeError, `limit ${limit}`)
})
test('ersteBuchungen: hoechstens limit (Standard 100) Buchungen einer Funktion, jeder Status, nur die Kennung (nie Gastfelder)', async () => {
const nr = (i) => String(i).padStart(3, '0')
for (let i = 0; i < 130; i += 1) seedBuchung(`b_a${nr(i)}`, { status: ['bestaetigt', 'erledigt', 'abgesagt', 'gehalten'][i % 4] })
for (let i = 0; i < 7; i += 1) seedBuchung(`b_fremd${i}`, { featureId: 'f_feature02' })
const alleA = Array.from({ length: 130 }, (_, i) => `b_a${nr(i)}`)
aw.resetListCalls()
const standard = await B.ersteBuchungen(F)
assert.equal(standard.length, 100, 'Standard 100')
assert.ok(standard.every((doc) => alleA.includes(doc.$id)), 'nur Buchungen dieser Funktion')
assert.equal(new Set(standard.map((doc) => doc.$id)).size, 100)
assert.equal((await B.ersteBuchungen(F, 10)).length, 10)
assert.equal((await B.ersteBuchungen(F, 1)).length, 1)
assert.deepEqual((await B.ersteBuchungen('f_feature02')).map((doc) => doc.$id).sort(), Array.from({ length: 7 }, (_, i) => `b_fremd${i}`))
assert.deepEqual(await B.ersteBuchungen('f_gibt_es_nicht'), [])
// nur die Kennung: keine Gastfelder, und die Abfrage sagt es selbst (select, limit, Funktionsfilter)
for (const doc of standard) {
assert.ok(doc.$id)
assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$')), [], `${doc.$id}: keine Felder ausser den $-Feldern`)
}
const aufrufe = aw.listCalls('termineBookings')
const abfrage = aufrufe[0].queries
assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id'])
assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100])
assert.deepEqual(abfrage.find((q) => q.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [F] })
for (const { queries } of aufrufe) assert.ok(queries.find((q) => q.method === 'limit').values[0] <= 100)
// wer loescht, liest neu: Geloeschtes fehlt in der naechsten Seite
for (const doc of standard) await B.loesche(doc.$id)
const rest = await B.ersteBuchungen(F)
assert.deepEqual(rest.map((doc) => doc.$id).sort(), alleA.filter((id) => !standard.some((doc) => doc.$id === id)))
assert.equal(rest.length, 30)
// ungueltige Eingaben: nie eine Abfrage ohne Funktionsfilter oder ohne Grenze
aw.resetListCalls()
for (const featureId of [undefined, null, '', 5]) await assert.rejects(B.ersteBuchungen(featureId), TypeError, `featureId ${featureId}`)
for (const limit of [0, -1, 1.5, NaN, '10', null]) await assert.rejects(B.ersteBuchungen(F, limit), TypeError, `limit ${limit}`)
assert.equal(aw.listCalls('termineBookings').length, 0, 'bei ungueltigen Eingaben geht keine Abfrage raus')
})
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)
})