diff --git a/lib/storage/investigation-storage.js b/lib/storage/investigation-storage.js new file mode 100644 index 0000000..67b6995 --- /dev/null +++ b/lib/storage/investigation-storage.js @@ -0,0 +1,5 @@ +// investigation-storage — generic persistence boundary +// Exposes loadInvestigation / saveInvestigation / clearInvestigation +// and delegates internally to the concrete localStorage provider. + +export { loadInvestigation, saveInvestigation, clearInvestigation } from "./providers/local-storage.js"; diff --git a/lib/storage/providers/local-storage.js b/lib/storage/providers/local-storage.js new file mode 100644 index 0000000..c0a8163 --- /dev/null +++ b/lib/storage/providers/local-storage.js @@ -0,0 +1,111 @@ +// ── canonical identifiers ──────────────────────────────────────────── + +const CANONICAL_KEY = "confidence-engine-investigation"; +const LEGACY_KEY = "confidence-engine-session"; +const SCHEMA_VERSION = 1; + +// ── helpers ────────────────────────────────────────────────────────── + +function safeGet(storage, key) { + if (!storage || typeof storage.getItem !== "function") return null; + try { return storage.getItem(key); } catch (_) { return null; } +} + +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +// ── loadInvestigation ──────────────────────────────────────────────── + +/** + * Returns the persisted investigation snapshot (normalised to current schema) + * or null when no usable state exists. + */ +export function loadInvestigation() { + const storage = _getTargetStorage(); + if (!storage) return null; + + // 1 – canonical key first + let raw = safeGet(storage, CANONICAL_KEY); + if (raw !== null) { + try { + const parsed = JSON.parse(raw); + if (isPlainObject(parsed)) { + if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION; + return parsed; + } + } catch (_) { /* malformed — fall through to legacy */ } + } + + // 2 – legacy fallback + const legacyStorage = _getLegacyStorage(); + if (!legacyStorage) return null; + + raw = safeGet(legacyStorage, LEGACY_KEY); + if (raw === null) return null; + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_) { return null; } + + if (!isPlainObject(parsed)) return null; + + // Normalise schemaVersion for legacy data + if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION; + + // Migrate into canonical key + _persist(storage, CANONICAL_KEY, JSON.stringify(parsed)); + + return parsed; +} + +// ── saveInvestigation ──────────────────────────────────────────────── + +/** + * Persists the supplied snapshot to the canonical localStorage key. + * The caller's object is never mutated. + */ +export function saveInvestigation(snapshot) { + const storage = _getTargetStorage(); + if (!storage) return; // silently no-op in non-browser + + try { + const record = JSON.parse(JSON.stringify(snapshot)); + record.schemaVersion = SCHEMA_VERSION; + _persist(storage, CANONICAL_KEY, JSON.stringify(record)); + } catch (_) { /* storage errors must not crash caller */ } +} + +// ── clearInvestigation ─────────────────────────────────────────────── + +/** Removes only the canonical investigation key. */ +export function clearInvestigation() { + const storage = _getTargetStorage(); + if (!storage) return; + try { storage.removeItem(CANONICAL_KEY); } catch (_) {} +} + +// ── internals ──────────────────────────────────────────────────────── + +function _persist(storage, key, value) { + try { storage.setItem(key, value); } catch (_) {} +} + +function _getTargetStorage() { + if (typeof globalThis.window === "undefined") return null; + const s = globalThis.window.localStorage; + if (!s || typeof s.getItem !== "function") return null; + return s; +} + +function _getLegacyStorage() { + if (typeof globalThis.window === "undefined") return null; + const s = globalThis.window.sessionStorage; + if (!s || typeof s.getItem !== "function") return null; + return s; +} diff --git a/tests/storage/investigation-storage.test.js b/tests/storage/investigation-storage.test.js new file mode 100644 index 0000000..e35a6a7 --- /dev/null +++ b/tests/storage/investigation-storage.test.js @@ -0,0 +1,228 @@ +/** + * 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(); } + getItem(k) { return this._data.has(k) ? this._data.get(k) : null; } + setItem(k, v){ this._data.set(k, String(v)); } + removeItem(k){ this._data.delete(k); } + clear() { this._data.clear(); } +} + +function installMockStorage() { + // Define window with mock localStorage and sessionStorage as properties + Object.defineProperty(globalThis, "window", { + value: globalThis.window || {}, + writable: true, + configurable: true, + }); + if (!globalThis.window.localStorage) { + Object.defineProperty(globalThis.window, "localStorage", { + value: new MockStorageMap(), + writable: true, + configurable: true, + }); + } + if (!globalThis.window.sessionStorage) { + 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 + }); + +});