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) })