Files
Webklar-Kundenbereich/test/termineZellen.test.js
T
ClaudeandClaude Sonnet 5.5 a1732613c9 Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen
loescheAbgeschaltete liest je abgeschalteter Funktion (disabledAt vor 30 Tagen) Seiten zu 100
(neu buchungen.ersteBuchungen: nur $id, limit, nie Gastfelder) und loescht sie ueber arbeiteSeiten
samt Zellen, bis die Seite leer ist. Die Funktion wird nach jedem Lesen einer Seite und alle 50
Buchungen frisch geprueft (nochAbgeschaltet); wird sie wieder eingeschaltet, endet die Loeschung
fuer sie sofort. Deckel config.termine.loeschDeckel je Funktion: am Deckel bleibt die Funktion
disabled mit altem disabledAt, eine Warnung ohne Kennungen steht im Log, der naechste Tag macht
weiter, die uebrigen Funktionen kommen im selben Lauf dran. Die Einstellung (termineConfig) wird
nur nach leerer Seite ohne Fehler und mit letzter Pruefung unter termine-cfg:<id> geloescht.
loescheAbgeschalteteFunktionen liefert { funktionen, buchungen, gedeckelt }; taeglicherJob traegt
gedeckelt.abgeschaltet getrennt vom Loeschzaehler ein (Tagesbericht: Loeschlaeufe am Deckel).

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

1063 lines
55 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('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)
})