diff --git a/server/services/zeiten/cache.js b/server/services/zeiten/cache.js new file mode 100644 index 0000000..9caba6e --- /dev/null +++ b/server/services/zeiten/cache.js @@ -0,0 +1,18 @@ +import { neuerLru } from './lru.js' + +/** + * Speicher der oeffentlichen Zeiten-Antwort (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4: 30 Sekunden, + * als LRU hoechstens 500 Eintraege). Schluessel = siteKey. Wer Zeiten oder Team-Einstellungen schreibt, ruft vergissZeiten; + * wer eine Antwort baut, legt sie mit zeitenCache.setze(siteKey, antwort, jetztMs) ab und holt sie mit hole (undefined = nicht da). + * Ein Eintrag verfaellt 30 Sekunden nach dem Setzen, auch ohne Speichern (Zustandswechsel der Funktion wirken so mit hoechstens 30 s Verzoegerung). + */ +export const zeitenCache = neuerLru({ max: 500, ttlMs: 30000 }) + +/** Entfernt die Antwort dieses siteKeys (nach jedem Schreiben). Unbekannte oder fehlende Schluessel schaden nicht. */ +export function vergissZeiten(siteKey) { + zeitenCache.vergiss(siteKey) +} + +export function resetZeitenCacheForTests() { + zeitenCache.leere() +} diff --git a/server/services/zeiten/lru.js b/server/services/zeiten/lru.js new file mode 100644 index 0000000..74c2eb5 --- /dev/null +++ b/server/services/zeiten/lru.js @@ -0,0 +1,56 @@ +/** + * Oeffnungszeiten live: kleiner LRU-Speicher mit Ablaufzeit (rein, ohne eigene Uhr: die Zeit kommt als Millisekunden-Parameter). + * + * - hoechstens max Eintraege; ist er voll, faellt der am laengsten nicht benutzte (hole oder setze) heraus; + * - jeder Eintrag verfaellt ttlMs nach dem Setzen (genau nach ttlMs ist er weg); ein Treffer erneuert nur seinen Platz in + * der Reihe, nie die Ablaufzeit, damit auch ein oft abgefragter Eintrag spaetestens nach ttlMs neu gebaut wird; + * - hole liefert bei Fehlschlag oder Ablauf undefined; gespeichert werden darf jeder Wert (auch null, 0, ''). + * Umsetzung: eine Map; ihre Einfuegereihenfolge ist die Nutzungsreihenfolge (aeltester zuerst), ein Treffer haengt den Eintrag neu an. + */ + +const pruefeZeit = (nowMs, wer) => { + if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) throw new TypeError(`${wer}: nowMs muss eine Zahl (Millisekunden) sein`) +} + +/** + * @param {{ max: number, ttlMs: number }} optionen max = ganze Zahl >= 1, ttlMs = Zahl > 0 (endlich) + * @returns {{ hole(key: string, nowMs: number): *, setze(key: string, wert: *, nowMs: number): void, vergiss(key: string): void, leere(): void, groesse(): number }} + */ +export function neuerLru({ max, ttlMs } = {}) { + if (!Number.isInteger(max) || max < 1) throw new TypeError('neuerLru: max muss eine ganze Zahl ab 1 sein') + if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('neuerLru: ttlMs muss eine Zahl groesser 0 sein') + + /** @type {Map} */ + const eintraege = new Map() + + return { + hole(key, nowMs) { + pruefeZeit(nowMs, 'hole') + const eintrag = eintraege.get(key) + if (!eintrag) return undefined + eintraege.delete(key) + if (eintrag.bis <= nowMs) return undefined + eintraege.set(key, eintrag) + return eintrag.wert + }, + + setze(key, wert, nowMs) { + pruefeZeit(nowMs, 'setze') + eintraege.delete(key) + eintraege.set(key, { bis: nowMs + ttlMs, wert }) + while (eintraege.size > max) eintraege.delete(eintraege.keys().next().value) + }, + + vergiss(key) { + eintraege.delete(key) + }, + + leere() { + eintraege.clear() + }, + + groesse() { + return eintraege.size + }, + } +} diff --git a/server/services/zeiten/speicher.js b/server/services/zeiten/speicher.js new file mode 100644 index 0000000..29830d1 --- /dev/null +++ b/server/services/zeiten/speicher.js @@ -0,0 +1,284 @@ +import { config } from '../../config.js' +import { createDocument, deleteDocument, getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { vergissZeiten, zeitenCache } from './cache.js' +import { normalisiereTeam, normalisiereZeiten } from './pruefen.js' + +/** + * Oeffnungszeiten live: Speicher in Appwrite (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, 2.1). + * Ein Dokument je Funktion in config.collections.oeffnungszeiten, Dokument-ID = ID des portalFeatures-Dokuments (feature.$id). + * + * Zwei getrennte Versionen (optimistische Sperre): `version` zaehlt die Speichervorgaenge des Kunden (daten), `teamVersion` die + * des Teams (team); ein Kunde im Editor und das Team in WOMS stoeren sich so nicht. 0 = noch nie gespeichert. Speichert das Team, + * bevor der Kunde Zeiten eingetragen hat, entsteht das Dokument mit version 0 und ohne daten (ladeZeiten: daten null); der + * Kunde speichert danach mit version 0 und bekommt 1. + * Weicht die mitgebrachte Version von der gespeicherten ab (auch wenn sie fehlt oder keine ganze Zahl ist), passiert nichts und + * es folgt { status: 409, code: 'ZEITEN_VERALTET' }; ungueltige Eingaben ergeben { status: 400, fehler }. + * + * Alle Schreibzugriffe (Zeiten, Team, Ersatz-Hash, Loeschen) laufen unter der Sperre `zeiten:` (nicht wiederbetretbar: + * Aufrufer duerfen sie nicht selbst halten); gelesen und verglichen wird INNERHALB der Sperre. Nach jedem Schreibversuch (auch + * einem fehlgeschlagenen: unklar, was in der Datenbank steht) verwirft vergissZeiten die oeffentliche Antwort dieses siteKeys. + */ + +const veraltet = () => + Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' }) + +const ungueltig = (nachricht, fehler) => Object.assign(new Error(nachricht), { status: 400, fehler }) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function pruefeText(wert, name) { + if (typeof wert !== 'string' || wert === '') throw new TypeError(`${name} fehlt`) +} + +function pruefeFeature(feature) { + pruefeText(feature?.$id, 'feature.$id') + pruefeText(feature.projectId, 'feature.projectId') +} + +function pruefeJetzt(now) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum') + return now +} + +/** updatedBy: 'kunde' oder 'team:' (Spalte 64 Zeichen). */ +const vonWem = (by, standard) => String(by ?? '').trim().slice(0, 64) || standard + +/** Sperre aller Schreibzugriffe auf die Zeiten eines Projekts. */ +const sperre = (projectId) => `zeiten:${projectId}` + +/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt, das ist ein Fehler und wird weitergereicht). */ +const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' + +async function holeOderNull(collectionId, id) { + try { + return await getDocument(collectionId, id) + } catch (err) { + if (dokumentFehlt(err)) return null + throw err + } +} + +/** Dokument loeschen; war es schon weg, ist das kein Fehler. => true, wenn dieser Aufruf es geloescht hat. */ +async function loescheOderIgnoriere(collectionId, id) { + try { + await deleteDocument(collectionId, id) + return true + } catch (err) { + if (dokumentFehlt(err)) return false + throw err + } +} + +const versionVon = (doc, feld) => (doc && Number.isInteger(doc[feld]) ? doc[feld] : 0) + +/** JSON-Objekt aus einem Textfeld; fehlt es: null (still), unlesbar: null mit einer Logzeile ohne Inhalt (der gehoert nicht ins Log). */ +function liesObjekt(doc, feld) { + const roh = doc?.[feld] + if (typeof roh !== 'string' || roh === '') return null + try { + const wert = JSON.parse(roh) + if (istObjekt(wert)) return wert + } catch { + // unten gemeldet + } + console.error(`[zeiten-speicher] ${feld} von ${doc.$id} ist unlesbar`) + return null +} + +const standardTeam = () => normalisiereTeam({}).team + +/** Team-Einstellungen aus dem Dokument; fehlend, unlesbar oder nicht mehr gueltig (von Hand veraendert) ergibt die Vorgabe. */ +function liesTeam(doc) { + const roh = liesObjekt(doc, 'team') + if (!roh) return standardTeam() + const erg = normalisiereTeam(roh) + if (erg.fehler) { + console.error(`[zeiten-speicher] team von ${doc.$id} ist ungueltig`) + return standardTeam() + } + return erg.team +} + +function grundfelder(feature) { + return { customerId: feature.customerId ?? '', projectId: feature.projectId, featureId: feature.$id } +} + +/** + * Schreibt felder in das Dokument der Funktion (aendern; gibt es keins, mit den Grundfeldern anlegen, beide Versionen 0, soweit + * felder sie nicht setzt). Legt ein anderer Vorgang das Dokument zwischen Lesen und Anlegen an (Appwrite 409), ist der Stand veraltet. + * Danach (auch bei Fehlern) ist die oeffentliche Antwort dieses siteKeys vergessen. + */ +async function schreibe(feature, vorhanden, felder) { + const col = config.collections.oeffnungszeiten + try { + if (vorhanden) { + await updateDocument(col, feature.$id, felder) + return + } + try { + await createDocument(col, { ...grundfelder(feature), version: 0, teamVersion: 0, ...felder }, feature.$id) + } catch (err) { + if (err?.status === 409) throw veraltet() + throw err + } + } finally { + vergissZeiten(feature.siteKey) + } +} + +/** + * Die gespeicherten Zeiten einer Funktion. + * @param {string} featureId ID des portalFeatures-Dokuments (= ID des oeffnungszeiten-Dokuments) + * @returns {Promise<{ daten: object|null, version: number, team: object, teamVersion: number, ersatzHash: string }|null>} + * null, wenn es kein Dokument gibt. daten ist null, wenn der Kunde noch nichts gespeichert hat (nur Team-Einstellungen oder + * Ersatz-Hash vorhanden) oder die Daten unlesbar sind (version bleibt die des Dokuments: wer sie ueberschreiben will, speichert + * mit genau dieser Version). team ist immer vollstaendig (Vorgabe, wenn nichts oder nichts Gueltiges gespeichert ist), + * ersatzHash '' ohne Eintrag. + */ +export async function ladeZeiten(featureId) { + pruefeText(featureId, 'featureId') + const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId) + if (!doc) return null + return { + daten: liesObjekt(doc, 'daten'), + version: versionVon(doc, 'version'), + team: liesTeam(doc), + teamVersion: versionVon(doc, 'teamVersion'), + ersatzHash: typeof doc.ersatzHash === 'string' ? doc.ersatzHash : '', + } +} + +/** + * Speichert die Zeiten des Kunden (oder des Teams im Namen des Kunden). Pruefen (normalisiereZeiten, mit den gespeicherten Daten als + * bisher fuer die unveraenderlichen Kurznamen) und Schreiben laufen unter der Sperre, nach der Versionspruefung: ist die Version + * veraltet, gewinnt 409, auch bei falscher Eingabe. + * @param {{ feature: { $id: string, customerId: string, projectId: string, siteKey?: string }, + * eingabe: object, version: number, by?: string, now?: Date }} p + * version = Version, auf der die Aenderung beruht (0 = noch nie gespeichert); by = 'kunde' (Vorgabe) | 'team:' + * @returns {Promise<{ daten: object, version: number }>} das Normalisierte, wie gespeichert, mit der neuen Version + * @throws {Error} status 400 mit fehler (je Pfad eine Meldung) / status 409 mit code 'ZEITEN_VERALTET' + */ +export async function speichereZeiten({ feature, eingabe, version, by = 'kunde', now = new Date() } = {}) { + pruefeFeature(feature) + const jetzt = pruefeJetzt(now) + + return withLocks([sperre(feature.projectId)], async () => { + const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) + const aktuell = versionVon(vorhanden, 'version') + if (version !== aktuell) throw veraltet() + + const erg = normalisiereZeiten(eingabe, { bisher: vorhanden ? liesObjekt(vorhanden, 'daten') : null, now: jetzt }) + if (erg.fehler) throw ungueltig('Bitte prüfe deine Eingaben.', erg.fehler) + + await schreibe(feature, vorhanden, { + daten: JSON.stringify(erg.daten), + version: aktuell + 1, + updatedAt: jetzt.toISOString(), + updatedBy: vonWem(by, 'kunde'), + }) + return { daten: erg.daten, version: aktuell + 1 } + }) +} + +/** + * Speichert die Team-Einstellungen (nur ueber WOMS); eigene Version teamVersion, die Kundendaten und deren Version bleiben. + * Gibt es noch kein Dokument, wird eins ohne daten angelegt (version 0). + * @param {{ feature: object, eingabe: object, teamVersion: number, by?: string, now?: Date }} p by = 'team:' + * @returns {Promise<{ team: object, teamVersion: number }>} + * @throws {Error} status 400 mit fehler / status 409 mit code 'ZEITEN_VERALTET' + */ +export async function speichereTeam({ feature, eingabe, teamVersion, by = 'team', now = new Date() } = {}) { + pruefeFeature(feature) + const jetzt = pruefeJetzt(now) + + return withLocks([sperre(feature.projectId)], async () => { + const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) + const aktuell = versionVon(vorhanden, 'teamVersion') + if (teamVersion !== aktuell) throw veraltet() + + const erg = normalisiereTeam(eingabe) + if (erg.fehler) throw ungueltig('Bitte prüfe die Team-Einstellungen.', erg.fehler) + + await schreibe(feature, vorhanden, { + team: JSON.stringify(erg.team), + teamVersion: aktuell + 1, + updatedAt: jetzt.toISOString(), + updatedBy: vonWem(by, 'team'), + }) + return { team: erg.team, teamVersion: aktuell + 1 } + }) +} + +/** + * Merkt den Hash des Hauptplan-Wochenplans zum Zeitpunkt des Ersatz-HTMLs (wochenplanHash, '' = zuruecksetzen). Fasst weder + * die Versionen noch updatedAt/updatedBy noch Daten und Team an: kein Kunde und kein Team-Editor wird dadurch veraltet. + * Gibt es noch kein Dokument, entsteht eins ohne daten (version 0). + */ +export async function setzeErsatzHash(feature, hash) { + pruefeFeature(feature) + if (typeof hash !== 'string' || hash.length > 64) throw new TypeError('hash muss ein Text mit hoechstens 64 Zeichen sein') + + await withLocks([sperre(feature.projectId)], async () => { + const vorhanden = await holeOderNull(config.collections.oeffnungszeiten, feature.$id) + await schreibe(feature, vorhanden, vorhanden ? { ersatzHash: hash } : { ersatzHash: hash, updatedAt: new Date().toISOString(), updatedBy: 'system' }) + }) +} + +/** Loescht alle Erinnerungsmerker einer Funktion (mehrere Seiten moeglich). */ +async function loescheMerker(featureId) { + const merker = await listAllDocuments(config.collections.zeitenErinnerungen, [Query.equal('featureId', featureId), Query.select(['featureId'])], { pageSize: 100 }) + for (const doc of merker) await loescheOderIgnoriere(config.collections.zeitenErinnerungen, doc.$id) +} + +/** Loescht Dokument (unter der Sperre seines Projekts) und Merker einer Funktion. => true, wenn dieser Aufruf das Dokument geloescht hat. */ +async function loescheFunktion(featureId, projectId) { + const dokumentGeloescht = await withLocks([sperre(projectId)], () => loescheOderIgnoriere(config.collections.oeffnungszeiten, featureId)) + await loescheMerker(featureId) + return dokumentGeloescht +} + +/** + * Loescht die Zeiten einer Funktion samt Erinnerungsmerkern (Abo-Ende nach der Frist). Ein fehlendes Dokument ist kein Fehler. + * Die oeffentliche Antwort wird verworfen (der siteKey ist hier nicht bekannt: der ganze Speicher wird geleert, Loeschen ist selten). + * @returns {Promise} true, wenn es ein Dokument gab und es geloescht wurde + */ +export async function loescheZeiten(featureId) { + pruefeText(featureId, 'featureId') + try { + const doc = await holeOderNull(config.collections.oeffnungszeiten, featureId) + if (!doc) { + await loescheMerker(featureId) + return false + } + return await loescheFunktion(featureId, doc.projectId) + } finally { + zeitenCache.leere() + } +} + +/** + * Loescht alle Zeiten-Dokumente eines Kunden samt Erinnerungsmerkern (Kunden-Loeschen). Jedes Dokument unter der Sperre seines + * Projekts; ein Fehler bricht nicht ab: alle uebrigen werden trotzdem geloescht und der erste Fehler kommt am Ende (ein neuer + * Aufruf raeumt den Rest). + * @returns {Promise} Anzahl der geloeschten Dokumente + */ +export async function loescheZeitenVonKunde(customerId) { + pruefeText(customerId, 'customerId') + let geloescht = 0 + let ersterFehler = null + try { + const docs = await listAllDocuments(config.collections.oeffnungszeiten, [Query.equal('customerId', customerId), Query.select(['projectId'])], { pageSize: 100 }) + for (const doc of docs) { + try { + if (await loescheFunktion(doc.$id, doc.projectId)) geloescht += 1 + } catch (err) { + ersterFehler ??= err + } + } + } finally { + zeitenCache.leere() + } + if (ersterFehler) throw ersterFehler + return geloescht +} diff --git a/test/zeitenLru.test.js b/test/zeitenLru.test.js new file mode 100644 index 0000000..b933250 --- /dev/null +++ b/test/zeitenLru.test.js @@ -0,0 +1,152 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { neuerLru } from '../server/services/zeiten/lru.js' +import { zeitenCache, vergissZeiten, resetZeitenCacheForTests } from '../server/services/zeiten/cache.js' + +// Zeiten-Speicher im Arbeitsspeicher: echtes LRU mit Ablaufzeit (server/services/zeiten/lru.js) und die Instanz fuer die +// oeffentliche Schnittstelle (cache.js: hoechstens 500 Eintraege, 30 Sekunden). Rein, die Uhr kommt als Parameter (Millisekunden). + +test('LRU verdrängt den ältesten, Treffer erneuern', () => { + const lru = neuerLru({ max: 2, ttlMs: 1000 }) + lru.setze('a', 1, 0) + lru.setze('b', 2, 0) + assert.equal(lru.hole('a', 1), 1) // a ist jetzt der jüngste, b der älteste + lru.setze('c', 3, 2) + assert.equal(lru.hole('b', 3), undefined, 'b war der älteste und muss fehlen') + assert.equal(lru.hole('a', 3), 1) + assert.equal(lru.hole('c', 3), 3) + assert.equal(lru.groesse(), 2) +}) + +test('LRU ohne Treffer verdrängt in Einfügereihenfolge', () => { + const lru = neuerLru({ max: 2, ttlMs: 1000 }) + lru.setze('a', 1, 0) + lru.setze('b', 2, 0) + lru.setze('c', 3, 0) + assert.equal(lru.hole('a', 0), undefined) + assert.equal(lru.hole('b', 0), 2) + assert.equal(lru.hole('c', 0), 3) +}) + +test('LRU: erneutes Setzen ersetzt den Wert, erneuert Platz und Ablaufzeit und füllt den Speicher nicht auf', () => { + const lru = neuerLru({ max: 2, ttlMs: 1000 }) + lru.setze('a', 1, 0) + lru.setze('b', 2, 0) + lru.setze('a', 11, 900) // a ist neu und jünger, b der älteste + assert.equal(lru.groesse(), 2) + lru.setze('c', 3, 900) + assert.equal(lru.hole('b', 900), undefined) + assert.equal(lru.hole('a', 1500), 11, 'die Ablaufzeit läuft ab dem letzten Setzen') + assert.equal(lru.hole('a', 1900), undefined, '900 + 1000 = abgelaufen') +}) + +test('LRU-Einträge verfallen nach ttl', () => { + const lru = neuerLru({ max: 10, ttlMs: 30000 }) + lru.setze('a', 'x', 1000) + assert.equal(lru.hole('a', 1000), 'x') + assert.equal(lru.hole('a', 30999), 'x', 'eine Millisekunde vor Ablauf') + assert.equal(lru.hole('a', 31000), undefined, 'genau nach ttlMs ist der Eintrag weg') + assert.equal(lru.groesse(), 0, 'ein abgelaufener Eintrag bleibt nicht liegen') +}) + +test('LRU: ein Treffer verlängert die Ablaufzeit nicht (nur den Platz in der Reihe)', () => { + const lru = neuerLru({ max: 10, ttlMs: 1000 }) + lru.setze('a', 1, 0) + assert.equal(lru.hole('a', 900), 1) + assert.equal(lru.hole('a', 1000), undefined) +}) + +test('LRU: vergiss, leere, groesse', () => { + const lru = neuerLru({ max: 5, ttlMs: 1000 }) + assert.equal(lru.groesse(), 0) + lru.setze('a', 1, 0) + lru.setze('b', 2, 0) + assert.equal(lru.groesse(), 2) + lru.vergiss('a') + lru.vergiss('gibt-es-nicht') + assert.equal(lru.hole('a', 0), undefined) + assert.equal(lru.hole('b', 0), 2) + assert.equal(lru.groesse(), 1) + lru.leere() + assert.equal(lru.groesse(), 0) + assert.equal(lru.hole('b', 0), undefined) +}) + +test('LRU: Fehlschlag ist undefined, gespeicherte Werte wie null, 0 und leerer Text bleiben unterscheidbar', () => { + const lru = neuerLru({ max: 5, ttlMs: 1000 }) + assert.equal(lru.hole('fehlt', 0), undefined) + lru.setze('n', null, 0) + lru.setze('z', 0, 0) + lru.setze('s', '', 0) + assert.equal(lru.hole('n', 0), null) + assert.equal(lru.hole('z', 0), 0) + assert.equal(lru.hole('s', 0), '') +}) + +test('LRU: max 1 behält nur den letzten Eintrag, die Größe überschreitet max nie', () => { + const lru = neuerLru({ max: 1, ttlMs: 1000 }) + lru.setze('a', 1, 0) + lru.setze('b', 2, 0) + assert.equal(lru.groesse(), 1) + assert.equal(lru.hole('a', 0), undefined) + assert.equal(lru.hole('b', 0), 2) + const viele = neuerLru({ max: 50, ttlMs: 1000 }) + for (let i = 0; i < 500; i += 1) viele.setze(`k${i}`, i, i) + assert.equal(viele.groesse(), 50) + assert.equal(viele.hole('k499', 500), 499) + assert.equal(viele.hole('k449', 500), undefined) + assert.equal(viele.hole('k450', 500), 450) +}) + +test('LRU: ungültige Angaben werfen', () => { + for (const max of [0, -1, 1.5, '3', null, undefined, Number.NaN, Infinity]) { + assert.throws(() => neuerLru({ max, ttlMs: 1000 }), TypeError, `max ${String(max)}`) + } + for (const ttlMs of [0, -5, '1000', null, undefined, Number.NaN, Infinity]) { + assert.throws(() => neuerLru({ max: 3, ttlMs }), TypeError, `ttlMs ${String(ttlMs)}`) + } + assert.throws(() => neuerLru(), TypeError) + const lru = neuerLru({ max: 3, ttlMs: 1000 }) + for (const jetzt of [undefined, null, '5', Number.NaN, new Date()]) { + assert.throws(() => lru.hole('a', jetzt), TypeError, `hole nowMs ${String(jetzt)}`) + assert.throws(() => lru.setze('a', 1, jetzt), TypeError, `setze nowMs ${String(jetzt)}`) + } +}) + +test('LRU: Instanzen teilen nichts', () => { + const a = neuerLru({ max: 2, ttlMs: 1000 }) + const b = neuerLru({ max: 2, ttlMs: 1000 }) + a.setze('k', 1, 0) + assert.equal(b.hole('k', 0), undefined) + assert.equal(b.groesse(), 0) +}) + +// ---------------------------------------------------------------- cache.js + +beforeEach(() => resetZeitenCacheForTests()) + +test('zeitenCache: 500 Einträge, 30 Sekunden', () => { + for (let i = 0; i < 501; i += 1) zeitenCache.setze(`k${i}`, i, 0) + assert.equal(zeitenCache.groesse(), 500) + assert.equal(zeitenCache.hole('k0', 0), undefined, 'der älteste ist verdrängt') + assert.equal(zeitenCache.hole('k1', 0), 1) + assert.equal(zeitenCache.hole('k500', 29999), 500) + assert.equal(zeitenCache.hole('k500', 30000), undefined) +}) + +test('vergissZeiten entfernt nur den Eintrag dieses siteKeys', () => { + zeitenCache.setze('abcdabcdabcdabcd', { aktiv: true }, 0) + zeitenCache.setze('wxyzwxyzwxyzwxyz', { aktiv: true }, 0) + vergissZeiten('abcdabcdabcdabcd') + assert.equal(zeitenCache.hole('abcdabcdabcdabcd', 1), undefined) + assert.deepEqual(zeitenCache.hole('wxyzwxyzwxyzwxyz', 1), { aktiv: true }) + assert.doesNotThrow(() => vergissZeiten(undefined)) + assert.doesNotThrow(() => vergissZeiten('unbekannt')) +}) + +test('resetZeitenCacheForTests leert alles', () => { + zeitenCache.setze('a', 1, 0) + zeitenCache.setze('b', 2, 0) + resetZeitenCacheForTests() + assert.equal(zeitenCache.groesse(), 0) +}) diff --git a/test/zeitenSpeicher.test.js b/test/zeitenSpeicher.test.js new file mode 100644 index 0000000..6b538e8 --- /dev/null +++ b/test/zeitenSpeicher.test.js @@ -0,0 +1,524 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Speicher der Oeffnungszeiten (server/services/zeiten/speicher.js): Dokument je Funktion (ID = ID des portalFeatures- +// Dokuments), getrennte Versionen fuer Kundendaten (version) und Team-Einstellungen (teamVersion), alles unter der Sperre +// zeiten:. Nachbau von Appwrite mit Schema der beiden Zeiten-Collections und Standard-Limit 25 wie in Produktion. +const aw = await startAppwriteMock({ + schema: { + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: ZEITEN_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 } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { zeitenCache, resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const S = await import('../server/services/zeiten/speicher.js') +const { ladeZeiten, speichereZeiten, speichereTeam, setzeErsatzHash, loescheZeiten, loescheZeitenVonKunde } = S + +const COL = config.collections.oeffnungszeiten +const MERKER = config.collections.zeitenErinnerungen +const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00 +const P1 = 'p_aaaaaaa1' +const P2 = 'p_aaaaaaa2' + +const FEATURE = { + $id: 'f_p_projekt01_zeiten', + customerId: 'c_kunde01', + projectId: 'p_projekt01', + siteKey: 'abcdabcdabcdabcd', + featureKey: 'zeiten', + state: 'active', +} +const ANDERES = { + $id: 'f_p_projekt02_zeiten', + customerId: 'c_kunde02', + projectId: 'p_projekt02', + siteKey: 'wxyzwxyzwxyzwxyz', + featureKey: 'zeiten', + state: 'active', +} + +function tage(woche = {}) { + const t = {} + for (let d = 1; d <= 7; d += 1) { + const w = woche[d] + t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] } + } + return t +} + +function plan(extra = {}) { + return { id: P1, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }), ...extra } +} + +function eingabe(extra = {}) { + return { land: 'RP', plaene: [plan()], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, ...extra } +} + +const dokument = (id = FEATURE.$id) => aw.docs('oeffnungszeiten').get(id) +const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +let log + +beforeEach(async () => { + for (const collection of ['oeffnungszeiten', 'zeitenErinnerungen']) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) + } + aw.resetListCalls() + resetZeitenCacheForTests() + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } +}) +afterEach(() => mock.restoreAll()) + +// ---------------------------------------------------------------- Zeiten speichern + +test('erstes Speichern legt das Dokument mit version 1 an', async () => { + const erg = await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + assert.equal(erg.version, 1) + assert.equal(erg.daten.plaene[0].kurz, 'standard') + assert.equal(erg.daten.plaene[0].tage[1].fenster[0][0], '09:00') + + const doc = dokument() + assert.ok(doc, 'Dokument-ID = ID des portalFeatures-Dokuments') + assert.equal(doc.customerId, FEATURE.customerId) + assert.equal(doc.projectId, FEATURE.projectId) + assert.equal(doc.featureId, FEATURE.$id) + assert.equal(doc.version, 1) + assert.equal(doc.teamVersion, 0) + assert.equal(doc.updatedBy, 'kunde') + assert.equal(new Date(doc.updatedAt).getTime(), JETZT.getTime()) + assert.deepEqual(JSON.parse(doc.daten), erg.daten, 'gespeichert wird genau das normalisierte Ergebnis') +}) + +test('jedes weitere Speichern zählt die Version um eins hoch und ersetzt die Daten', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + const zweite = await speichereZeiten({ + feature: FEATURE, + eingabe: eingabe({ plaene: [plan({ name: 'Neu' })] }), + version: 1, + by: 'team:st_1', + now: JETZT, + }) + assert.equal(zweite.version, 2) + assert.equal(dokument().version, 2) + assert.equal(JSON.parse(dokument().daten).plaene[0].name, 'Neu') + assert.equal(dokument().updatedBy, 'team:st_1') + assert.equal(aw.docs('oeffnungszeiten').size, 1) +}) + +test('ohne Angabe von by steht kunde im Dokument', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, now: JETZT }) + assert.equal(dokument().updatedBy, 'kunde') +}) + +test('veraltete Version ⇒ 409 ZEITEN_VERALTET und nichts wird geschrieben', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Zwei' })] }), version: 1, by: 'kunde', now: JETZT }) + const vorher = JSON.stringify(dokument()) + + for (const version of [0, 1, 3, undefined, null, '2', 2.5, -1]) { + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Drei' })] }), version, by: 'kunde', now: JETZT }), + (err) => err.status === 409 && err.code === 'ZEITEN_VERALTET' && /neu laden/i.test(err.message), + `version ${String(version)}`, + ) + } + assert.equal(JSON.stringify(dokument()), vorher) +}) + +test('erstes Speichern mit version 1 (es gibt noch nichts) ⇒ 409, kein Dokument', async () => { + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 1, by: 'kunde', now: JETZT }), + (err) => err.status === 409 && err.code === 'ZEITEN_VERALTET', + ) + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('Prüffehler ⇒ 400 mit fehler, es wird nichts geschrieben', async () => { + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ land: 'XX' }), version: 0, by: 'kunde', now: JETZT }), + (err) => err.status === 400 && typeof err.fehler?.land === 'string' && err.code !== 'ZEITEN_VERALTET', + ) + assert.equal(aw.docs('oeffnungszeiten').size, 0) + + // auch auf einem vorhandenen Dokument bleibt der Stand unberührt + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + const vorher = JSON.stringify(dokument()) + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [] }), version: 1, by: 'kunde', now: JETZT }), + (err) => err.status === 400 && typeof err.fehler?.plaene === 'string', + ) + assert.equal(JSON.stringify(dokument()), vorher) +}) + +test('Eingabe, die kein Objekt ist ⇒ 400', async () => { + for (const kaputt of [null, undefined, 'x', 5, [], [eingabe()]]) { + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: kaputt, version: 0, by: 'kunde', now: JETZT }), + (err) => err.status === 400 && typeof err.fehler?.daten === 'string', + ) + } + assert.equal(aw.docs('oeffnungszeiten').size, 0) +}) + +test('ist die Version veraltet und die Eingabe falsch, gewinnt 409 (der Kunde lädt ohnehin neu)', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ land: 'XX' }), version: 0, by: 'kunde', now: JETZT }), + (err) => err.status === 409 && err.code === 'ZEITEN_VERALTET', + ) +}) + +test('die gespeicherten Daten gehen als bisher in die Prüfung (Kurznamen bleiben)', async () => { + const kueche = plan({ id: P1, name: 'Küche', kurz: 'egal' }) + const erste = await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [kueche] }), version: 0, by: 'kunde', now: JETZT }) + assert.equal(erste.daten.plaene[0].kurz, 'kueche') + + const zweite = await speichereZeiten({ + feature: FEATURE, + eingabe: eingabe({ plaene: [plan({ id: P1, name: 'Mittag', kurz: 'x' }), plan({ id: P2, name: 'Küche', haupt: false })] }), + version: 1, + by: 'kunde', + now: JETZT, + }) + assert.equal(zweite.daten.plaene[0].kurz, 'kueche', 'der Kurzname eines vorhandenen Plans ändert sich nie') + assert.equal(zweite.daten.plaene[1].kurz, 'kueche-2', 'und wird auch nicht neu vergeben') +}) + +test('Speichern leert den Cache-Eintrag des siteKeys', async () => { + zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0) + zeitenCache.setze(ANDERES.siteKey, { aktiv: true }, 0) + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined) + assert.deepEqual(zeitenCache.hole(ANDERES.siteKey, 1), { aktiv: true }, 'andere Seiten bleiben im Cache') +}) + +test('scheitert das Schreiben in Appwrite, geht der Fehler durch, nichts ändert sich, der Cache wird trotzdem geleert', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + const vorher = JSON.stringify(dokument()) + zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0) + aw.failNext({ collection: 'oeffnungszeiten', method: 'PATCH', status: 500, message: 'kaputt' }) + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Neu' })] }), version: 1, by: 'kunde', now: JETZT }), + (err) => err.status === 500, + ) + assert.equal(JSON.stringify(dokument()), vorher) + assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined, 'unklar, was in der Datenbank steht') + // danach geht es mit derselben Version weiter + const erg = await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Neu' })] }), version: 1, by: 'kunde', now: JETZT }) + assert.equal(erg.version, 2) +}) + +test('legt ein anderer Vorgang das Dokument zwischen Lesen und Anlegen an (Appwrite 409), ist das ebenfalls veraltet', async () => { + aw.failNext({ collection: 'oeffnungszeiten', method: 'POST', status: 409, message: 'Document with the requested ID already exists.' }) + await assert.rejects( + () => speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }), + (err) => err.status === 409 && err.code === 'ZEITEN_VERALTET', + ) +}) + +test('gleichzeitiges Speichern: genau einer gewinnt', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + const [a, b] = await Promise.allSettled([ + speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Anna' })] }), version: 1, by: 'kunde', now: JETZT }), + speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Bernd' })] }), version: 1, by: 'kunde', now: JETZT }), + ]) + const gewinner = [a, b].filter((r) => r.status === 'fulfilled') + const verlierer = [a, b].filter((r) => r.status === 'rejected') + assert.equal(gewinner.length, 1) + assert.equal(verlierer.length, 1) + assert.equal(verlierer[0].reason.status, 409) + assert.equal(verlierer[0].reason.code, 'ZEITEN_VERALTET') + assert.equal(gewinner[0].value.version, 2) + assert.equal(dokument().version, 2) + assert.deepEqual(JSON.parse(dokument().daten), gewinner[0].value.daten, 'gespeichert ist das, was der Gewinner geschickt hat') +}) + +test('gleichzeitiges erstes Speichern: genau einer legt das Dokument an', async () => { + const ergebnisse = await Promise.allSettled([ + speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Anna' })] }), version: 0, by: 'kunde', now: JETZT }), + speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Bernd' })] }), version: 0, by: 'kunde', now: JETZT }), + speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Cora' })] }), version: 0, by: 'kunde', now: JETZT }), + ]) + assert.equal(ergebnisse.filter((r) => r.status === 'fulfilled').length, 1) + assert.equal(ergebnisse.filter((r) => r.status === 'rejected' && r.reason.code === 'ZEITEN_VERALTET').length, 2) + assert.equal(dokument().version, 1) + assert.equal(aw.docs('oeffnungszeiten').size, 1) +}) + +// ---------------------------------------------------------------- Sperre + +test('alle Schreibzugriffe warten auf die Sperre zeiten:, andere Projekte nicht', async () => { + const schreiber = { + speichereZeiten: () => speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Neu' })] }), version: 1, by: 'kunde', now: JETZT }), + speichereTeam: () => speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf' }, teamVersion: 0, by: 'team:st_1' }), + setzeErsatzHash: () => setzeErsatzHash(FEATURE, 'abc123'), + loescheZeiten: () => loescheZeiten(FEATURE.$id), + loescheZeitenVonKunde: () => loescheZeitenVonKunde(FEATURE.customerId), + } + + for (const [name, starte] of Object.entries(schreiber)) { + // jede Runde beginnt mit genau einem Dokument der Funktion (version 1) + for (const id of [...aw.docs('oeffnungszeiten').keys()]) await deleteDocument('oeffnungszeiten', id) + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + + let freigeben + const gehalten = new Promise((resolve) => { freigeben = resolve }) + const halter = withLocks([`zeiten:${FEATURE.projectId}`], () => gehalten) + let fertig = false + const lauf = starte().finally(() => { fertig = true }) + + try { + // ein anderes Projekt wird nicht aufgehalten + const fremd = await speichereZeiten({ feature: ANDERES, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + assert.equal(fremd.version, 1, name) + + await warte(40) + assert.equal(fertig, false, `${name} muss auf die Sperre warten`) + } finally { + freigeben() // auch bei einem Fehlschlag, sonst bliebe die Sperre fuer alle folgenden Tests belegt + } + await halter + await lauf + assert.equal(fertig, true, name) + } +}) + +// ---------------------------------------------------------------- Team + +test('Team-Einstellungen haben eine eigene Version', async () => { + await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT }) + + const erste = await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: ' #kopf nav ', kontaktleisteAus: true }, teamVersion: 0, by: 'team:st_1' }) + assert.deepEqual(erste, { team: { navSelektor: '#kopf nav', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 1 }) + assert.equal(dokument().teamVersion, 1) + assert.equal(dokument().version, 1, 'die Version der Kundendaten bleibt') + assert.equal(dokument().updatedBy, 'team:st_1') + assert.deepEqual(JSON.parse(dokument().team), erste.team) + + // der Kunde speichert weiter mit seiner Version, ohne dass die Team-Einstellung stört (und umgekehrt) + const kunde = await speichereZeiten({ feature: FEATURE, eingabe: eingabe({ plaene: [plan({ name: 'Neu' })] }), version: 1, by: 'kunde', now: JETZT }) + assert.equal(kunde.version, 2) + assert.equal(dokument().teamVersion, 1) + assert.deepEqual(JSON.parse(dokument().team), erste.team, 'Kundenänderungen lassen das Team-Feld unberührt') + + const zweite = await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: null, hinweisleisteAus: true }, teamVersion: 1, by: 'team:st_2' }) + assert.deepEqual(zweite, { team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: true }, teamVersion: 2 }) + assert.equal(dokument().version, 2) + assert.equal(JSON.parse(dokument().daten).plaene[0].name, 'Neu', 'Team-Änderungen lassen die Kundendaten unberührt') +}) + +test('Team-Einstellungen: veraltete teamVersion ⇒ 409 ZEITEN_VERALTET, Prüffehler ⇒ 400', async () => { + await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#a' }, teamVersion: 0, by: 'team:st_1' }) + const vorher = JSON.stringify(dokument()) + for (const teamVersion of [0, 2, undefined, null, '1']) { + await assert.rejects( + () => speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#b' }, teamVersion, by: 'team:st_1' }), + (err) => err.status === 409 && err.code === 'ZEITEN_VERALTET', + `teamVersion ${String(teamVersion)}`, + ) + } + await assert.rejects( + () => speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '