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:
@@ -83,6 +83,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
addComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
@@ -6129,3 +6130,143 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6666,6 +6666,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
/**
|
||||
* GET /api/activity-feed
|
||||
* 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
|
||||
* 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 types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
|
||||
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
let centralEntries: any[] = [];
|
||||
let centralError: Error | null = null;
|
||||
|
||||
const entries = await central.getRecentActivity({ limit, projectId, types });
|
||||
await central.close();
|
||||
// Try to get central activity data first
|
||||
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) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user