Öffnungszeiten: Speicher mit Versionen und Cache
LRU-Speicher mit Ablaufzeit (lru.js), Cache-Instanz der öffentlichen Antwort (cache.js: 500 Einträge, 30 s) und Appwrite-Speicher mit getrennten Versionen für Kundendaten und Team-Einstellungen (speicher.js), alle Schreibzugriffe unter der Sperre zeiten:<projectId>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88fb9263f9
commit
097036c9a5
5 files changed
+1034
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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<string, { bis: number, wert: * }>} */
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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:<projectId>` (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:<staffId>' (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:<staffId>'
|
||||
* @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:<staffId>'
|
||||
* @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<boolean>} 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<number>} 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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:<projectId>. 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:<projectId>, 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: '<script>' }, teamVersion: 1, by: 'team:st_1' }),
|
||||
(err) => err.status === 400 && typeof err.fehler?.navSelektor === 'string',
|
||||
)
|
||||
assert.equal(JSON.stringify(dokument()), vorher)
|
||||
})
|
||||
|
||||
test('Team-Einstellungen vor den ersten Kundenzeiten legen das Dokument mit version 0 an; der Kunde speichert danach mit 0', async () => {
|
||||
const team = await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf' }, teamVersion: 0, by: 'team:st_1' })
|
||||
assert.equal(team.teamVersion, 1)
|
||||
const doc = dokument()
|
||||
assert.equal(doc.version, 0)
|
||||
assert.equal(doc.customerId, FEATURE.customerId)
|
||||
assert.equal(doc.projectId, FEATURE.projectId)
|
||||
assert.equal(doc.featureId, FEATURE.$id)
|
||||
assert.ok(!doc.daten, 'noch keine Kundendaten')
|
||||
|
||||
assert.deepEqual(await ladeZeiten(FEATURE.$id), {
|
||||
daten: null,
|
||||
version: 0,
|
||||
team: team.team,
|
||||
teamVersion: 1,
|
||||
ersatzHash: '',
|
||||
})
|
||||
const kunde = await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT })
|
||||
assert.equal(kunde.version, 1)
|
||||
assert.equal(dokument().teamVersion, 1)
|
||||
assert.equal(JSON.parse(dokument().team).navSelektor, '#kopf')
|
||||
})
|
||||
|
||||
test('Team-Speichern leert den Cache-Eintrag des siteKeys', async () => {
|
||||
zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0)
|
||||
await speichereTeam({ feature: FEATURE, eingabe: {}, teamVersion: 0, by: 'team:st_1' })
|
||||
assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Laden
|
||||
|
||||
test('ladeZeiten: ohne Dokument null', async () => {
|
||||
assert.equal(await ladeZeiten(FEATURE.$id), null)
|
||||
})
|
||||
|
||||
test('ladeZeiten liefert Daten, Version, Team, Team-Version und Ersatz-Hash', async () => {
|
||||
const gespeichert = await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT })
|
||||
assert.deepEqual(await ladeZeiten(FEATURE.$id), {
|
||||
daten: gespeichert.daten,
|
||||
version: 1,
|
||||
team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false },
|
||||
teamVersion: 0,
|
||||
ersatzHash: '',
|
||||
})
|
||||
await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf', hinweisleisteAus: true }, teamVersion: 0, by: 'team:st_1' })
|
||||
await setzeErsatzHash(FEATURE, '0123456789abcdef')
|
||||
assert.deepEqual(await ladeZeiten(FEATURE.$id), {
|
||||
daten: gespeichert.daten,
|
||||
version: 1,
|
||||
team: { navSelektor: '#kopf', kontaktleisteAus: false, hinweisleisteAus: true },
|
||||
teamVersion: 1,
|
||||
ersatzHash: '0123456789abcdef',
|
||||
})
|
||||
})
|
||||
|
||||
test('ladeZeiten: unlesbare Daten ergeben daten null mit der Version des Dokuments und einer Logzeile ohne Inhalt', async () => {
|
||||
aw.seed('oeffnungszeiten', { $id: FEATURE.$id, customerId: FEATURE.customerId, projectId: FEATURE.projectId, featureId: FEATURE.$id, version: 7, daten: '{geheim:kaputt', teamVersion: 2, team: '{"navSelektor":"<script>"}' })
|
||||
const geladen = await ladeZeiten(FEATURE.$id)
|
||||
assert.equal(geladen.daten, null)
|
||||
assert.equal(geladen.version, 7, 'wer die kaputten Daten überschreiben will, speichert mit genau dieser Version')
|
||||
assert.deepEqual(geladen.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, 'ein ungültiges Team-Feld fällt auf die Vorgabe zurück')
|
||||
assert.equal(geladen.teamVersion, 2)
|
||||
const text = log.error.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n')
|
||||
assert.ok(text.includes(FEATURE.$id))
|
||||
assert.ok(!text.includes('geheim') && !text.includes('script'), 'der Inhalt gehört nicht ins Log')
|
||||
|
||||
const erg = await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 7, by: 'kunde', now: JETZT })
|
||||
assert.equal(erg.version, 8)
|
||||
})
|
||||
|
||||
test('ladeZeiten und Schreiber verlangen eine Funktions-ID', async () => {
|
||||
for (const kaputt of ['', undefined, null, 5]) await assert.rejects(() => ladeZeiten(kaputt), TypeError)
|
||||
await assert.rejects(() => speichereZeiten({ feature: { projectId: 'p' }, eingabe: eingabe(), version: 0, now: JETZT }), TypeError)
|
||||
await assert.rejects(() => speichereZeiten({ feature: { $id: 'f_x' }, eingabe: eingabe(), version: 0, now: JETZT }), TypeError)
|
||||
await assert.rejects(() => speichereTeam({ feature: undefined, eingabe: {}, teamVersion: 0 }), TypeError)
|
||||
await assert.rejects(() => setzeErsatzHash(FEATURE, 5), TypeError)
|
||||
await assert.rejects(() => setzeErsatzHash(FEATURE, 'x'.repeat(65)), TypeError)
|
||||
await assert.rejects(() => loescheZeiten(''), TypeError)
|
||||
await assert.rejects(() => loescheZeitenVonKunde(''), TypeError)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Ersatz-Hash
|
||||
|
||||
test('setzeErsatzHash schreibt nur den Hash: Version, Daten und Team bleiben', async () => {
|
||||
await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT })
|
||||
await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf' }, teamVersion: 0, by: 'team:st_1' })
|
||||
const vorher = { ...dokument() }
|
||||
zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0)
|
||||
|
||||
await setzeErsatzHash(FEATURE, 'abcdef0123456789')
|
||||
const nachher = dokument()
|
||||
assert.equal(nachher.ersatzHash, 'abcdef0123456789')
|
||||
for (const feld of ['version', 'daten', 'teamVersion', 'team', 'updatedAt', 'updatedBy', 'customerId', 'projectId', 'featureId']) {
|
||||
assert.deepEqual(nachher[feld], vorher[feld], feld)
|
||||
}
|
||||
assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined)
|
||||
|
||||
await setzeErsatzHash(FEATURE, '')
|
||||
assert.equal((await ladeZeiten(FEATURE.$id)).ersatzHash, '')
|
||||
})
|
||||
|
||||
test('setzeErsatzHash ohne Dokument legt eines ohne Kundendaten an', async () => {
|
||||
await setzeErsatzHash(FEATURE, 'abcdef0123456789')
|
||||
const doc = dokument()
|
||||
assert.equal(doc.ersatzHash, 'abcdef0123456789')
|
||||
assert.equal(doc.version, 0)
|
||||
assert.equal(doc.teamVersion, 0)
|
||||
assert.equal(doc.customerId, FEATURE.customerId)
|
||||
assert.equal((await ladeZeiten(FEATURE.$id)).daten, null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Loeschen
|
||||
|
||||
const merker = (id, featureId, datum = '2026-12-24') => aw.seed('zeitenErinnerungen', { $id: id, featureId, datum, createdAt: '2026-12-01T08:00:00.000Z' })
|
||||
|
||||
function dokumentFuer(kunde, nr) {
|
||||
const id = `f_${kunde}_${nr}`
|
||||
aw.seed('oeffnungszeiten', { $id: id, customerId: kunde, projectId: `p_${kunde}_${nr}`, featureId: id, version: 1, daten: '{}', teamVersion: 0, ersatzHash: '' })
|
||||
return id
|
||||
}
|
||||
|
||||
test('loescheZeiten löscht Dokument und Merker der Funktion, sonst nichts', async () => {
|
||||
await speichereZeiten({ feature: FEATURE, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT })
|
||||
await speichereZeiten({ feature: ANDERES, eingabe: eingabe(), version: 0, by: 'kunde', now: JETZT })
|
||||
for (let i = 0; i < 30; i += 1) merker(`m_a_${i}`, FEATURE.$id, `2026-12-${String(i % 28 + 1).padStart(2, '0')}`)
|
||||
merker('m_b_1', ANDERES.$id)
|
||||
zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0)
|
||||
|
||||
assert.equal(await loescheZeiten(FEATURE.$id), true)
|
||||
assert.equal(dokument(), undefined)
|
||||
assert.ok(dokument(ANDERES.$id))
|
||||
assert.deepEqual([...aw.docs('zeitenErinnerungen').keys()], ['m_b_1'], 'alle 30 Merker der Funktion sind weg (mehr als eine Seite des Standard-Limits)')
|
||||
assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined)
|
||||
assert.equal(await ladeZeiten(FEATURE.$id), null)
|
||||
|
||||
// zweiter Aufruf: nichts mehr da, kein Fehler
|
||||
assert.equal(await loescheZeiten(FEATURE.$id), false)
|
||||
})
|
||||
|
||||
test('loescheZeiten räumt Merker auch auf, wenn es das Dokument schon nicht mehr gibt', async () => {
|
||||
merker('m_1', FEATURE.$id)
|
||||
assert.equal(await loescheZeiten(FEATURE.$id), false)
|
||||
assert.equal(aw.docs('zeitenErinnerungen').size, 0)
|
||||
})
|
||||
|
||||
test('loescheZeitenVonKunde löscht Daten und Merker', async () => {
|
||||
const eigene = [dokumentFuer('c_a', 1), dokumentFuer('c_a', 2), dokumentFuer('c_a', 3)]
|
||||
const fremd = dokumentFuer('c_b', 1)
|
||||
merker('m_a1', eigene[0])
|
||||
merker('m_a2', eigene[0], '2026-12-25')
|
||||
merker('m_a3', eigene[2])
|
||||
merker('m_b1', fremd)
|
||||
zeitenCache.setze(FEATURE.siteKey, { aktiv: true }, 0)
|
||||
|
||||
assert.equal(await loescheZeitenVonKunde('c_a'), 3)
|
||||
assert.deepEqual([...aw.docs('oeffnungszeiten').keys()], [fremd])
|
||||
assert.deepEqual([...aw.docs('zeitenErinnerungen').keys()], ['m_b1'])
|
||||
assert.equal(zeitenCache.hole(FEATURE.siteKey, 1), undefined)
|
||||
|
||||
assert.equal(await loescheZeitenVonKunde('c_a'), 0, 'zweiter Aufruf: nichts mehr da')
|
||||
assert.equal(await loescheZeitenVonKunde('c_unbekannt'), 0)
|
||||
})
|
||||
|
||||
test('loescheZeitenVonKunde blättert durch alle Seiten (mehr als das Standard-Limit)', async () => {
|
||||
const ids = []
|
||||
for (let i = 0; i < 130; i += 1) ids.push(dokumentFuer('c_viel', i))
|
||||
const fremd = dokumentFuer('c_anders', 1)
|
||||
for (let i = 0; i < 130; i += 1) merker(`m_${i}`, ids[i], '2026-12-24')
|
||||
merker('m_fremd', fremd)
|
||||
|
||||
assert.equal(await loescheZeitenVonKunde('c_viel'), 130)
|
||||
assert.deepEqual([...aw.docs('oeffnungszeiten').keys()], [fremd])
|
||||
assert.deepEqual([...aw.docs('zeitenErinnerungen').keys()], ['m_fremd'])
|
||||
assert.ok(aw.listCalls('oeffnungszeiten').length >= 2, 'mehrere Seiten abgefragt')
|
||||
})
|
||||
|
||||
test('loescheZeitenVonKunde: scheitert ein Dokument, werden die anderen trotzdem gelöscht und der Fehler kommt am Ende', async () => {
|
||||
const ids = [dokumentFuer('c_a', 1), dokumentFuer('c_a', 2), dokumentFuer('c_a', 3)]
|
||||
aw.failNext({ collection: 'oeffnungszeiten', method: 'DELETE', status: 500, message: 'kaputt' })
|
||||
await assert.rejects(() => loescheZeitenVonKunde('c_a'), (err) => err.status === 500)
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 1, 'zwei von drei sind weg')
|
||||
assert.equal(await loescheZeitenVonKunde('c_a'), 1, 'ein neuer Versuch räumt den Rest')
|
||||
assert.equal(aw.docs('oeffnungszeiten').size, 0)
|
||||
assert.equal(ids.length, 3)
|
||||
})
|
||||
Reference in new issue
Block a user