import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ladeSitzung, listRecoveryPoints, saveSession, setzeSpeicherUmgebung, } from '@/services/storage'; import { createEmptyProject } from '@/domain/model/factory'; /** * Befund 8 zur Fassung 5.9.0. * * `uebernehmeAltbestand` entschied allein danach, OB in IndexedDB unter dem * Sitzungsschluessel etwas steht. Stand dort etwas, wurde der localStorage-Stand * nicht uebernommen und anschliessend - ausserhalb des Zweiges, also unbedingt - * geloescht. Der Kommentar begruendete das mit "Liegt in IndexedDB bereits ein * Stand, ist der localStorage-Eintrag der aeltere". * * Diese Annahme trifft nicht zu, sobald IndexedDB einmal ausgefallen ist: * `oeffneDatenbank` liefert bei jedem Oeffnungsfehler `null`, die ganze Sitzung * laeuft dann ueber den Ersatzspeicher, und der dort geschriebene Stand ist der * JUENGERE. Der Uebernahmeweg war damit zugleich der Entsorgungsweg des * laufenden Ersatzbetriebs - stumm, ohne ein Wort an den Anwender. * * Zweiter, davon unabhaengiger Teil desselben Befundes: Geprueft wurde nur der * Sitzungsschluessel, geloescht wurden Sitzung UND Wiederherstellungspunkte. * Liegen in der Datenbank keine Punkte, verschwanden sie ersatzlos. * * WARUM EINE EIGENE NACHBILDUNG: Die Pruefung laeuft in Node; dort gibt es * weder IndexedDB noch localStorage, und 'fake-indexeddb' liegt nicht in * node_modules. Nachgebildet ist genau das, was der Sitzungsspeicher benutzt. */ // --- Nachbildung von IndexedDB --------------------------------------------- type Lager = Map; interface Anfrage { result: T; onsuccess: (() => void) | null; onerror: (() => void) | null; } class Bestand { readonly lager = new Map(); version = 0; /** Laesst das Oeffnen scheitern - privater Modus, gesperrte Datenbank. */ oeffnenSchlaegtFehl = false; } class Nachbildung { readonly bestand = new Bestand(); fabrik(): IDBFactory { const bestand = this.bestand; return { open: (_name: string, version?: number): unknown => { const anfrage: Record = { result: undefined, onupgradeneeded: null, onsuccess: null, onerror: null, onblocked: null, }; queueMicrotask(() => { if (bestand.oeffnenSchlaegtFehl) { (anfrage['onerror'] as (() => void) | null)?.(); return; } anfrage['result'] = new NachbildungDatenbank(bestand); if (version !== undefined && bestand.version < version) { bestand.version = version; (anfrage['onupgradeneeded'] as (() => void) | null)?.(); } (anfrage['onsuccess'] as (() => void) | null)?.(); }); return anfrage; }, } as unknown as IDBFactory; } } class NachbildungDatenbank { onversionchange: (() => void) | null = null; constructor(private readonly bestand: Bestand) {} get objectStoreNames(): { contains: (name: string) => boolean } { return { contains: (name: string) => this.bestand.lager.has(name) }; } createObjectStore(name: string): unknown { this.bestand.lager.set(name, new Map()); return {}; } transaction(namen: string | string[]): NachbildungTransaktion { return new NachbildungTransaktion(this.bestand, typeof namen === 'string' ? [namen] : namen); } close(): void { /* nichts zu tun */ } } class NachbildungTransaktion { error: Error | null = null; oncomplete: (() => void) | null = null; onerror: (() => void) | null = null; onabort: (() => void) | null = null; /** Arbeitskopie: erst beim Abschluss wird sie in den Bestand uebernommen. */ readonly entwurf = new Map(); private readonly auftraege: (() => void)[] = []; private geplant = false; private beendet = false; constructor( private readonly bestand: Bestand, namen: readonly string[], ) { for (const name of namen) { const vorhanden = bestand.lager.get(name); if (vorhanden === undefined) throw new Error(`Unbekanntes Lager: ${name}`); this.entwurf.set(name, new Map(vorhanden)); } this.pumpe(); } objectStore(name: string): NachbildungLager { return new NachbildungLager(this, name); } inhalt(name: string): Lager { return this.entwurf.get(name) ?? new Map(); } plane(auftrag: () => void): void { if (this.beendet) throw new Error('Die Transaktion ist bereits beendet.'); this.auftraege.push(auftrag); this.pumpe(); } abort(): void { if (this.beendet) return; this.beendet = true; this.error = new Error('Die Transaktion wurde abgebrochen.'); this.entwurf.clear(); this.onabort?.(); } private pumpe(): void { if (this.geplant || this.beendet) return; this.geplant = true; queueMicrotask(() => { this.geplant = false; if (this.beendet) return; const auftrag = this.auftraege.shift(); if (auftrag === undefined) { this.beendet = true; for (const [name, inhalt] of this.entwurf) this.bestand.lager.set(name, inhalt); this.oncomplete?.(); return; } auftrag(); this.pumpe(); }); } } class NachbildungLager { constructor( private readonly tx: NachbildungTransaktion, private readonly name: string, ) {} get(schluessel: IDBValidKey): Anfrage { return this.plane(() => this.tx.inhalt(this.name).get(schluessel)); } getAllKeys(): Anfrage { return this.plane(() => [...this.tx.inhalt(this.name).keys()]); } put(wert: unknown, schluessel: IDBValidKey): Anfrage { return this.plane(() => { this.tx.inhalt(this.name).set(schluessel, wert); return schluessel; }); } delete(schluessel: IDBValidKey): Anfrage { return this.plane(() => { this.tx.inhalt(this.name).delete(schluessel); return undefined; }); } private plane(arbeit: () => T): Anfrage { const anfrage: Anfrage = { result: undefined as T, onsuccess: null, onerror: null }; this.tx.plane(() => { anfrage.result = arbeit(); anfrage.onsuccess?.(); }); return anfrage; } } // --- Nachbildung von localStorage ------------------------------------------ function nachbildungSpeicher(): Storage { const daten = new Map(); return { get length(): number { return daten.size; }, clear: (): void => { daten.clear(); }, getItem: (schluessel: string): string | null => daten.get(schluessel) ?? null, key: (index: number): string | null => [...daten.keys()][index] ?? null, removeItem: (schluessel: string): void => { daten.delete(schluessel); }, setItem: (schluessel: string, wert: string): void => { daten.set(schluessel, String(wert)); }, }; } // --- Geruest ---------------------------------------------------------------- const KEY_SESSION = 'lsa-planer.v5.session'; const KEY_RECOVERY = 'lsa-planer.v5.recovery'; let nachbildung: Nachbildung; let speicher: Storage; /** Ein Programmstart auf demselben Datenbestand. */ function sitzungBeginnt(): void { setzeSpeicherUmgebung({ indexedDB: nachbildung.fabrik(), localStorage: speicher }); } beforeEach(() => { // Nur die Uhr wird gestellt: Der Sitzungsstand traegt seinen Zeitpunkt aus // `new Date()`, und ohne feste Uhr laegen beide Staende in derselben // Millisekunde. Zeitgeber und Mikroauftraege bleiben echt - die Nachbildung // der Datenbank haengt an `queueMicrotask`. vi.useFakeTimers({ toFake: ['Date'] }); nachbildung = new Nachbildung(); speicher = nachbildungSpeicher(); sitzungBeginnt(); }); afterEach(() => { setzeSpeicherUmgebung(null); vi.useRealTimers(); }); // --- Pruefungen ------------------------------------------------------------- describe('Ein im Ersatzspeicher entstandener Sitzungsstand', () => { it('wird uebernommen, weil er juenger ist als der in der Datenbank', async () => { // Sitzung A: Die Datenbank laeuft. vi.setSystemTime(new Date('2026-09-01T08:00:00.000Z')); expect((await saveSession(createEmptyProject('Knotenpunkt X'))).ok).toBe(true); // Sitzung B: Das Oeffnen scheitert einmal - die ganze Sitzung laeuft im // Ersatzspeicher. Der Anwender arbeitet zwei Stunden. nachbildung.bestand.oeffnenSchlaegtFehl = true; sitzungBeginnt(); vi.setSystemTime(new Date('2026-09-01T10:00:00.000Z')); expect((await saveSession(createEmptyProject('Knotenpunkt Y'))).ok).toBe(true); // Sitzung C: Die Datenbank laeuft wieder. nachbildung.bestand.oeffnenSchlaegtFehl = false; sitzungBeginnt(); vi.setSystemTime(new Date('2026-09-01T12:00:00.000Z')); const befund = await ladeSitzung(); expect(befund.art).toBe('geladen'); expect( befund.art === 'geladen' ? befund.stand.project.meta.name : '', 'Die Arbeit der Ersatzsitzung wurde verworfen', ).toBe('Knotenpunkt Y'); }); it('bleibt greifbar, wenn der Stand in der Datenbank der juengere ist', async () => { // Umgekehrte Lage: In der Datenbank steht der juengere Stand. vi.setSystemTime(new Date('2026-09-01T10:00:00.000Z')); await saveSession(createEmptyProject('Juengerer Stand')); // Neuer Start; im Ersatzspeicher liegt ein aelterer Eintrag. sitzungBeginnt(); speicher.setItem( KEY_SESSION, JSON.stringify({ gespeichertAm: '2026-09-01T08:00:00.000Z', projekt: createEmptyProject('Aelterer Ersatzstand'), }), ); const befund = await ladeSitzung(); expect(befund.art === 'geladen' ? befund.stand.project.meta.name : '').toBe('Juengerer Stand'); // Nicht uebernommen heisst beiseitegelegt, nicht geloescht - derselbe // Grundsatz wie bei einem beschaedigten Satz. expect(speicher.getItem(KEY_SESSION)).toBeNull(); expect( speicher.getItem(`${KEY_SESSION}.beiseite`), 'Der nicht uebernommene Stand wurde ersatzlos geloescht', ).not.toBeNull(); }); }); describe('Wiederherstellungspunkte im Ersatzspeicher', () => { it('werden nicht mitgeloescht, nur weil ein Sitzungsstand in der Datenbank liegt', async () => { vi.setSystemTime(new Date('2026-09-01T08:00:00.000Z')); await saveSession(createEmptyProject('Stand in der Datenbank')); // In der Datenbank liegen KEINE Punkte; im Ersatzspeicher schon. sitzungBeginnt(); speicher.setItem( KEY_RECOVERY, JSON.stringify([ { savedAt: '2026-09-01T07:30:00.000Z', projectName: 'Nur im Ersatzspeicher', project: createEmptyProject('Nur im Ersatzspeicher'), }, ]), ); const punkte = await listRecoveryPoints(); expect( punkte.map((p) => p.projectName), 'Die Punkte wurden geloescht, obwohl es in der Datenbank keinen Ersatz gab', ).toEqual(['Nur im Ersatzspeicher']); }); });