refactor(confidence-engine): add investigation storage provider
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user