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()),
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user