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: '