From 9ad8515d608575dee00788a79f96ba35e45ae694 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:52:25 +0000 Subject: [PATCH] Terminplaner: Zellen nur unter der Ressourcensperre freigeben, gemeinsame Hilfsfunktionen Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/buchungen.js | 18 ++- server/services/termine/slots.js | 13 +-- server/services/termine/token.js | 15 +-- server/services/termine/util.js | 23 ++++ server/services/termine/zellen.js | 124 ++++++++++++++------ test/termineZellen.test.js | 162 ++++++++++++++++++++++++++- 6 files changed, 284 insertions(+), 71 deletions(-) create mode 100644 server/services/termine/util.js diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js index 1fc2e7a..22f26a5 100644 --- a/server/services/termine/buchungen.js +++ b/server/services/termine/buchungen.js @@ -3,6 +3,7 @@ import { config } from '../../config.js' import { Query, createDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { istBelegend } from './slots.js' import { buchungsNummer } from './token.js' +import { pruefeNow } from './util.js' /** * Buchungsspeicher des Terminplaners: nur Datenzugriff auf termineBookings (Spezifikation 2026-10-02-terminplaner- @@ -33,11 +34,6 @@ const LOESCH_BEZUG = { const iso = (wert) => new Date(wert).toISOString() const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '') -function jetztMs(now, name) { - if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) - return now.getTime() -} - /** Ohne die von Appwrite gelieferten $-Felder ($id, $createdAt, ...), die nie zurueckgeschrieben werden duerfen. */ const nurFelder = (daten) => Object.fromEntries(Object.entries(daten).filter(([key]) => !key.startsWith('$'))) @@ -97,7 +93,7 @@ export async function perHash(feld, hash) { * zu Ende. Die Adresse zaehlt klein und getrimmt. => Buchungen (die Grenze maxOffeneJeEmail prueft der Aufrufer). */ export async function offeneFuerEmail(featureId, email, now) { - const nowMs = jetztMs(now, 'offeneFuerEmail') + const nowMs = pruefeNow(now, 'offeneFuerEmail') const mail = kleinGetrimmt(email) if (!mail) return [] const kandidaten = await listAllDocuments(col(), [ @@ -123,7 +119,7 @@ export async function perEmailUndNummer(featureId, email, nummer) { * normalisiereEinstellung. */ export async function zaehleKuenftige(featureId, now) { - const nowMs = jetztMs(now, 'zaehleKuenftige') + const nowMs = pruefeNow(now, 'zaehleKuenftige') const liste = await listAllDocuments(col(), [ Query.equal('featureId', featureId), Query.equal('status', BELEGEND), @@ -144,7 +140,7 @@ export async function zaehleKuenftige(featureId, now) { /** Haltungen (alle Funktionen), die abgelaufen sind (heldUntil <= jetzt): der Minutenjob macht sie zu verfallen. */ export async function abgelaufeneHaltungen(now) { - jetztMs(now, 'abgelaufeneHaltungen') + pruefeNow(now, 'abgelaufeneHaltungen') return listAllDocuments(col(), [Query.equal('status', 'gehalten'), Query.lessThanEqual('heldUntil', iso(now))]) } @@ -153,7 +149,7 @@ export async function abgelaufeneHaltungen(now) { * (jetzt + 1 h, jetzt + 48 h]. Den Vorlauf der Funktion (stundenVorher) und den Vertragsschluss prueft der Job. */ export async function erinnerungsKandidaten(now) { - const nowMs = jetztMs(now, 'erinnerungsKandidaten') + const nowMs = pruefeNow(now, 'erinnerungsKandidaten') const liste = await listAllDocuments(col(), [ Query.equal('status', 'bestaetigt'), Query.equal('erinnerung', true), @@ -165,13 +161,13 @@ export async function erinnerungsKandidaten(now) { /** Bestaetigte Buchungen (alle Funktionen, auch Sperren), deren Ende mehr als 1 h her ist: der Job macht sie erledigt. */ export async function zuErledigen(now) { - const nowMs = jetztMs(now, 'zuErledigen') + const nowMs = pruefeNow(now, 'zuErledigen') return listAllDocuments(col(), [Query.equal('status', 'bestaetigt'), Query.lessThan('endsAt', iso(nowMs - STUNDE_MS))]) } /** Anfragen (alle Funktionen), deren Beginn vorbei ist: der Job lehnt sie vom System aus ab. */ export async function vergangeneAnfragen(now) { - jetztMs(now, 'vergangeneAnfragen') + pruefeNow(now, 'vergangeneAnfragen') return listAllDocuments(col(), [Query.equal('status', 'angefragt'), Query.lessThan('startsAt', iso(now))]) } diff --git a/server/services/termine/slots.js b/server/services/termine/slots.js index 5073856..23b4d0b 100644 --- a/server/services/termine/slots.js +++ b/server/services/termine/slots.js @@ -1,4 +1,5 @@ import { istFeiertag } from './feiertage.js' +import { pruefeNow, zeitMs } from './util.js' import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js' /** @@ -22,18 +23,6 @@ const NICHT_BELEGT = new Set(['abgesagt', 'abgelehnt', 'verfallen']) const pad = (n) => String(n).padStart(2, '0') const hhmm = (min) => `${pad(Math.floor(min / 60))}:${pad(min % 60)}` -function pruefeNow(now, name) { - if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) -} - -/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00) oder Zahl; sonst NaN. */ -function zeitMs(wert) { - if (wert instanceof Date) return wert.getTime() - if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime() - if (typeof wert === 'number') return wert - return Number.NaN -} - /** 'HH:MM' -> Minuten seit Mitternacht ('24:00' = 1440), sonst null. */ function minuten(wert) { const m = typeof wert === 'string' ? /^(\d{2}):(\d{2})$/.exec(wert) : null diff --git a/server/services/termine/token.js b/server/services/termine/token.js index 86a54a7..aee9688 100644 --- a/server/services/termine/token.js +++ b/server/services/termine/token.js @@ -1,5 +1,6 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' +import { pruefeNow, pruefeText } from './util.js' /** * Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8). @@ -33,16 +34,6 @@ function schluessel(zweck) { return createHmac('sha256', geheimnis).update(zweck).digest() } -function pruefeText(wert, name) { - if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) -} - -function zeitMs(now, name) { - const ms = now instanceof Date ? now.getTime() : now - if (typeof ms !== 'number' || !Number.isFinite(ms)) throw new TypeError(`${name}: now fehlt`) - return ms -} - /** Neues Bestaetigungstoken: token geht in den Link, hash (SHA-256-Hex) in die Buchung. */ export function neuesToken() { const token = randomBytes(32).toString('base64url') @@ -74,7 +65,7 @@ function formMac(siteKey, zeit) { /** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */ export function formToken(siteKey, now = new Date()) { pruefeText(siteKey, 'siteKey') - const zeit = zeitMs(now, 'formToken').toString(36) + const zeit = pruefeNow(now, 'formToken').toString(36) return `${zeit}.${formMac(siteKey, zeit)}` } @@ -90,7 +81,7 @@ export function pruefeFormToken(siteKey, token, now = new Date()) { const gegeben = Buffer.from(mac) if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig' - const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36) + const alter = pruefeNow(now, 'pruefeFormToken') - parseInt(zeit, 36) if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen' if (alter < config.termine.formMinSekunden * 1000) return 'zu_schnell' return 'ok' diff --git a/server/services/termine/util.js b/server/services/termine/util.js new file mode 100644 index 0000000..b1400fb --- /dev/null +++ b/server/services/termine/util.js @@ -0,0 +1,23 @@ +/** + * Kleine Hilfen, die mehrere Terminplaner-Module teilen (Zeitpunkte lesen, Pflichtparameter pruefen). + * Reine Funktionen ohne Appwrite und ohne config. + */ + +/** Zeitpunkt als ms: Date, ISO-String (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */ +export function zeitMs(wert) { + if (wert instanceof Date) return wert.getTime() + if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime() + if (typeof wert === 'number') return wert + return Number.NaN +} + +/** `now` ist immer ein gueltiges Date (feste Uhr per Parameter, nie die Wanduhr), sonst TypeError. => Millisekunden. */ +export function pruefeNow(now, name) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) + return now.getTime() +} + +/** Pflichttext: nichtleerer String, sonst TypeError. */ +export function pruefeText(wert, name) { + if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) +} diff --git a/server/services/termine/zellen.js b/server/services/termine/zellen.js index b59cf72..0d3f592 100644 --- a/server/services/termine/zellen.js +++ b/server/services/termine/zellen.js @@ -3,6 +3,7 @@ import { config } from '../../config.js' import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { istBelegend } from './slots.js' +import { pruefeNow, pruefeText, zeitMs } from './util.js' /** * Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5). @@ -15,7 +16,13 @@ import { istBelegend } from './slots.js' * Ob eine vorhandene Zelle noch gilt, entscheidet allein ihre Buchung: fehlt sie, ist sie nicht belegend (siehe * istBelegend in slots.js) oder ist die Haltung abgelaufen, ist die Zelle frei. Solche Zellen loescht belegeZellen erst, * wenn sie eine Belegung blockieren (danach genau ein zweiter Versuch), der taegliche Job raeumt den Rest - * (verwaisteZellen). + * (raeumeVerwaisteZellen; verwaisteZellen nennt sie nur). + * + * Geloescht wird nie ausserhalb der Ressourcensperre und nie nach einer veralteten Liste: Zellen haben feste IDs, eine + * neue Buchung legt dieselben IDs neu an, sobald die alten als ungueltig gelten. Ein spaeter Loeschauftrag nach einer + * frueher gelesenen Liste wuerde die frischen Zellen der neuen Buchung treffen (Doppelbuchung). Darum lesen + * gibZellenFrei und raeumeVerwaisteZellen unter der Sperre jeder betroffenen Ressource noch einmal und loeschen nur, + * was dort jetzt noch zur freizugebenden Buchung gehoert bzw. noch verwaist ist. * * Reihenfolge fuer Aufrufer: die Buchung (Status gehalten mit heldUntil bzw. der Zielstatus) steht VOR ihren Zellen in * der Datenbank. Eine Zelle, deren Buchung noch fehlt, gilt als frei; eine parallele Anfrage koennte sie in der kurzen @@ -27,6 +34,11 @@ import { istBelegend } from './slots.js' const MAX_ZELLEN = 100 const TAG_MS = 24 * 60 * 60 * 1000 const SPERRE_WARTE_MS = 5000 +/** Aufraeumen darf laenger warten als eine Buchung: nach dem Ablauf liefe es ohne Sperre weiter (siehe withLocks). */ +const AUFRAEUM_WARTE_MS = 15000 +/** Zellen je Sperre beim Raeumen (haelt die Sperre kurz) und Wiederholungen, falls Zellen auf weiteren Ressourcen auftauchen. */ +const RAEUM_BLOCK = 100 +const RAEUM_RUNDEN = 3 /** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */ const ID_BLOCK = 50 const LOESCH_PARALLEL = 10 @@ -35,23 +47,7 @@ const ALLE_ZELLEN_MAX = 100000 const claims = () => config.collections.termineClaims const buchungen = () => config.collections.termineBookings - -/** Zeitpunkt als ms: Date, ISO-Text (auch mit +00:00 wie von Appwrite) oder Zahl; sonst NaN. */ -function zeitMs(wert) { - if (wert instanceof Date) return wert.getTime() - if (typeof wert === 'string' && wert !== '') return new Date(wert).getTime() - if (typeof wert === 'number') return wert - return Number.NaN -} - -function pruefeText(wert, name) { - if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) -} - -function pruefeJetzt(now, name) { - if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError(`${name}: now fehlt`) - return now.getTime() -} +const sperrSchluessel = (featureId, ressourceId) => `termine-res:${featureId}:${ressourceId}` const slotVergeben = () => Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), { @@ -155,7 +151,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b pruefeText(ressourceId, 'ressourceId') pruefeText(bookingId, 'bookingId') if (ausser !== null) pruefeText(ausser, 'ausser') - const nowMs = pruefeJetzt(now, 'belegeZellen') + const nowMs = pruefeNow(now, 'belegeZellen') const zellen = zellenFuer(start, blockEnde) const bis = new Date(zeitMs(blockEnde)).toISOString() const dokumente = zellen.map((zelle) => ({ @@ -168,7 +164,7 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b })) await withLocks( - [`termine-res:${featureId}:${ressourceId}`], + [sperrSchluessel(featureId, ressourceId)], async () => { let neu = dokumente if (ausser !== null) { @@ -187,30 +183,88 @@ export async function belegeZellen({ featureId, ressourceId, bookingId, start, b /** * Loescht die Zellen einer Buchung, ausser denen mit den Zeiten aus behalte (Date oder ISO-Text, z. B. das Ergebnis * von zellenFuer). Mit ressourceId bleiben nur Zellen dieser Ressource stehen (Verschieben auf eine andere Person zur - * selben Zeit). Liest seitenweise (mehr als 25 Zellen) und loescht alle; => Zahl geloeschter Zellen. + * selben Zeit). Liest seitenweise (mehr als 25 Zellen). Geloescht wird unter der Sperre jeder betroffenen Ressource + * nach erneutem Lesen: nur Zellen, die dort jetzt noch zu bookingId gehoeren (siehe Kopfkommentar). => Zahl geloeschter. */ export async function gibZellenFrei(bookingId, { behalte = [], ressourceId = null } = {}) { pruefeText(bookingId, 'bookingId') const behalten = new Set([...behalte].map(zeitMs).filter(Number.isFinite)) - const zellen = await listAllDocuments(claims(), [Query.equal('bookingId', bookingId)]) - const weg = zellen.filter((zelle) => !(behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId))) - return loesche(claims(), weg.map((zelle) => zelle.$id)) + const bleibt = (zelle) => behalten.has(zeitMs(zelle.zelle)) && (ressourceId === null || zelle.ressourceId === ressourceId) + const suche = [Query.equal('bookingId', bookingId)] + let geloescht = 0 + for (let runde = 0; runde < RAEUM_RUNDEN; runde += 1) { + const gelesen = await listAllDocuments(claims(), suche) // nur um die Ressourcen zu kennen, geloescht wird nach neuem Lesen + const schluessel = [...new Set(gelesen.map((zelle) => sperrSchluessel(zelle.featureId, zelle.ressourceId)))].sort() + if (schluessel.length === 0) break + const gesperrt = new Set(schluessel) + const vollstaendig = await withLocks( + schluessel, + async () => { + const aktuell = await listAllDocuments(claims(), suche) + const hier = aktuell.filter((zelle) => gesperrt.has(sperrSchluessel(zelle.featureId, zelle.ressourceId))) + geloescht += await loesche(claims(), hier.filter((zelle) => !bleibt(zelle)).map((zelle) => zelle.$id)) + return hier.length === aktuell.length + }, + { waitMs: AUFRAEUM_WARTE_MS } + ) + if (vollstaendig) break + } + return geloescht } +/** Verwaiste Zellen aus zellen: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. belegt nicht mehr. */ +async function verwaisteAus(zellen, nowMs) { + const grenze = nowMs - TAG_MS + const alt = [] + const pruefen = [] + for (const zelle of zellen) (zeitMs(zelle.bis) < grenze ? alt : pruefen).push(zelle) + return [...alt, ...(await ungueltigeZellen(pruefen, nowMs))] +} + +const alleZellen = () => listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX }) + /** * IDs der Zellen, die der taegliche Job loeschen darf: bis laenger als einen Tag vorbei, oder die Buchung fehlt bzw. - * belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). + * belegt nicht mehr (abgesagt, abgelehnt, verfallen, erledigt, nichterschienen, abgelaufene Haltung). Nur Bericht: + * zum Loeschen NICHT diese Liste benutzen (sie veraltet), sondern raeumeVerwaisteZellen. */ export async function verwaisteZellen(now) { - const nowMs = pruefeJetzt(now, 'verwaisteZellen') - const grenze = nowMs - TAG_MS - const alle = await listAllDocuments(claims(), [], { max: ALLE_ZELLEN_MAX }) - const verwaist = [] - const pruefen = [] - for (const zelle of alle) { - if (zeitMs(zelle.bis) < grenze) verwaist.push(zelle.$id) - else pruefen.push(zelle) + const nowMs = pruefeNow(now, 'verwaisteZellen') + return (await verwaisteAus(await alleZellen(), nowMs)).map((zelle) => zelle.$id) +} + +/** + * Loescht die verwaisten Zellen (Regeln wie verwaisteZellen) selbst: je Ressource in Bloecken zu 100 unter deren + * Sperre, nach erneutem Lesen der Zellen und ihrer Buchungen. Eine Zelle, die inzwischen einer anderen Buchung gehoert + * oder wieder gilt, bleibt. Fehler eines Blocks stoppen die anderen nicht; der erste wird am Ende geworfen. + * => Zahl geloeschter Zellen. + */ +export async function raeumeVerwaisteZellen(now) { + const nowMs = pruefeNow(now, 'raeumeVerwaisteZellen') + const nachSperre = new Map() + for (const zelle of await verwaisteAus(await alleZellen(), nowMs)) { + const schluessel = sperrSchluessel(zelle.featureId, zelle.ressourceId) + nachSperre.set(schluessel, [...(nachSperre.get(schluessel) ?? []), zelle]) } - for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id) - return verwaist + let geloescht = 0 + let fehler = null + for (const [schluessel, liste] of nachSperre) { + for (let i = 0; i < liste.length; i += RAEUM_BLOCK) { + const block = new Map(liste.slice(i, i + RAEUM_BLOCK).map((zelle) => [zelle.$id, zelle.bookingId])) + try { + geloescht += await withLocks( + [schluessel], + async () => { + const aktuell = (await leseNachIds(claims(), [...block.keys()])).filter((zelle) => block.get(zelle.$id) === zelle.bookingId) + return loesche(claims(), (await verwaisteAus(aktuell, nowMs)).map((zelle) => zelle.$id)) + }, + { waitMs: AUFRAEUM_WARTE_MS } + ) + } catch (err) { + fehler ??= err + } + } + } + if (fehler) throw fehler + return geloescht } diff --git a/test/termineZellen.test.js b/test/termineZellen.test.js index 2bc882e..7bb9cf1 100644 --- a/test/termineZellen.test.js +++ b/test/termineZellen.test.js @@ -19,7 +19,7 @@ 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 } = await import('../server/services/termine/zellen.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' ) @@ -73,6 +73,30 @@ async function belege(id, von, bis, over = {}) { 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 @@ -525,6 +549,54 @@ test('gibZellenFrei: ein Fehler bleibt nicht unbemerkt, die uebrigen Zellen werd 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', { @@ -582,6 +654,94 @@ test('verwaisteZellen liest alle Zellen (mehr als eine Seite) und prueft ihre Bu 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 () => {