Files
fusion/packages/dashboard/src/__tests__/project-store-resolver.test.ts
gsxdsm 356af7c9e6 fix: resolve race condition in project-store-resolver breaking real-time dashboard updates
Concurrent SSE and API requests for the same projectId both missed the
cache (storeCache.set ran after await store.watch()), creating independent
TaskStore instances with separate EventEmitters. SSE listeners attached
to one instance while mutations fired on the other, so no events reached
the browser.

Fix: add a pendingCreations promise map that deduplicates concurrent
calls, ensuring all callers share the same in-flight promise and thus
the same store instance. Also clear pendingCreations in evictProjectStore
and evictAllProjectStores. Adds a concurrent-call regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 07:08:47 -07:00

245 lines
8.7 KiB
TypeScript

/**
* Regression tests for project-scoped real-time SSE delivery (FN-758).
*
* Validates that:
* 1. The shared project-store resolver reuses the same TaskStore instance
* 2. No duplicate watch() calls or listeners are stacked
* 3. Eviction properly cleans up
* 4. SSE streams receive live events from the same store instance used for mutations
*/
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getOrCreateProjectStore,
evictProjectStore,
evictAllProjectStores,
} from "../project-store-resolver.js";
import type { TaskStore } from "@fusion/core";
// Mock @fusion/core to control TaskStore.getOrCreateForProject
const createdStores: Array<{
projectId: string;
store: TaskStore;
watchMock: ReturnType<typeof vi.fn>;
closeMock: ReturnType<typeof vi.fn>;
stopWatchingMock: ReturnType<typeof vi.fn>;
}> = [];
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
TaskStore: {
...actual.TaskStore,
getOrCreateForProject: vi.fn(async (projectId: string): Promise<TaskStore> => {
const store = Object.create(EventEmitter.prototype) as TaskStore;
EventEmitter.call(store as any);
(store as any).setMaxListeners(100);
const watchMock = vi.fn(async () => {});
const closeMock = vi.fn(() => {});
const stopWatchingMock = vi.fn(() => {});
const entry = { projectId, store, watchMock, closeMock, stopWatchingMock };
// Minimal store interface
(store as any).init = vi.fn(async () => {});
(store as any).watch = watchMock;
(store as any).close = closeMock;
(store as any).stopWatching = stopWatchingMock;
(store as any).getMissionStore = vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
}));
createdStores.push(entry);
return store;
}),
},
};
});
describe("project-store-resolver", () => {
beforeEach(() => {
evictAllProjectStores();
createdStores.length = 0;
});
afterEach(() => {
evictAllProjectStores();
createdStores.length = 0;
});
it("creates a new store on first call for a project", async () => {
const store = await getOrCreateProjectStore("proj_abc");
expect(store).toBeDefined();
expect(createdStores).toHaveLength(1);
expect(createdStores[0].projectId).toBe("proj_abc");
});
it("reuses the same TaskStore instance for repeated calls with the same projectId", async () => {
const store1 = await getOrCreateProjectStore("proj_abc");
const store2 = await getOrCreateProjectStore("proj_abc");
const store3 = await getOrCreateProjectStore("proj_abc");
// Same instance returned every time
expect(store1).toBe(store2);
expect(store2).toBe(store3);
// Only one store was created
expect(createdStores).toHaveLength(1);
});
it("deduplicates concurrent calls — concurrent SSE + API route requests share one store", async () => {
// Simulate the race: SSE endpoint and an API mutation both call
// getOrCreateProjectStore before either has set the cache.
const [store1, store2, store3] = await Promise.all([
getOrCreateProjectStore("proj_concurrent"),
getOrCreateProjectStore("proj_concurrent"),
getOrCreateProjectStore("proj_concurrent"),
]);
// All callers must receive the same instance so SSE and mutations share an EventEmitter
expect(store1).toBe(store2);
expect(store2).toBe(store3);
// Only one underlying store should have been created
expect(createdStores).toHaveLength(1);
// watch() called exactly once
expect(createdStores[0].watchMock).toHaveBeenCalledTimes(1);
});
it("creates separate stores for different projectIds", async () => {
const storeA = await getOrCreateProjectStore("proj_alpha");
const storeB = await getOrCreateProjectStore("proj_beta");
expect(storeA).not.toBe(storeB);
expect(createdStores).toHaveLength(2);
expect(createdStores[0].projectId).toBe("proj_alpha");
expect(createdStores[1].projectId).toBe("proj_beta");
});
it("calls watch() exactly once per project store", async () => {
await getOrCreateProjectStore("proj_once");
await getOrCreateProjectStore("proj_once");
await getOrCreateProjectStore("proj_once");
expect(createdStores).toHaveLength(1);
expect(createdStores[0].watchMock).toHaveBeenCalledTimes(1);
});
it("does not stack duplicate watch() calls on repeated lookups", async () => {
for (let i = 0; i < 10; i++) {
await getOrCreateProjectStore("proj_repeat");
}
expect(createdStores).toHaveLength(1);
expect(createdStores[0].watchMock).toHaveBeenCalledTimes(1);
});
it("evictProjectStore stops watching and closes the store", async () => {
await getOrCreateProjectStore("proj_evict");
expect(createdStores).toHaveLength(1);
evictProjectStore("proj_evict");
expect(createdStores[0].stopWatchingMock).toHaveBeenCalledTimes(1);
expect(createdStores[0].closeMock).toHaveBeenCalledTimes(1);
// Next call should create a fresh store
createdStores.length = 0;
await getOrCreateProjectStore("proj_evict");
expect(createdStores).toHaveLength(1);
});
it("evictAllProjectStores cleans up all cached stores", async () => {
await getOrCreateProjectStore("proj_a");
await getOrCreateProjectStore("proj_b");
await getOrCreateProjectStore("proj_c");
expect(createdStores).toHaveLength(3);
evictAllProjectStores();
for (const entry of createdStores) {
expect(entry.stopWatchingMock).toHaveBeenCalledTimes(1);
expect(entry.closeMock).toHaveBeenCalledTimes(1);
}
});
it("evictProjectStore is a no-op for unknown projectIds", () => {
expect(() => evictProjectStore("nonexistent")).not.toThrow();
});
it("shared store receives events from both SSE and API route context", async () => {
const store = await getOrCreateProjectStore("proj_events");
// Simulate an SSE listener attaching to the store's EventEmitter
const events: string[] = [];
store.on("task:created", (task: any) => {
events.push(`created:${task.id}`);
});
store.on("task:updated", (task: any) => {
events.push(`updated:${task.id}`);
});
// Simulate the same store being used for a mutation (like getScopedStore)
const sameStore = await getOrCreateProjectStore("proj_events");
expect(sameStore).toBe(store);
// Emit events from "mutation path" — same store instance
sameStore.emit("task:created", { id: "FN-001" });
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" });
// SSE listener should have received both events through the shared EventEmitter
expect(events).toEqual(["created:FN-001", "updated:FN-001"]);
});
it("SSE stream receives live events via shared store (integration)", async () => {
// This test simulates the full server-side path:
// 1. SSE handler calls getOrCreateProjectStore("proj_sse") → store A
// 2. Task mutation route calls getOrCreateProjectStore("proj_sse") → same store A
// 3. Task mutation emits event on store A
// 4. SSE listener (attached to store A) receives the event
const sseStore = await getOrCreateProjectStore("proj_sse");
// Collect SSE messages (simulates createSSE attaching listeners)
const sseMessages: string[] = [];
sseStore.on("task:created", (task: any) => {
sseMessages.push(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
});
sseStore.on("task:updated", (task: any) => {
sseMessages.push(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
});
sseStore.on("task:moved", (data: any) => {
sseMessages.push(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
});
// Simulate API route handler using the same resolver
const apiStore = await getOrCreateProjectStore("proj_sse");
expect(apiStore).toBe(sseStore);
// Simulate task creation via API (this is what routes.ts does)
const newTask = { id: "FN-100", description: "Integration test task" };
apiStore.emit("task:created", newTask);
// Simulate task update
const updatedTask = { ...newTask, title: "Updated title" };
apiStore.emit("task:updated", updatedTask);
// Simulate task move
apiStore.emit("task:moved", { task: updatedTask, from: "triage", to: "todo" });
// Assert SSE listener received all events in order
expect(sseMessages).toHaveLength(3);
expect(sseMessages[0]).toContain("task:created");
expect(sseMessages[0]).toContain("FN-100");
expect(sseMessages[1]).toContain("task:updated");
expect(sseMessages[1]).toContain("Updated title");
expect(sseMessages[2]).toContain("task:moved");
expect(sseMessages[2]).toContain('"to":"todo"');
});
});