Files
confidence-engine/tests/storage/investigation-storage.test.js
T

818 lines
28 KiB
JavaScript

/**
* 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");
});
});