feat(confidence-engine): use server investigation persistence

This commit is contained in:
2026-09-08 19:21:05 +01:00
parent 6dd447e56a
commit d6df1d210e
15 changed files with 425 additions and 114 deletions
+33 -4
View File
@@ -61,8 +61,14 @@ describe("server investigation persistence", () => {
});
it("lists only RLS-visible persistence records", async () => {
const snapshot = {
scenario: "A situation",
updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
investigationReport: { generatedFromRevision: 2 },
};
const { client, query } = makeClient({
data: [{ id: "investigation-1", created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
data: [{ id: "investigation-1", snapshot, created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
error: null,
});
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
@@ -71,11 +77,34 @@ describe("server investigation persistence", () => {
await expect(listInvestigations()).resolves.toEqual([{
id: "investigation-1",
createdAt: "2026-09-08T00:00:00Z",
updatedAt: "2026-09-08T01:00:00Z",
scenario: "A situation",
updatedAt: "2026-09-08T00:30:00Z",
investigationRevision: 3,
reportExists: true,
reportGeneratedFromRevision: 2,
}]);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.select).toHaveBeenCalledWith("id, created_at, updated_at");
expect(query.select).toHaveBeenCalledWith("id, snapshot, created_at, updated_at");
expect(query.order).toHaveBeenCalledWith("updated_at", { ascending: false });
});
it("restarts an owned snapshot through the server path using the established transformation", async () => {
const snapshot = {
id: "investigation-1", scenario: "A situation", situationGraph: {}, selectedQuestion: "Question",
summary: "Summary", focusedContributions: [{}], findings: [{}], investigationReport: {}, investigationRevision: 4,
};
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { restartInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await restartInvestigation("investigation-1");
expect(query.upsert).toHaveBeenCalledWith(expect.objectContaining({
id: "investigation-1", user_id: "trusted-user", snapshot: expect.objectContaining({
id: "investigation-1", scenario: "A situation", situationGraph: null, selectedQuestion: null,
summary: null, focusedContributions: [], findings: [], investigationReport: null, investigationRevision: 0,
}),
}), { onConflict: "id" });
});
});
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const save = vi.fn();
const load = vi.fn();
const list = vi.fn();
const restart = vi.fn();
vi.mock("@/lib/storage/providers/server-http.js", () => ({
saveInvestigation: (...args) => save(...args),
loadInvestigation: (...args) => load(...args),
listInvestigations: (...args) => list(...args),
restartInvestigation: (...args) => restart(...args),
}));
function deferred() {
let resolve;
const promise = new Promise((next) => { resolve = next; });
return { promise, resolve };
}
describe("server-authoritative investigation storage seam", () => {
beforeEach(() => {
vi.clearAllMocks();
save.mockResolvedValue(null);
});
it("uses only the server provider for async load and list", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
load.mockResolvedValue({ id: "inv-1" });
list.mockResolvedValue([{ id: "inv-1" }]);
await expect(storage.loadInvestigation("inv-1")).resolves.toEqual({ id: "inv-1" });
await expect(storage.listInvestigations()).resolves.toEqual([{ id: "inv-1" }]);
expect(load).toHaveBeenCalledWith("inv-1");
expect(list).toHaveBeenCalledTimes(1);
});
it("allows only one in-flight save per investigation and coalesces rapid pending saves to the latest snapshot", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const one = storage.saveInvestigation({ id: "inv-1", revision: 1 });
const two = storage.saveInvestigation({ id: "inv-1", revision: 2 });
const three = storage.saveInvestigation({ id: "inv-1", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ id: "inv-1", revision: 1 }, "inv-1");
first.resolve({ id: "inv-1", revision: 1 });
await Promise.resolve();
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith({ id: "inv-1", revision: 3 }, "inv-1");
second.resolve({ id: "inv-1", revision: 3 });
await expect(Promise.all([one, two, three])).resolves.toEqual([
{ id: "inv-1", revision: 1 },
{ id: "inv-1", revision: 3 },
{ id: "inv-1", revision: 3 },
]);
});
it("does not permit an older request to complete after a newer request becomes durable", async () => {
const storage = await import("@/lib/storage/investigation-storage.js");
const first = deferred();
const second = deferred();
save.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const older = storage.saveInvestigation({ id: "inv-2", revision: 2 });
const newer = storage.saveInvestigation({ id: "inv-2", revision: 3 });
expect(save).toHaveBeenCalledTimes(1);
first.resolve({ id: "inv-2", revision: 2 });
await Promise.resolve();
expect(save).toHaveBeenLastCalledWith({ id: "inv-2", revision: 3 }, "inv-2");
second.resolve({ id: "inv-2", revision: 3 });
await Promise.all([older, newer]);
expect(save).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as provider from "@/lib/storage/providers/server-http.js";
afterEach(() => vi.unstubAllGlobals());
describe("server HTTP investigation provider", () => {
it("maps save, load, list, and restart to authenticated investigation API paths", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ investigations: [] }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ snapshot: { id: "inv/a" } }) });
vi.stubGlobal("fetch", fetch);
await expect(provider.saveInvestigation({ id: "inv/a" }, "inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.loadInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
await expect(provider.listInvestigations()).resolves.toEqual([]);
await expect(provider.restartInvestigation("inv/a")).resolves.toEqual({ id: "inv/a" });
expect(fetch).toHaveBeenNthCalledWith(1, "/api/investigations", expect.objectContaining({
method: "POST",
body: JSON.stringify({ id: "inv/a", snapshot: { id: "inv/a" } }),
}));
expect(fetch).toHaveBeenNthCalledWith(2, "/api/investigations/inv%2Fa");
expect(fetch).toHaveBeenNthCalledWith(3, "/api/investigations", undefined);
expect(fetch).toHaveBeenNthCalledWith(4, "/api/investigations/inv%2Fa/restart", { method: "POST" });
});
it("returns null for a missing investigation (HTTP 404) rather than throwing", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "Investigation not found" }) }));
await expect(provider.loadInvestigation("inv/missing")).resolves.toBeNull();
});
it("surfaces API failures rather than returning an empty result", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: "Unauthorized" }) }));
await expect(provider.listInvestigations()).rejects.toThrow("Unauthorized");
});
});