From e281db1b2b46ef40a80ea5a6ba6825fc812a66e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:39:37 +0000 Subject: [PATCH] Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/buchungen.js | 192 +++++++ server/services/termine/slots.js | 3 +- server/services/termine/token.js | 97 ++++ server/services/termine/zellen.js | 216 +++++++ test/termineZellen.test.js | 808 +++++++++++++++++++++++++++ 5 files changed, 1315 insertions(+), 1 deletion(-) create mode 100644 server/services/termine/buchungen.js create mode 100644 server/services/termine/token.js create mode 100644 server/services/termine/zellen.js create mode 100644 test/termineZellen.test.js diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js new file mode 100644 index 0000000..1fc2e7a --- /dev/null +++ b/server/services/termine/buchungen.js @@ -0,0 +1,192 @@ +import { randomUUID } from 'node:crypto' +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' + +/** + * Buchungsspeicher des Terminplaners: nur Datenzugriff auf termineBookings (Spezifikation 2026-10-02-terminplaner- + * design.md, Abschnitte 2 und 4), keine Geschaeftsregeln. Die Listen kommen seitenweise vollstaendig (listAllDocuments, + * Appwrite liefert ohne limit nur 25), `now` ist immer ein Parameter. Datumswerte aus Appwrite sind "+00:00"-Texte + * und werden vom Aufrufer mit new Date() gelesen. Abfragen ueber mehrere Funktionen (Jobs) haben kein featureId. + */ + +const col = () => config.collections.termineBookings +/** Status, die eine Buchung belegen koennen (gehalten nur, solange heldUntil > now: istBelegend prueft es). */ +const BELEGEND = ['gehalten', 'angefragt', 'bestaetigt'] +/** Hoechste Erinnerungsvorlaufzeit (einstellungen.js: 2, 12, 24 oder 48 Stunden) und der Mindestabstand zum Termin. */ +const ERINNERUNG_MAX_MS = 48 * 60 * 60 * 1000 +const ERINNERUNG_MIN_MS = 60 * 60 * 1000 +const STUNDE_MS = 60 * 60 * 1000 +const HASH_FELDER = new Set(['verifyHash', 'manageHash']) +const HASH_RE = /^[0-9a-f]{64}$/ +const NUMMER_RE = /^[A-Z0-9]{8}$/ +/** Bezugszeit der Loeschfristen je Status (siehe loeschKandidaten). */ +const LOESCH_BEZUG = { + verfallen: 'heldUntil', + abgesagt: 'updatedAt', + abgelehnt: 'updatedAt', + erledigt: 'endsAt', + nichterschienen: 'endsAt', +} + +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('$'))) + +/** + * Legt eine Buchung an. Die ID kommt vom Aufrufer (id oder daten.$id), sonst randomUUID(); der Aufrufer braucht sie + * vorher, weil Verwaltungstoken und Buchungsnummer aus ihr entstehen. createdAt/updatedAt: jetzt, wenn nicht gegeben. + * => das angelegte Dokument. + */ +export async function legeAn(daten, id) { + const felder = nurFelder(daten) + const jetzt = new Date().toISOString() + return createDocument( + col(), + { ...felder, createdAt: felder.createdAt ?? jetzt, updatedAt: felder.updatedAt ?? jetzt }, + id ?? daten.$id ?? randomUUID() + ) +} + +/** Eine Buchung per ID; null, wenn es sie nicht gibt. */ +export async function hole(id) { + try { + return await getDocument(col(), id) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +/** Aendert Felder einer Buchung und setzt updatedAt (jetzt, wenn nicht gegeben). => das geaenderte Dokument. */ +export async function aendere(id, felder) { + const aenderung = nurFelder(felder) + return updateDocument(col(), id, { ...aenderung, updatedAt: aenderung.updatedAt ?? new Date().toISOString() }) +} + +/** + * Alle Buchungen (jeder Status) einer Funktion, die den Zeitraum [von, bis] beruehren: Beginn vor bis und Blockende + * nach von (strikt). Findet damit auch eine Belegung vom Vortag, die in den Tag hineinreicht. + */ +export async function imZeitraum(featureId, von, bis) { + return listAllDocuments(col(), [ + Query.equal('featureId', featureId), + Query.lessThan('startsAt', iso(bis)), + Query.greaterThan('blockEndsAt', iso(von)), + ]) +} + +/** Buchung zu einem Token-Hash (feld 'verifyHash' oder 'manageHash'); null bei leerem, kaputtem oder unbekanntem Hash. */ +export async function perHash(feld, hash) { + if (!HASH_FELDER.has(feld)) throw new TypeError(`perHash: Feld ${feld} nicht erlaubt`) + if (typeof hash !== 'string' || !HASH_RE.test(hash)) return null + const [buchung] = await listDocuments(col(), [Query.equal(feld, hash), Query.limit(1)]) + return buchung ?? null +} + +/** + * Offene Termine einer Adresse in dieser Funktion: belegend (gueltige Haltung, Anfrage, bestaetigt) und noch nicht + * 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 mail = kleinGetrimmt(email) + if (!mail) return [] + const kandidaten = await listAllDocuments(col(), [ + Query.equal('featureId', featureId), + Query.equal('gastEmail', mail), + Query.greaterThan('endsAt', iso(now)), + ]) + return kandidaten.filter((buchung) => istBelegend(buchung, nowMs)) +} + +/** Buchung zu Adresse und Buchungsnummer (Widerruf ohne Link); null, wenn beides zusammen nicht passt. */ +export async function perEmailUndNummer(featureId, email, nummer) { + const mail = kleinGetrimmt(email) + const gesucht = typeof nummer === 'string' ? nummer.trim().toUpperCase() : '' + if (!mail || !NUMMER_RE.test(gesucht)) return null + const kandidaten = await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('gastEmail', mail)]) + return kandidaten.find((buchung) => buchungsNummer(buchung.$id) === gesucht) ?? null +} + +/** + * Kuenftige belegende Termine (Ende nach jetzt) je Leistung und je Ressource, ohne Sperren (kein Gast, kein Termin): + * { leistungen: { [id]: Anzahl }, ressourcen: { [id]: Anzahl } }, die Form von kuenftigeTermine in + * normalisiereEinstellung. + */ +export async function zaehleKuenftige(featureId, now) { + const nowMs = jetztMs(now, 'zaehleKuenftige') + const liste = await listAllDocuments(col(), [ + Query.equal('featureId', featureId), + Query.equal('status', BELEGEND), + Query.greaterThan('endsAt', iso(now)), + ]) + const leistungen = new Map() + const ressourcen = new Map() + const zaehle = (karte, id) => { + if (typeof id === 'string' && id !== '') karte.set(id, (karte.get(id) ?? 0) + 1) + } + for (const buchung of liste) { + if (buchung.quelle === 'sperre' || !istBelegend(buchung, nowMs)) continue + zaehle(leistungen, buchung.leistungId) + zaehle(ressourcen, buchung.ressourceId) + } + return { leistungen: Object.fromEntries(leistungen), ressourcen: Object.fromEntries(ressourcen) } +} + +/** Haltungen (alle Funktionen), die abgelaufen sind (heldUntil <= jetzt): der Minutenjob macht sie zu verfallen. */ +export async function abgelaufeneHaltungen(now) { + jetztMs(now, 'abgelaufeneHaltungen') + return listAllDocuments(col(), [Query.equal('status', 'gehalten'), Query.lessThanEqual('heldUntil', iso(now))]) +} + +/** + * Kandidaten fuer die Erinnerung (alle Funktionen): bestaetigt, Erinnerung gewuenscht, noch keine gesendet, Beginn in + * (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 liste = await listAllDocuments(col(), [ + Query.equal('status', 'bestaetigt'), + Query.equal('erinnerung', true), + Query.greaterThan('startsAt', iso(nowMs + ERINNERUNG_MIN_MS)), + Query.lessThanEqual('startsAt', iso(nowMs + ERINNERUNG_MAX_MS)), + ]) + return liste.filter((buchung) => !buchung.erinnerungGesendetAt) +} + +/** 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') + 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') + return listAllDocuments(col(), [Query.equal('status', 'angefragt'), Query.lessThan('startsAt', iso(now))]) +} + +/** + * Buchungen eines Status (alle Funktionen), deren Bezugszeit vor `vor` liegt. Bezugszeit je Status: verfallen = + * heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte + * Aenderung, also der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Die Frist je + * Funktion (aufbewahrungTage) wendet der Aufrufer auf das Ergebnis an. Andere Status: TypeError. + */ +export async function loeschKandidaten(status, vor) { + if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`) + return listAllDocuments(col(), [Query.equal('status', status), Query.lessThan(LOESCH_BEZUG[status], iso(vor))]) +} + +/** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */ +export async function zaehleAnfragen(featureId) { + return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length +} diff --git a/server/services/termine/slots.js b/server/services/termine/slots.js index 24c7b2a..5073856 100644 --- a/server/services/termine/slots.js +++ b/server/services/termine/slots.js @@ -59,7 +59,8 @@ function feiertag(tag, land) { // ---------------------------------------------------------------- Belegung -function istBelegend(buchung, jetztMs) { +/** Belegt die Buchung zum Zeitpunkt jetztMs? gehalten nur solange heldUntil > jetzt, angefragt und bestaetigt immer. */ +export function istBelegend(buchung, jetztMs) { if (buchung.status === 'angefragt' || buchung.status === 'bestaetigt') return true if (buchung.status === 'gehalten') return zeitMs(buchung.heldUntil) > jetztMs // abgelaufen oder ohne Haltezeit = frei return false diff --git a/server/services/termine/token.js b/server/services/termine/token.js new file mode 100644 index 0000000..86a54a7 --- /dev/null +++ b/server/services/termine/token.js @@ -0,0 +1,97 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' + +/** + * Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8). + * + * - Bestaetigungstoken: 32 Zufallsbytes (base64url), gespeichert wird nur der SHA-256-Hex (verifyHash). + * - Verwaltungstoken: base64url(HMAC-SHA256(Schluessel, bookingId)); jederzeit neu berechenbar, damit spaetere Mails + * (Erinnerung) denselben Link tragen, gespeichert wird nur sein Hash (manageHash). Wie lange er gilt (Terminende + * + 1 Tag), entscheidet der Aufrufer anhand der Buchung. + * - Buchungsnummer: 8 Zeichen A-Z0-9, nur aus der Buchungs-ID berechnet (kein eigenes Feld); steht in der Bestaetigung + * und gehoert beim Widerruf ohne Link zusammen mit der E-Mail-Adresse. + * - Formular-Token: "." je siteKey, beweist nur Alter und Herkunft (kein Geheimnis fuer + * den Besucher). Zu schnell abgeschickt (config.termine.formMinSekunden) = Bot, aelter als 2 Stunden = neu laden. + * + * Die HMAC-Schluessel werden je Zweck aus config.sessionSecret abgeleitet (Beschriftung 'termine-manage' bzw. + * 'termine-form'), damit ein Token nie fuer einen anderen Zweck gilt. config wird bei jedem Aufruf gelesen. + */ + +const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000 +const FORM_TOKEN_MAX_LAENGE = 100 +const MAC_RE = /^[A-Za-z0-9_-]{43}$/ +const ZEIT_RE = /^[0-9a-z]{1,13}$/ + +const sha256Hex = (text) => createHash('sha256').update(text).digest('hex') + +/** Schluessel fuer genau einen Zweck; ohne (ausreichend langes) Sitzungsgeheimnis gibt es keine Tokens. */ +function schluessel(zweck) { + const geheimnis = config.sessionSecret + if (typeof geheimnis !== 'string' || geheimnis.length < 32) { + throw new Error('Terminplaner-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).') + } + 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') + return { token, hash: hashToken(token) } +} + +/** SHA-256-Hex eines Tokens. Fremde Eingaben (kein Text) ergeben den Hash des leeren Textes und treffen nichts. */ +export function hashToken(token) { + return sha256Hex(typeof token === 'string' ? token : '') +} + +/** Verwaltungstoken einer Buchung (stabil; bei geaendertem Sitzungsgeheimnis ein anderer Wert). */ +export function verwaltungsToken(bookingId) { + pruefeText(bookingId, 'bookingId') + return createHmac('sha256', schluessel('termine-manage')).update(bookingId).digest('base64url') +} + +/** Buchungsnummer aus der Buchungs-ID: 8 Zeichen A-Z0-9 (die ersten 64 Bit des SHA-256 zur Basis 36). */ +export function buchungsNummer(bookingId) { + pruefeText(bookingId, 'bookingId') + const zahl = createHash('sha256').update(`termine-nummer|${bookingId}`).digest().readBigUInt64BE(0) + return (zahl % 36n ** 8n).toString(36).toUpperCase().padStart(8, '0') +} + +function formMac(siteKey, zeit) { + return createHmac('sha256', schluessel('termine-form')).update(`${siteKey}|${zeit}`).digest('base64url') +} + +/** 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) + return `${zeit}.${formMac(siteKey, zeit)}` +} + +/** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */ +export function pruefeFormToken(siteKey, token, now = new Date()) { + if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig' + if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig' + const teile = token.split('.') + if (teile.length !== 2) return 'ungueltig' + const [zeit, mac] = teile + if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig' + const erwartet = Buffer.from(formMac(siteKey, zeit)) + const gegeben = Buffer.from(mac) + if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig' + + const alter = zeitMs(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/zellen.js b/server/services/termine/zellen.js new file mode 100644 index 0000000..b59cf72 --- /dev/null +++ b/server/services/termine/zellen.js @@ -0,0 +1,216 @@ +import { createHash } from 'node:crypto' +import { config } from '../../config.js' +import { Query, createDocuments, deleteDocument, listAllDocuments } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { istBelegend } from './slots.js' + +/** + * Belegungszellen gegen Doppelbuchung (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 5). + * + * Jede 5-Minuten-Zelle (config.termine.zelleMin) von Beginn bis Blockende ist ein termineClaims-Dokument mit + * deterministischer ID (c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso)). Alle neuen Zellen einer Belegung gehen + * in EINEM Bulk-Create (hoechstens 100 Zellen = 500 Minuten): Appwrite schreibt alles oder nichts, eine vergebene ID + * ergibt 409 document_already_exists. Zusaetzlich serialisiert withLocks je Ressource innerhalb des Prozesses. + * + * 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). + * + * 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 + * Zeit zwischen Zellen und Buchung wegraeumen. Scheitert belegeZellen (SLOT_VERGEBEN), raeumt der Aufrufer die + * Buchung selbst wieder auf. Beim Verschieben ist die Buchung ohnehin da. + */ + +/** Hoechstens so viele Zellen in einem Bulk-Create (Appwrite: 100 Dokumente je Anfrage). */ +const MAX_ZELLEN = 100 +const TAG_MS = 24 * 60 * 60 * 1000 +const SPERRE_WARTE_MS = 5000 +/** Werte je $id-Abfrage (haelt die Adresse kurz) und gleichzeitige Loeschungen. */ +const ID_BLOCK = 50 +const LOESCH_PARALLEL = 10 +/** Der taegliche Job liest bewusst alle Zellen; mehr als das waere ein Fehler. */ +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 slotVergeben = () => + Object.assign(new Error('Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.'), { + status: 409, + code: 'SLOT_VERGEBEN', + }) + +/** + * Alle Zellen von start bis blockEnde (ausgeschlossen) als Date, ausgerichtet am Raster seit 1970 (die Berliner + * Zeitzone verschiebt um ganze Stunden, daher stimmt das Raster mit der Wanduhr ueberein). Krumme Zeiten werden zu + * ganzen Zellen erweitert. Wirft bei ungueltigen Zeiten, Ende <= Beginn und mehr als 100 Zellen. + */ +export function zellenFuer(start, blockEnde) { + const von = zeitMs(start) + const bis = zeitMs(blockEnde) + if (!Number.isFinite(von) || !Number.isFinite(bis) || bis <= von) throw new RangeError('zellenFuer: Beginn und Ende ungültig') + const zelle = config.termine.zelleMin * 60000 + const erste = Math.floor(von / zelle) + const letzte = Math.ceil(bis / zelle) + if (letzte - erste > MAX_ZELLEN) throw new RangeError(`zellenFuer: mehr als ${MAX_ZELLEN} Zellen`) + const zellen = [] + for (let i = erste; i < letzte; i += 1) zellen.push(new Date(i * zelle)) + return zellen +} + +/** Dokument-ID einer Zelle: c_ + 34 Hex (36 Zeichen, das Maximum fuer Appwrite-IDs). */ +export function zellenId(featureId, ressourceId, zelle) { + const iso = new Date(zeitMs(zelle)).toISOString() + return `c_${createHash('sha1').update(`${featureId}|${ressourceId}|${iso}`).digest('hex').slice(0, 34)}` +} + +/** Dokumente einer Collection nach ihren IDs (in Bloecken, damit die Abfrage kurz bleibt; fehlende fehlen im Ergebnis). */ +async function leseNachIds(collection, ids) { + const gefunden = [] + for (let i = 0; i < ids.length; i += ID_BLOCK) { + gefunden.push(...(await listAllDocuments(collection, [Query.equal('$id', ids.slice(i, i + ID_BLOCK))]))) + } + return gefunden +} + +/** Zellen, deren Buchung fehlt oder zum Zeitpunkt nowMs nichts belegt. */ +async function ungueltigeZellen(zellen, nowMs) { + const buchungsIds = [...new Set(zellen.map((zelle) => zelle.bookingId).filter((id) => typeof id === 'string' && id !== ''))] + const vorhanden = new Map((await leseNachIds(buchungen(), buchungsIds)).map((buchung) => [buchung.$id, buchung])) + return zellen.filter((zelle) => { + const buchung = vorhanden.get(zelle.bookingId) + return !buchung || !istBelegend(buchung, nowMs) + }) +} + +/** Loescht Dokumente (404 = schon weg); versucht alle, wirft danach den ersten anderen Fehler. => Zahl geloeschter. */ +async function loesche(collection, ids) { + let geloescht = 0 + let fehler = null + for (let i = 0; i < ids.length; i += LOESCH_PARALLEL) { + const ergebnisse = await Promise.allSettled(ids.slice(i, i + LOESCH_PARALLEL).map((id) => deleteDocument(collection, id))) + for (const ergebnis of ergebnisse) { + if (ergebnis.status === 'fulfilled') geloescht += 1 + else if (ergebnis.reason?.status !== 404) fehler ??= ergebnis.reason + } + } + if (fehler) throw fehler + return geloescht +} + +/** Ein Bulk-Create; false bei 409 (eine Zelle ist vergeben, nichts geschrieben), andere Fehler gehen durch. */ +async function legeZellenAn(dokumente) { + try { + await createDocuments(claims(), dokumente) + return true + } catch (err) { + if (err?.status === 409) return false + throw err + } +} + +/** + * Nach einem 409: die blockierenden Zellen lesen und die ungueltigen loeschen. true = ein zweiter Versuch lohnt + * (ungueltige geloescht oder die Blockierer sind inzwischen weg), false = alle Blockierer gelten. + */ +async function raeumeBlockierer(ids, nowMs) { + const blockierer = await leseNachIds(claims(), ids) + if (blockierer.length === 0) return true + const ungueltig = await ungueltigeZellen(blockierer, nowMs) + if (ungueltig.length === 0) return false + await loesche(claims(), ungueltig.map((zelle) => zelle.$id)) + return true +} + +/** + * Belegt die Zellen von start bis blockEnde fuer bookingId auf einer Ressource oder wirft { status: 409, code: + * 'SLOT_VERGEBEN' } (dann ist nichts geschrieben). Unter der Sperre termine-res:: (hoechstens + * 5 s Wartezeit; die Datenbank bleibt die eigentliche Sicherung). + * + * ausser = ID einer Buchung, deren Zellen als eigene zaehlen (Verschieben): angelegt wird nur, was von ihr noch fehlt; + * ihre alten Zellen bleiben, bis der Aufrufer gibZellenFrei(id, { behalte }) aufruft. Bei einem Fehler aendert sich an + * ihnen nichts. + */ +export async function belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null } = {}) { + pruefeText(featureId, 'featureId') + pruefeText(ressourceId, 'ressourceId') + pruefeText(bookingId, 'bookingId') + if (ausser !== null) pruefeText(ausser, 'ausser') + const nowMs = pruefeJetzt(now, 'belegeZellen') + const zellen = zellenFuer(start, blockEnde) + const bis = new Date(zeitMs(blockEnde)).toISOString() + const dokumente = zellen.map((zelle) => ({ + $id: zellenId(featureId, ressourceId, zelle), + featureId, + ressourceId, + bookingId, + zelle: zelle.toISOString(), + bis, + })) + + await withLocks( + [`termine-res:${featureId}:${ressourceId}`], + async () => { + let neu = dokumente + if (ausser !== null) { + const eigene = new Set((await listAllDocuments(claims(), [Query.equal('bookingId', ausser)])).map((zelle) => zelle.$id)) + neu = dokumente.filter((dokument) => !eigene.has(dokument.$id)) + } + if (neu.length === 0) return + if (await legeZellenAn(neu)) return + if (!(await raeumeBlockierer(neu.map((dokument) => dokument.$id), nowMs))) throw slotVergeben() + if (!(await legeZellenAn(neu))) throw slotVergeben() + }, + { waitMs: SPERRE_WARTE_MS } + ) +} + +/** + * 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. + */ +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)) +} + +/** + * 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). + */ +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) + } + for (const zelle of await ungueltigeZellen(pruefen, nowMs)) verwaist.push(zelle.$id) + return verwaist +} diff --git a/test/termineZellen.test.js b/test/termineZellen.test.js new file mode 100644 index 0000000..2bc882e --- /dev/null +++ b/test/termineZellen.test.js @@ -0,0 +1,808 @@ +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 } = 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 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('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()) +}) + +// ---------------------------------------------------------------- Buchungsspeicher + +test('legeAn, hole, aendere: Anlegen mit fester ID, Lesen (fehlt => null), Aendern setzt updatedAt', async () => { + const id = randomUUID() + const vorLegeAn = Date.now() + const angelegt = await B.legeAn({ featureId: F, status: 'gehalten', gastEmail: 'max@gmail.com' }, id) + assert.equal(angelegt.$id, id) + assert.equal(angelegt.status, 'gehalten') + const geladen = await B.hole(id) + assert.equal(geladen.gastEmail, 'max@gmail.com') + for (const feld of ['createdAt', 'updatedAt']) { + const ms = new Date(geladen[feld]).getTime() + assert.ok(ms >= vorLegeAn - 1000 && ms <= Date.now() + 1000, `${feld}: ${geladen[feld]}`) + } + assert.equal(await B.hole('b_gibt_es_nicht'), null) + + const vorAendere = Date.now() + aw.seed('termineBookings', { ...geladen, updatedAt: '2020-01-01T00:00:00.000Z' }) + const geaendert = await B.aendere(id, { status: 'bestaetigt', bestaetigtAt: iso(JETZT) }) + assert.equal(geaendert.status, 'bestaetigt') + assert.equal(geaendert.gastEmail, 'max@gmail.com', 'andere Felder bleiben') + assert.ok(new Date(geaendert.updatedAt).getTime() >= vorAendere - 1000) + assert.equal((await B.hole(id)).status, 'bestaetigt') + // ein ganzes gelesenes Dokument darf zurueckgegeben werden: $-Felder werden nicht mitgeschickt + const wieder = await B.aendere(id, { ...(await B.hole(id)), status: 'abgesagt' }) + assert.equal(wieder.status, 'abgesagt') + await assert.rejects(B.aendere('b_gibt_es_nicht', { status: 'abgesagt' }), (err) => err.status === 404) +}) + +test('legeAn: eigene Zeitstempel bleiben, die ID darf auch als $id im Dokument stehen', async () => { + const angelegt = await B.legeAn(buchung('b_mit_id', { createdAt: '2026-10-05T08:00:00.000Z', updatedAt: '2026-10-05T08:00:00.000Z' })) + assert.equal(angelegt.$id, 'b_mit_id') + const geladen = await B.hole('b_mit_id') + assert.equal(new Date(geladen.createdAt).getTime(), JETZT.getTime()) + assert.equal(new Date(geladen.updatedAt).getTime(), JETZT.getTime()) +}) + +test('legeAn vergibt ohne ID eine neue, nimmt keine doppelte ID an und schreibt keine unbekannten Felder', async () => { + const a = await B.legeAn({ featureId: F, status: 'gehalten' }) + assert.match(a.$id, /^[0-9a-f-]{36}$/) + await assert.rejects(B.legeAn({ featureId: F }, a.$id), (err) => err.status === 409) + await assert.rejects(B.legeAn({ featureId: F, bogus: 1 }), (err) => err.status === 400) +}) + +test('imZeitraum findet die Belegung vom Vortag und auch mehr als 25 Treffer', async () => { + // 22:00 bis 06:00 Berlin (Sommerzeit): 20:00Z bis 04:00Z des Folgetags + seedBuchung('b_nacht', { startsAt: '2026-10-11T20:00:00Z', endsAt: '2026-10-12T04:00:00Z', blockEndsAt: '2026-10-12T04:00:00Z' }) + seedBuchung('b_vorher', { startsAt: '2026-10-11T08:00:00Z', endsAt: '2026-10-11T09:00:00Z', blockEndsAt: '2026-10-11T09:00:00Z' }) + seedBuchung('b_endet_genau', { startsAt: '2026-10-11T21:00:00Z', endsAt: '2026-10-11T22:00:00Z', blockEndsAt: '2026-10-11T22:00:00Z' }) + seedBuchung('b_beginnt_genau', { startsAt: '2026-10-12T22:00:00Z', endsAt: '2026-10-12T23:00:00Z', blockEndsAt: '2026-10-12T23:00:00Z' }) + seedBuchung('b_nachher', { startsAt: '2026-10-13T08:00:00Z', endsAt: '2026-10-13T09:00:00Z', blockEndsAt: '2026-10-13T09:00:00Z' }) + seedBuchung('b_fremd', { featureId: 'f_feature02', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' }) + seedBuchung('b_abgesagt', { status: 'abgesagt', startsAt: '2026-10-12T08:00:00Z', blockEndsAt: '2026-10-12T09:00:00Z' }) + const von = new Date('2026-10-11T22:00:00Z') // Beginn des 12.10. in Berlin + const bis = new Date('2026-10-12T22:00:00Z') // Ende des 12.10. in Berlin + const treffer = await B.imZeitraum(F, von, bis) + assert.deepEqual(treffer.map((doc) => doc.$id).sort(), ['b_abgesagt', 'b_nacht']) + + for (let i = 0; i < 30; i += 1) { + seedBuchung(`b_tag${String(i).padStart(2, '0')}`, { startsAt: iso(new Date(T('06:00').getTime() + i * 5 * MIN)), blockEndsAt: iso(new Date(T('06:00').getTime() + (i + 1) * 5 * MIN)) }) + } + const viele = await B.imZeitraum(F, von, bis) + assert.equal(viele.length, 32) + // Text- und Date-Grenzen sind gleichwertig + assert.equal((await B.imZeitraum(F, von.toISOString(), bis.toISOString())).length, 32) +}) + +test('perHash findet ueber verifyHash oder manageHash, nie ueber leere oder fremde Werte', async () => { + const t = neuesToken() + const m = hashToken(verwaltungsToken('b_eins')) + seedBuchung('b_eins', { verifyHash: t.hash, manageHash: m }) + seedBuchung('b_leer', { verifyHash: '', manageHash: '' }) + assert.equal((await B.perHash('verifyHash', t.hash)).$id, 'b_eins') + assert.equal((await B.perHash('manageHash', m)).$id, 'b_eins') + assert.equal(await B.perHash('verifyHash', m), null) + assert.equal(await B.perHash('verifyHash', hashToken('anderes')), null) + assert.equal(await B.perHash('verifyHash', ''), null) + assert.equal(await B.perHash('verifyHash', undefined), null) + assert.equal(await B.perHash('verifyHash', 'abc'), null) + await assert.rejects(B.perHash('gastEmail', t.hash), TypeError) +}) + +test('offeneFuerEmail: gueltige Haltungen, Anfragen und kuenftige bestaetigte Termine dieser Adresse', async () => { + const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms)) + const termin = (ms) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN) }) + seedBuchung('b_best', { status: 'bestaetigt', ...termin(2 * TAG) }) + seedBuchung('b_anfr', { status: 'angefragt', ...termin(3 * TAG) }) + seedBuchung('b_halt', { status: 'gehalten', heldUntil: spaeter(10 * MIN), ...termin(4 * TAG) }) + seedBuchung('b_halt_alt', { status: 'gehalten', heldUntil: spaeter(-MIN), ...termin(4 * TAG) }) + seedBuchung('b_abg', { status: 'abgesagt', ...termin(2 * TAG) }) + seedBuchung('b_verg', { status: 'bestaetigt', ...termin(-2 * TAG) }) + seedBuchung('b_laeuft', { status: 'bestaetigt', ...termin(-10 * MIN) }) + seedBuchung('b_fremd_mail', { status: 'bestaetigt', gastEmail: 'anna@gmail.com', ...termin(2 * TAG) }) + seedBuchung('b_fremd_f', { status: 'bestaetigt', featureId: 'f_feature02', ...termin(2 * TAG) }) + const offen = await B.offeneFuerEmail(F, 'max@gmail.com', JETZT) + assert.deepEqual(offen.map((doc) => doc.$id).sort(), ['b_anfr', 'b_best', 'b_halt', 'b_laeuft']) + // dieselbe Adresse in anderer Schreibweise + assert.equal((await B.offeneFuerEmail(F, ' Max@GMail.com ', JETZT)).length, 4) + assert.deepEqual(await B.offeneFuerEmail(F, 'niemand@gmail.com', JETZT), []) +}) + +test('perEmailUndNummer: Adresse und Buchungsnummer muessen zusammen passen', async () => { + seedBuchung('b_eins') + seedBuchung('b_zwei') + seedBuchung('b_drei', { gastEmail: 'anna@gmail.com' }) + seedBuchung('b_vier', { featureId: 'f_feature02' }) + const nummer = buchungsNummer('b_zwei') + assert.equal((await B.perEmailUndNummer(F, 'max@gmail.com', nummer)).$id, 'b_zwei') + assert.equal((await B.perEmailUndNummer(F, ' MAX@gmail.com ', nummer.toLowerCase())).$id, 'b_zwei') + assert.equal(await B.perEmailUndNummer(F, 'anna@gmail.com', nummer), null) + assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_drei')), null) + assert.equal(await B.perEmailUndNummer('f_feature02', 'max@gmail.com', buchungsNummer('b_vier')).then((d) => d.$id), 'b_vier') + assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', buchungsNummer('b_vier')), null) + assert.equal(await B.perEmailUndNummer(F, 'max@gmail.com', ''), null) + assert.equal(await B.perEmailUndNummer(F, '', nummer), null) +}) + +test('zaehleKuenftige: belegende Termine je Leistung und Ressource, ohne Sperren und Vergangenes', async () => { + const spaeter = (ms) => iso(new Date(JETZT.getTime() + ms)) + const termin = (ms, over = {}) => ({ startsAt: spaeter(ms), endsAt: spaeter(ms + 45 * MIN), blockEndsAt: spaeter(ms + 50 * MIN), ...over }) + seedBuchung('b_1', termin(TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa1' })) + seedBuchung('b_2', termin(2 * TAG, { leistungId: 's_aaaaaaa1', ressourceId: 'r_aaaaaaa2', status: 'angefragt' })) + seedBuchung('b_3', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(MIN) })) + seedBuchung('b_4', termin(3 * TAG, { leistungId: 's_aaaaaaa2', ressourceId: 'r_aaaaaaa1', status: 'gehalten', heldUntil: spaeter(-MIN) })) + seedBuchung('b_5', termin(TAG, { status: 'abgesagt' })) + seedBuchung('b_6', termin(-TAG)) + seedBuchung('b_7', termin(TAG, { quelle: 'sperre', leistungId: '', ressourceId: 'r_aaaaaaa1' })) + seedBuchung('b_8', termin(TAG, { featureId: 'f_feature02' })) + const zahlen = await B.zaehleKuenftige(F, JETZT) + assert.deepEqual(zahlen, { + leistungen: { s_aaaaaaa1: 2, s_aaaaaaa2: 1 }, + ressourcen: { r_aaaaaaa1: 2, r_aaaaaaa2: 1 }, + }) + assert.deepEqual(await B.zaehleKuenftige('f_leer', JETZT), { leistungen: {}, ressourcen: {} }) +}) + +test('abgelaufeneHaltungen: gehalten mit heldUntil <= jetzt, ueber alle Funktionen', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + seedBuchung('b_alt', { status: 'gehalten', heldUntil: mit(-HALB) }) + seedBuchung('b_genau', { status: 'gehalten', heldUntil: mit(0), featureId: 'f_feature02' }) + seedBuchung('b_noch', { status: 'gehalten', heldUntil: mit(1) }) + seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-HALB) }) + seedBuchung('b_verf', { status: 'verfallen', heldUntil: mit(-HALB) }) + const liste = await B.abgelaufeneHaltungen(JETZT) + assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_genau']) +}) + +test('erinnerungsKandidaten: bestaetigt, Erinnerung gewuenscht, noch nicht gesendet, Beginn in (jetzt + 1 h, jetzt + 48 h]', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + const k = (id, ms, over = {}) => seedBuchung(id, { status: 'bestaetigt', erinnerung: true, startsAt: mit(ms), endsAt: mit(ms + 45 * MIN), blockEndsAt: mit(ms + 50 * MIN), ...over }) + k('b_ok1', 2 * STUNDE) + k('b_ok2', 24 * STUNDE, { featureId: 'f_feature02' }) + k('b_ok3', 48 * STUNDE) + k('b_ok4', STUNDE + 1) + k('b_zu_nah', STUNDE) + k('b_zu_fern', 48 * STUNDE + 1) + k('b_ohne_haken', 5 * STUNDE, { erinnerung: false }) + k('b_gesendet', 5 * STUNDE, { erinnerungGesendetAt: mit(-MIN) }) + k('b_anfrage', 5 * STUNDE, { status: 'angefragt' }) + k('b_abgesagt', 5 * STUNDE, { status: 'abgesagt' }) + const liste = await B.erinnerungsKandidaten(JETZT) + assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_ok1', 'b_ok2', 'b_ok3', 'b_ok4']) +}) + +test('zuErledigen: bestaetigt mit Ende vor jetzt - 1 h', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + const k = (id, endeMs, over = {}) => seedBuchung(id, { status: 'bestaetigt', startsAt: mit(endeMs - 45 * MIN), endsAt: mit(endeMs), blockEndsAt: mit(endeMs + 5 * MIN), ...over }) + k('b_alt', -STUNDE - 1, { featureId: 'f_feature02' }) + k('b_alt2', -3 * TAG) + k('b_genau', -STUNDE) + k('b_laeuft', 10 * MIN) + k('b_angefragt', -3 * TAG, { status: 'angefragt' }) + k('b_erledigt', -3 * TAG, { status: 'erledigt' }) + const liste = await B.zuErledigen(JETZT) + assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2']) +}) + +test('vergangeneAnfragen: angefragt mit Beginn vor jetzt', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + seedBuchung('b_alt', { status: 'angefragt', startsAt: mit(-MIN) }) + seedBuchung('b_alt2', { status: 'angefragt', startsAt: mit(-5 * TAG), featureId: 'f_feature02' }) + seedBuchung('b_genau', { status: 'angefragt', startsAt: mit(0) }) + seedBuchung('b_spaeter', { status: 'angefragt', startsAt: mit(MIN) }) + seedBuchung('b_best', { status: 'bestaetigt', startsAt: mit(-TAG) }) + const liste = await B.vergangeneAnfragen(JETZT) + assert.deepEqual(liste.map((doc) => doc.$id).sort(), ['b_alt', 'b_alt2']) +}) + +test('loeschKandidaten: Bezugszeit je Status (verfallen heldUntil, erledigt Terminende, abgesagt letzte Aenderung)', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + const grenze = new Date(JETZT.getTime() - 48 * STUNDE) + seedBuchung('b_verf_alt', { status: 'verfallen', heldUntil: mit(-49 * STUNDE) }) + seedBuchung('b_verf_neu', { status: 'verfallen', heldUntil: mit(-47 * STUNDE) }) + seedBuchung('b_best', { status: 'bestaetigt', heldUntil: mit(-49 * STUNDE) }) + assert.deepEqual((await B.loeschKandidaten('verfallen', grenze)).map((d) => d.$id), ['b_verf_alt']) + + const dreissig = new Date(JETZT.getTime() - 30 * TAG) + seedBuchung('b_abg_alt', { status: 'abgesagt', updatedAt: mit(-31 * TAG) }) + seedBuchung('b_abg_neu', { status: 'abgesagt', updatedAt: mit(-29 * TAG) }) + seedBuchung('b_abl_alt', { status: 'abgelehnt', updatedAt: mit(-31 * TAG), featureId: 'f_feature02' }) + assert.deepEqual((await B.loeschKandidaten('abgesagt', dreissig)).map((d) => d.$id), ['b_abg_alt']) + assert.deepEqual((await B.loeschKandidaten('abgelehnt', dreissig)).map((d) => d.$id), ['b_abl_alt']) + + const neunzig = new Date(JETZT.getTime() - 90 * TAG) + seedBuchung('b_erl_alt', { status: 'erledigt', endsAt: mit(-91 * TAG) }) + seedBuchung('b_erl_neu', { status: 'erledigt', endsAt: mit(-89 * TAG) }) + seedBuchung('b_nie_alt', { status: 'nichterschienen', endsAt: mit(-100 * TAG) }) + assert.deepEqual((await B.loeschKandidaten('erledigt', neunzig)).map((d) => d.$id), ['b_erl_alt']) + assert.deepEqual((await B.loeschKandidaten('nichterschienen', neunzig)).map((d) => d.$id), ['b_nie_alt']) + + for (let i = 0; i < 30; i += 1) seedBuchung(`b_viele${String(i).padStart(2, '0')}`, { status: 'erledigt', endsAt: mit(-200 * TAG) }) + assert.equal((await B.loeschKandidaten('erledigt', neunzig)).length, 31) + await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig), TypeError) + await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError) +}) + +test('zaehleAnfragen: offene Anfragen einer Funktion (auch ueber 25)', async () => { + for (let i = 0; i < 27; i += 1) seedBuchung(`b_anf${String(i).padStart(2, '0')}`, { status: 'angefragt' }) + seedBuchung('b_best', { status: 'bestaetigt' }) + seedBuchung('b_fremd', { status: 'angefragt', featureId: 'f_feature02' }) + assert.equal(await B.zaehleAnfragen(F), 27) + assert.equal(await B.zaehleAnfragen('f_feature02'), 1) + assert.equal(await B.zaehleAnfragen('f_leer'), 0) +})