/** * Focused provider-level contract tests for investigation-storage. * * Deterministic in-memory mocks — no React, no Playwright, no LLM calls. */ import { describe, it, expect, vi } from "vitest"; // ── test-time storage mock (injected into globalThis.window) ─────────── class MockStorageMap { constructor() { this._data = new Map(); this._keys = []; // ordered key list for WebStorage API compatibility } get length() { return this._data.size; } getItem(k) { return this._data.has(k) ? this._data.get(k) : null; } setItem(k, v) { this._data.set(k, String(v)); if (!this._keys.includes(k)) this._keys.push(k); } removeItem(k) { this._data.delete(k); const idx = this._keys.indexOf(k); if (idx !== -1) this._keys.splice(idx, 1); } clear() { this._data.clear(); this._keys.length = 0; } key(n) { return this._keys[n] ?? null; } } function installMockStorage() { // Define window with mock localStorage and sessionStorage as properties Object.defineProperty(globalThis, "window", { value: globalThis.window || {}, writable: true, configurable: true, }); // Always force-install mock storage (previous uninstall may have left window // without own properties but with inherited ones in some environments) Object.defineProperty(globalThis.window, "localStorage", { value: new MockStorageMap(), writable: true, configurable: true, }); Object.defineProperty(globalThis.window, "sessionStorage", { value: new MockStorageMap(), writable: true, configurable: true, }); } function uninstallMockStorage() { const desc = Object.getOwnPropertyDescriptor(globalThis, "window"); if (desc && !("localStorage" in globalThis.window)) return; delete globalThis.window.localStorage; delete globalThis.window.sessionStorage; if (Object.getOwnPropertyNames(globalThis.window).length === 0) { delete globalThis.window; } } // ── lifecycle ────────────────────────────────────────────────────────── beforeEach(() => { installMockStorage(); }); afterEach (() => { uninstallMockStorage(); }); // ── import under test (fresh module each time) ──────────────────────── async function getModule() { // Clear all cached modules to get fresh bindings per test vi.resetModules(); const m = await import("../../lib/storage/providers/local-storage.js"); return { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation }; } // ── shared snapshot factories ────────────────────────────────────────── function makeSnapshot(overrides = {}) { return { schemaVersion: overrides.schemaVersion ?? 1, situationGraph: { nodes: [{ id: "n1", label: "A" }], edges: [] }, openQuestions: ["q1"], assumptions: [], observations: [], uncertainties: [], relationships: [], messages: [], ...overrides, }; } function legacySnapshot() { return { situationGraph: { nodes: [{ id: "old" }], edges: [] }, legacy: true }; } // ════════════════════════════════════════════════════════════════ // Tests // ════════════════════════════════════════════════════════════════ describe("investigation-storage provider", () => { // ── 1. save → load round trip ─────────────────────────────────── it("save then load returns the same snapshot (round-trip)", async () => { const { load, save } = await getModule(); const snap = makeSnapshot({ situationGraph: { nodes: [{ id: "x1" }], edges: [] }, openQuestions: ["why?"] }); save(snap); const loaded = load(); expect(loaded).not.toBeNull(); expect(loaded.situationGraph.nodes[0].id).toBe("x1"); expect(loaded.openQuestions).toEqual(["why?"]); }); // ── 2. schemaVersion = 1 persisted ────────────────────────────── it("persisted record includes schemaVersion = 1", async () => { const { load, save } = await getModule(); const snap = makeSnapshot({ schemaVersion: undefined }); save(snap); const loaded = load(); expect(loaded.schemaVersion).toBe(1); }); // ── 3. caller snapshot object not mutated ─────────────────────── it("save does not mutate the caller's snapshot object", async () => { const { save } = await getModule(); const snap = { nodes: [{ id: "a" }] }; const before = JSON.stringify(snap); save(snap); expect(JSON.stringify(snap)).toBe(before); }); // ── 4. clear → load returns null ──────────────────────────────── it("clear then load returns null", async () => { const { load, save, clear } = await getModule(); save(makeSnapshot()); expect(load()).not.toBeNull(); clear(); expect(load()).toBeNull(); }); // ── 5. malformed localStorage → null, no throw ───────────────── it("malformed localStorage returns null without throwing", async () => { const { load } = await getModule(); globalThis.window.localStorage.setItem( "confidence-engine-investigation", "not valid json {{{" ); expect(() => load()).not.toThrow(); expect(load()).toBeNull(); }); // ── 6. localStorage takes precedence over sessionStorage ──────── it("localStorage value used even when legacy sessionStorage exists", async () => { const { load, save } = await getModule(); const localSnap = makeSnapshot({ source: "local" }); const sessionSnap = legacySnapshot(); // no schemaVersion save(localSnap); globalThis.window.sessionStorage.setItem( "confidence-engine-session", JSON.stringify(sessionSnap) ); const loaded = load(); expect(loaded).not.toBeNull(); expect(loaded.source).toBe("local"); // from localStorage, not sessionStorage }); // ── 7. empty localStorage + valid legacy sessionStorage → legacy returned ── it("empty canonical key returns legacy snapshot when no local state", async () => { const { load, save } = await getModule(); save(makeSnapshot({ source: "local" })); // populate a value globalThis.window.localStorage.clear(); // remove it (canonical key gone) globalThis.window.sessionStorage.setItem( "confidence-engine-session", JSON.stringify(legacySnapshot()) ); const loaded = load(); expect(loaded).not.toBeNull(); expect(loaded.legacy).toBe(true); // came from sessionStorage }); // ── 8. legacy snapshot copied into localStorage ───────────────── it("legacy migration copies snapshot into canonical key", async () => { const { load } = await getModule(); globalThis.window.localStorage.clear(); globalThis.window.sessionStorage.setItem( "confidence-engine-session", JSON.stringify(legacySnapshot()) ); load(); // triggers migration const migrated = globalThis.window.localStorage.getItem("confidence-engine-investigation"); expect(migrated).not.toBeNull(); expect(JSON.parse(migrated).legacy).toBe(true); expect(JSON.parse(migrated).schemaVersion).toBe(1); }); // ── 9. unrelated keys untouched ───────────────────────────────── it("unrelated storage keys remain untouched", async () => { const { load, save } = await getModule(); globalThis.window.localStorage.setItem("unrelated-key", '{"data":42}'); globalThis.window.sessionStorage.setItem("unrelated-session", "hello"); save(makeSnapshot()); load(); expect(globalThis.window.localStorage.getItem("unrelated-key")).toBe('{"data":42}'); expect(globalThis.window.sessionStorage.getItem("unrelated-session")).toBe("hello"); }); // ── 10. non-browser environment safely returns null / no-op ───── it("non-browser environment returns null load, safe no-op save/clear", async () => { uninstallMockStorage(); // removes window entirely const { load, save, clear } = await getModule(); expect(load()).toBeNull(); // no browser → null expect(() => save(makeSnapshot())).not.toThrow(); // no-op without crash expect(() => clear()).not.toThrow(); // no-op without crash installMockStorage(); // restore for remaining tests }); }); // ── v0.60c — identity-aware multi-investigation persistence ──────────── describe("investigation-storage v0.60c identity-aware", () => { function newId() { return `inv-${Math.random().toString(36).slice(2, 9)}`; } beforeEach(() => { globalThis.window.localStorage.clear(); }); // ── A: saveInvestigation(A) persists A under A.id ─────────────── it("save with id persists under that id key", async () => { const { load, save } = await getModule(); const idA = newId(); const snapA = makeSnapshot({ id: idA, title: "Alpha" }); save(snapA, idA); const loaded = load(idA); expect(loaded).not.toBeNull(); expect(loaded.id).toBe(idA); expect(loaded.title).toBe("Alpha"); }); // ── B: saveInvestigation(B) persists B independently under B.id ── it("save two investigations with different ids are independent", async () => { const { load, save } = await getModule(); const idA = newId(); const idB = newId(); const snapA = makeSnapshot({ id: idA, title: "Alpha" }); const snapB = makeSnapshot({ id: idB, title: "Beta" }); save(snapA, idA); save(snapB, idB); // A survives B save const loadedA = load(idA); expect(loadedA).not.toBeNull(); expect(loadedA.id).toBe(idA); expect(loadedA.title).toBe("Alpha"); }); // ── A loads by A.id ────────────────────────────────────────────── it("loadInvestigation(A.id) returns A", async () => { const { load, save } = await getModule(); const idA = newId(); const snapA = makeSnapshot({ id: idA, title: "Alpha" }); save(snapA, idA); const loaded = load(idA); expect(loaded).not.toBeNull(); expect(loaded.id).toBe(idA); expect(loaded.title).toBe("Alpha"); }); // ── B loads by B.id ────────────────────────────────────────────── it("loadInvestigation(B.id) returns B", async () => { const { load, save } = await getModule(); const idA = newId(); const idB = newId(); const snapA = makeSnapshot({ id: idA, title: "Alpha" }); const snapB = makeSnapshot({ id: idB, title: "Beta" }); save(snapA, idA); save(snapB, idB); const loaded = load(idB); expect(loaded).not.toBeNull(); expect(loaded.id).toBe(idB); expect(loaded.title).toBe("Beta"); }); // ── unknown ID returns null ────────────────────────────────────── it("loadInvestigation(unknownId) returns null", async () => { const { load } = await getModule(); const loaded = load("non-existent-id-xyz"); expect(loaded).toBeNull(); }); // ── save does not invent or change the supplied ID ─────────────── it("saveInvestigation does not invent or replace the supplied id", async () => { const { save } = await getModule(); const originalId = `inv-unique-77`; const snapA = makeSnapshot({ id: originalId, title: "Alpha" }); save(snapA, originalId); // Verify the persisted record still carries exactly the supplied id // (the deep clone should not have mutated it) const raw = globalThis.window.localStorage.getItem( `confidence-engine-investigation:${originalId}` ); expect(raw).not.toBeNull(); const parsed = JSON.parse(raw); expect(parsed.id).toBe(originalId); }); }); // ── v0.60d — canonical save identity contract (snapshot.id is sole authority) ─ describe("investigation-storage v0.60d canonical identity", () => { function newId() { return `inv-${Math.random().toString(36).slice(2, 9)}`; } // Import the application-facing wrapper (not the bare provider) async function getWrapper() { vi.resetModules(); const m = await import("../../lib/storage/investigation-storage.js"); return { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation }; } beforeEach(() => { globalThis.window.localStorage.clear(); }); // ── 1. saveInvestigation({ id: "inv-a", ... }) persists under inv-a ── it("save with snapshot.id persists under that id key", async () => { const { save } = await getWrapper(); const snap = makeSnapshot({ id: newId(), title: "Alpha" }); save(snap); const raw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${snap.id}`); expect(raw).not.toBeNull(); }); // ── 2. loadInvestigation("inv-a") returns inv-a ─────────────── it("save with snapshot.id and load by same id round-trips", async () => { const { load, save } = await getWrapper(); const snap = makeSnapshot({ id: newId(), title: "Alpha" }); save(snap); const loaded = load(snap.id); expect(loaded).not.toBeNull(); expect(loaded.id).toBe(snap.id); expect(loaded.title).toBe("Alpha"); }); // ── 3. provider does not allocate/change the ID ──────────────── it("saveInvestigation does not invent or replace snapshot.id", async () => { const { save } = await getWrapper(); const snap = makeSnapshot({ id: "inv-audit-99", title: "Audit" }); save(snap); const raw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${snap.id}`); expect(JSON.parse(raw).id).toBe("inv-audit-99"); }); // ── 4. canonical save has no second competing identity authority ─ it("explicit id argument is ignored when snapshot has an id", async () => { const { load, save } = await getWrapper(); const snap = makeSnapshot({ id: newId(), title: "Alpha" }); const fakeSecondId = "fake-override"; save(snap, fakeSecondId); // Should NOT appear under fakeSecondId — identity comes from snapshot.id only const fakeRaw = globalThis.window.localStorage.getItem(`confidence-engine-investigation:${fakeSecondId}`); expect(fakeRaw).toBeNull(); }); // ── 5. A and B remain independently addressable ──────────────── it("save two identified investigations are independent", async () => { const { load, save } = await getWrapper(); const idA = newId(); const idB = newId(); const snapA = makeSnapshot({ id: idA, title: "Alpha" }); const snapB = makeSnapshot({ id: idB, title: "Beta" }); save(snapA); save(snapB); expect((await load(idA)).id).toBe(idA); expect((await load(idA)).title).toBe("Alpha"); expect((await load(idB)).id).toBe(idB); expect((await load(idB)).title).toBe("Beta"); }); // ── 6. unknown ID remains null ──────────────────────────────── it("loadInvestigation(unknownId) returns null", async () => { const { load } = await getWrapper(); expect(await load("non-existent-id-xyz")).toBeNull(); }); // ── 7. legacy singleton compatibility preserved ─────────────── it("unidentified snapshot falls back to CANONICAL_KEY for legacy callers", async () => { const { load, save } = await getWrapper(); save(makeSnapshot({ legacy: true })); // no id on snapshot const loaded = load(); // no id argument expect(loaded).not.toBeNull(); expect(loaded.legacy).toBe(true); }); }); // ── v0.60g1 — listInvestigations lightweight summaries ─────────────── describe("investigation-storage v0.60g1 listing", () => { function newId() { return `inv-${Math.random().toString(36).slice(2, 9)}`; } async function getWrapper() { vi.resetModules(); const m = await import("../../lib/storage/investigation-storage.js"); return { list: m.listInvestigations, save: m.saveInvestigation, load: m.loadInvestigation, }; } beforeEach(() => { if (globalThis.window && globalThis.window.localStorage) { try { globalThis.window.localStorage.clear(); } catch(e) {} } }); // ── 1. Two durable-ID Investigations coexist ──────────────────── it("listInvestigations returns summaries for two durable-ID Investigations", async () => { const { list, save } = await getWrapper(); save(makeSnapshot({ id: newId(), scenario: "Scenario A" })); save(makeSnapshot({ id: newId(), scenario: "Scenario B" })); const summaries = list(); expect(summaries.length).toBe(2); }); // ── 2. Correct identities ─────────────────────────────────────── it("each summary carries its correct durable ID", async () => { const { list, save } = await getWrapper(); const idA = newId(); const idB = newId(); save(makeSnapshot({ id: idA, scenario: "Scenario A" })); save(makeSnapshot({ id: idB, scenario: "Scenario B" })); const summaries = list(); expect(summaries.map(s => s.id)).toContain(idA); expect(summaries.map(s => s.id)).toContain(idB); }); // ── 3. Lightweight projection ─────────────────────────────────── it("summary contains portfolio fields but not canonical payload", async () => { const { list, save } = await getWrapper(); save(makeSnapshot({ id: newId(), scenario: "Scenario A" })); const summaries = list(); const summary = summaries[0]; // Portfolio-required fields present expect(summary).toHaveProperty("id"); expect(summary).toHaveProperty("scenario"); expect(summary).toHaveProperty("updatedAt"); expect(summary).toHaveProperty("investigationRevision"); expect(summary).toHaveProperty("reportExists"); expect(summary).toHaveProperty("reportGeneratedFromRevision"); // Canonical payload fields NOT exposed expect(summary.situationGraph).toBeUndefined(); expect(summary.findings).toBeUndefined(); expect(summary.investigationReport).toBeUndefined(); }); // ── 4. Legacy singleton exclusion ─────────────────────────────── it("legacy singleton is not included in listing", async () => { const { list } = await getWrapper(); globalThis.window.localStorage.setItem( "confidence-engine-investigation", JSON.stringify(makeSnapshot({ legacy: true })) ); const summaries = list(); expect(summaries.length).toBe(0); }); // ── 5. Unrelated storage exclusion ────────────────────────────── it("unrelated localStorage record is not included", async () => { const { list, save } = await getWrapper(); globalThis.window.localStorage.setItem( "confidence-engine-investigation-metadata", '{"data":42}' ); save(makeSnapshot({ id: newId(), scenario: "Scenario X" })); const summaries = list(); expect(summaries.length).toBe(1); }); // ── 6. Independent update preserves other Investigation ───────── it("updating inv-a does not replace or remove inv-b", async () => { const { list, save } = await getWrapper(); const idA = newId(); const idB = newId(); save(makeSnapshot({ id: idA, scenario: "Scenario A" })); save(makeSnapshot({ id: idB, scenario: "Scenario B" })); // Update inv-a save(makeSnapshot({ id: idA, scenario: "Updated Scenario A" })); const summaries = list(); expect(summaries.length).toBe(2); const aSummary = summaries.find(s => s.id === idA); const bSummary = summaries.find(s => s.id === idB); expect(aSummary.scenario).toBe("Updated Scenario A"); expect(bSummary.scenario).toBe("Scenario B"); }); // ── 7. Deterministic ordering by updatedAt descending ─────────── it("most recently updated Investigation listed first", async () => { const { list, save } = await getWrapper(); const idA = newId(); const idB = newId(); save(makeSnapshot({ id: idA, scenario: "Scenario A", updatedAt: "2026-09-01T10:00:00.000Z", })); save(makeSnapshot({ id: idB, scenario: "Scenario B", updatedAt: "2026-09-02T10:00:00.000Z", })); const summaries = list(); expect(summaries[0].id).toBe(idB); expect(summaries[1].id).toBe(idA); }); // ── 8. Malformed durable-ID entry skipped ────────────────────── it("malformed durable-ID entry does not prevent listing valid records", async () => { globalThis.window.localStorage.setItem( "confidence-engine-investigation:inv-malformed", "not valid json {{{" ); const { list, save } = await getWrapper(); save(makeSnapshot({ id: newId(), scenario: "Valid Scenario" })); const summaries = list(); expect(summaries.length).toBe(1); }); }); // ── v0.60j — restartInvestigation semantic reset ───────────────────── describe("investigation-storage v0.60j restart", () => { function newId() { return `inv-${Math.random().toString(36).slice(2, 9)}`; } async function getWrapper() { vi.resetModules(); const m = await import("../../lib/storage/investigation-storage.js"); return { restart: m.restartInvestigation, load: m.loadInvestigation, list: m.listInvestigations, save: m.saveInvestigation, }; } function makeRestartedSnapshot(overrides = {}) { return { id: overrides.id ?? newId(), scenario: "Test scenario text", situationGraph: { nodes: [{ id: "n1" }], edges: [] }, selectedQuestion: { question: "Why?" }, summary: "Previous understanding", focusedContributions: [{ evidence: "some evidence" }], findings: [{ id: "f1", proposition: "A finding" }], investigationReport: { understanding: "report text", generatedFromRevision: 5 }, investigationRevision: 3, updatedAt: "2026-09-01T10:00:00.000Z", ...overrides, }; } beforeEach(() => { if (globalThis.window && globalThis.window.localStorage) { try { globalThis.window.localStorage.clear(); } catch(e) {} } }); // ── A — restart preserves identity ────────────────────────────── it("restart preserves the Investigation durable id", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-a" }); save(snap); restart("inv-a"); const loaded = load("inv-a"); expect(loaded).not.toBeNull(); expect(loaded.id).toBe("inv-a"); }); // ── B — restart preserves container-required state ────────────── it("restart preserves scenario framing", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-b" }); save(snap); restart("inv-b"); const loaded = load("inv-b"); expect(loaded.scenario).toBe("Test scenario text"); }); it("restart preserves schemaVersion", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-c" }); save(snap); restart("inv-c"); const loaded = load("inv-c"); expect(loaded.schemaVersion).toBe(1); }); // ── C — restart removes previous reasoning/report state ──────── it("restart clears situationGraph", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-d" }); save(snap); restart("inv-d"); const loaded = load("inv-d"); expect(loaded.situationGraph).toBe(null); }); it("restart clears selectedQuestion", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-e" }); save(snap); restart("inv-e"); const loaded = load("inv-e"); expect(loaded.selectedQuestion).toBe(null); }); it("restart clears summary (current understanding)", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-f" }); save(snap); restart("inv-f"); const loaded = load("inv-f"); expect(loaded.summary).toBe(null); }); it("restart clears focusedContributions", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-g" }); save(snap); restart("inv-g"); const loaded = load("inv-g"); expect(loaded.focusedContributions).toEqual([]); }); it("restart clears findings", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-h" }); save(snap); restart("inv-h"); const loaded = load("inv-h"); expect(loaded.findings).toEqual([]); }); it("restart clears investigationReport (stale Report must not survive)", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-i" }); save(snap); restart("inv-i"); const loaded = load("inv-i"); expect(loaded.investigationReport).toBe(null); }); it("restart resets investigationRevision to 0", async () => { const { save, load, restart } = await getWrapper(); const snap = makeRestartedSnapshot({ id: "inv-j" }); save(snap); restart("inv-j"); const loaded = load("inv-j"); expect(loaded.investigationRevision).toBe(0); }); // ── D — restart is isolated ──────────────────────────────────── it("restart A does not affect B", async () => { const { save, load, restart } = await getWrapper(); save(makeRestartedSnapshot({ id: "inv-a" })); save(makeRestartedSnapshot({ id: "inv-b" })); restart("inv-a"); const bLoaded = load("inv-b"); expect(bLoaded.id).toBe("inv-b"); expect(bLoaded.scenario).toBe("Test scenario text"); expect(bLoaded.situationGraph).not.toBeNull(); // B untouched expect(bLoaded.findings.length).toBe(1); // B findings preserved }); // ── E — listing remains after restart ────────────────────────── it("restart does not remove Investigation from listInvestigations", async () => { const { save, load, restart, list } = await getWrapper(); save(makeRestartedSnapshot({ id: "inv-k" })); const before = list(); expect(before.length).toBe(1); restart("inv-k"); const after = list(); expect(after.length).toBe(1); expect(after[0].id).toBe("inv-k"); }); // ── F — missing identity does not restart singleton ──────────── it("restart with undefined id is silently no-op (no singleton fallback)", async () => { const { save, load, restart } = await getWrapper(); // Set up a legacy singleton entry if (globalThis.window && globalThis.window.localStorage) { globalThis.window.localStorage.setItem( "confidence-engine-investigation", JSON.stringify(makeRestartedSnapshot({ id: "legacy-singleton" })) ); } restart(undefined); // should be no-op, not touch singleton // Singleton entry still exists (unchanged) const raw = globalThis.window?.localStorage.getItem("confidence-engine-investigation"); expect(raw).not.toBeNull(); const parsed = JSON.parse(raw); expect(parsed.id).toBe("legacy-singleton"); }); });