feat(FN-698): add per-project fallback for /api/activity-feed route

- Add dual-source fallback: try CentralCore first, fall back to store.getActivityLog()
- Adapt per-project ActivityLogEntry[] to ActivityFeedEntry[] format with projectId="local"
- Handle CentralCore init failures gracefully without returning 500
- Add comprehensive tests covering central data, empty state, init failure, and query params
- Add JSDoc documenting the fallback strategy and data source priority
This commit is contained in:
gsxdsm
2026-04-01 09:41:48 -07:00
parent 185368aac7
commit 1654c91fb5
2 changed files with 192 additions and 6 deletions

View File

@@ -83,6 +83,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()), getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]), getAgentLogs: vi.fn().mockResolvedValue([]),
getActivityLog: vi.fn().mockResolvedValue([]),
addComment: vi.fn(), addComment: vi.fn(),
addTaskComment: vi.fn(), addTaskComment: vi.fn(),
updateTaskComment: vi.fn(), updateTaskComment: vi.fn(),
@@ -6129,3 +6130,143 @@ describe("POST /workflow-step-templates/:id/create", () => {
expect(res.body.error).toContain("already exists"); expect(res.body.error).toContain("already exists");
}); });
}); });
// ── Activity Feed Tests ─────────────────────────────────────────────
describe("GET /api/activity-feed", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns empty array when both central and per-project activities are empty", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty array
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns central activity when available", async () => {
const store = createMockStore();
store.getActivityLog.mockResolvedValue([]);
const centralEntries = [
{
id: "central-1",
timestamp: "2026-04-01T11:00:00.000Z",
type: "task:moved" as const,
projectId: "proj-123",
projectName: "Test Project",
taskId: "KB-002",
details: "Moved task to done",
},
];
// Override the module-level CentralCore mock to return data
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue(centralEntries),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual(centralEntries);
// Should not call per-project activity when central data exists
expect(store.getActivityLog).not.toHaveBeenCalled();
});
it("handles CentralCore initialization failure gracefully", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to throw error on init
const mockCentralInstance = {
init: vi.fn().mockRejectedValue(new Error("CentralCore init failed")),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn(),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(res.body).toEqual([]); // Fallback returns empty array from mock store
});
it("passes through limit query parameter", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed?limit=25");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("handles type filter query parameter", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed?types=task:moved");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("validates fallback route path exists", async () => {
const store = createMockStore();
// Override the module-level CentralCore mock to return empty - this ensures fallback path is taken
const mockCentralInstance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getRecentActivity: vi.fn().mockResolvedValue([]),
};
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
const app = buildApp(store);
const res = await REQUEST(app, "GET", "/api/activity-feed");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
});

View File

@@ -6666,6 +6666,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/** /**
* GET /api/activity-feed * GET /api/activity-feed
* Get unified activity feed across all projects. * Get unified activity feed across all projects.
* Falls back to per-project activity when central activity is empty or unavailable.
*
* Fallback Strategy:
* 1. First attempts to read from CentralCore.getRecentActivity() (multi-project mode)
* 2. If central returns empty array OR CentralCore init fails, falls back to store.getActivityLog()
* 3. Per-project entries are adapted to ActivityFeedEntry format with projectId="local"
* 4. This ensures single-project dashboards show activity even without engine ProjectManager
*
* Query: limit, projectId, types * Query: limit, projectId, types
* Returns: ActivityFeedEntry[] * Returns: ActivityFeedEntry[]
*/ */
@@ -6676,14 +6684,51 @@ Output ONLY the prompt text (no markdown, no explanations).`;
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
const { CentralCore } = await import("@fusion/core"); let centralEntries: any[] = [];
const central = new CentralCore(); let centralError: Error | null = null;
await central.init();
const entries = await central.getRecentActivity({ limit, projectId, types }); // Try to get central activity data first
await central.close(); try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
centralEntries = await central.getRecentActivity({ limit, projectId, types });
await central.close();
} catch (err) {
centralError = err as Error;
}
res.json(entries); // If central data exists and is not empty, return it
if (centralEntries.length > 0) {
res.json(centralEntries);
return;
}
// Fall back to per-project activity data
const options: { limit?: number; type?: import("@fusion/core").ActivityEventType } = { limit };
if (types && types.length === 1) {
options.type = types[0];
}
const perProjectEntries = await store.getActivityLog(options);
// Convert per-project ActivityLogEntry[] to ActivityFeedEntry[] format
const projectName = (store as any).rootDir ?
require("path").basename((store as any).rootDir) : "Current Project";
const feedEntries = perProjectEntries.map((entry) => ({
id: entry.id,
timestamp: entry.timestamp,
type: entry.type,
projectId: "local",
projectName: projectName,
taskId: entry.taskId,
taskTitle: entry.taskTitle,
details: entry.details,
metadata: entry.metadata,
}));
res.json(feedEntries);
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }