diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index 8fe8973763..7af5acf58a 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -1222,6 +1222,82 @@ describe("createServer health and headless mode", () => { vi.useRealTimers(); }); + // FNXC:ReliabilityHealth 2026-07-10-11:15: + // FUX-042 regression: reliability GET/reset must read/write the per-project store, not the shared root store. + // Enumerated surfaces: (1) GET with projectId reads project store, (2) GET without projectId falls back to root, (3) POST reset with projectId writes project store. + it("FUX-042: GET /api/health/reliability reads the project-scoped store, not the root store", async () => { + const rootStore = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ reliabilityStatsResetAt: "2026-01-01T00:00:00.000Z" }), + getRunAuditEvents: vi.fn().mockReturnValue([]), + }); + const projectStore = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ reliabilityStatsResetAt: "2026-05-10T00:00:00.000Z" }), + getRunAuditEvents: vi.fn().mockReturnValue([]), + }); + const getEngine = vi.fn((projectId: string) => + projectId === "proj_a" ? { getTaskStore: vi.fn(() => projectStore) } : undefined, + ); + const app = createServer(rootStore, { + engineManager: { getEngine } as unknown as import("@fusion/engine").ProjectEngineManager, + }); + + const res = await GET(app, "/api/health/reliability?projectId=proj_a"); + + expect(res.status).toBe(200); + expect((res.body as { resetAt: string }).resetAt).toBe("2026-05-10T00:00:00.000Z"); + expect(projectStore.getSettings).toHaveBeenCalled(); + expect(projectStore.getRunAuditEvents).toHaveBeenCalled(); + expect(rootStore.getSettings).not.toHaveBeenCalled(); + expect(rootStore.getRunAuditEvents).not.toHaveBeenCalled(); + }); + + it("FUX-042: GET /api/health/reliability without projectId falls back to the root store", async () => { + const rootStore = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ reliabilityStatsResetAt: "2026-01-01T00:00:00.000Z" }), + getRunAuditEvents: vi.fn().mockReturnValue([]), + }); + const projectStore = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ reliabilityStatsResetAt: "2026-05-10T00:00:00.000Z" }), + getRunAuditEvents: vi.fn().mockReturnValue([]), + }); + const getEngine = vi.fn((projectId: string) => + projectId === "proj_a" ? { getTaskStore: vi.fn(() => projectStore) } : undefined, + ); + const app = createServer(rootStore, { + engineManager: { getEngine } as unknown as import("@fusion/engine").ProjectEngineManager, + }); + + const res = await GET(app, "/api/health/reliability"); + + expect(res.status).toBe(200); + expect((res.body as { resetAt: string }).resetAt).toBe("2026-01-01T00:00:00.000Z"); + expect(rootStore.getSettings).toHaveBeenCalled(); + expect(projectStore.getSettings).not.toHaveBeenCalled(); + }); + + it("FUX-042: POST /api/health/reliability/reset writes the project-scoped store, not the root store", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z")); + + const rootStore = createMockStore({ updateSettings: vi.fn().mockResolvedValue({}) }); + const projectStore = createMockStore({ updateSettings: vi.fn().mockResolvedValue({}) }); + const getEngine = vi.fn((projectId: string) => + projectId === "proj_a" ? { getTaskStore: vi.fn(() => projectStore) } : undefined, + ); + const app = createServer(rootStore, { + engineManager: { getEngine } as unknown as import("@fusion/engine").ProjectEngineManager, + }); + + const res = await REQUEST(app, "POST", "/api/health/reliability/reset?projectId=proj_a"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ resetAt: "2026-05-13T12:00:00.000Z" }); + expect(projectStore.updateSettings).toHaveBeenCalledWith({ reliabilityStatsResetAt: "2026-05-13T12:00:00.000Z" }); + expect(rootStore.updateSettings).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + it("rejects invalid windowDays values", async () => { const app = createServer(createMockStore({ getActivityLog: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 2a783ba560..1572eb6844 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1654,7 +1654,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT app.get("/api/health/reliability", async (req, res) => { const projectId = getProjectIdFromRequest(req); - const scopedStore = projectId ? await getScopedStore(projectId) : store; + /* + FNXC:ReliabilityHealth 2026-07-10-11:15: + Reliability GET/reset must read/write the per-project store so multi-project servers report per-project stats. + Use the in-scope resolveProjectScopedStore helper (createServer scope) — NOT the badge-websocket getScopedStore, which lives in a different function and is not visible here. + Store creation can fail (getOrCreateProjectStore throwing on a DB error); mirror the project SSE handler and return a targeted 500 instead of letting the failure fall through to the generic Express error handler with a vague message. + */ + let scopedStore: TaskStore; + try { + scopedStore = await resolveProjectScopedStore(projectId); + } catch (err: unknown) { + sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to resolve project store"); + return; + } const rawWindowDays = req.query.windowDays; const parsedWindowDays = rawWindowDays === undefined ? 7 : Number.parseInt(String(rawWindowDays), 10); @@ -1760,7 +1772,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT app.post("/api/health/reliability/reset", async (req, res) => { const projectId = getProjectIdFromRequest(req); - const scopedStore = projectId ? await getScopedStore(projectId) : store; + /* + FNXC:ReliabilityHealth 2026-07-10-11:15: + Same in-scope resolveProjectScopedStore + guard as the GET handler so the reset writes reliabilityStatsResetAt to the per-project store and a store-creation failure returns a targeted 500 rather than a vague generic error. + */ + let scopedStore: TaskStore; + try { + scopedStore = await resolveProjectScopedStore(projectId); + } catch (err: unknown) { + sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to resolve project store"); + return; + } const resetAt = new Date().toISOString(); await scopedStore.updateSettings({ reliabilityStatsResetAt: resetAt }); res.json({ resetAt });