feat(FN-3166): remove dashboard roadmap backend and add reports plugin scaf

Completes the FN-3166 multi-step removal of the legacy dashboard roadmap feature (backend routes, frontend API wrappers, nav state), along with FN-3792 deep-link URL support, and initial reports plugin scaffold with WhatsApp dedupe retention pruning.

Fusion-Task-Id: FN-3166
This commit is contained in:
Fusion
2026-05-08 23:41:46 -07:00
committed by gsxdsm
parent de070db4a5
commit 9d1c05a7d9
20 changed files with 44 additions and 3196 deletions

View File

@@ -536,7 +536,6 @@ function AppInner() {
settingsLoaded,
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,
@@ -1386,7 +1385,6 @@ function AppInner() {
isRemote={isRemote}
experimentalFeatures={{
insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,
@@ -1492,7 +1490,6 @@ function AppInner() {
showSkillsTab={skillsEnabled}
experimentalFeatures={{
insights: insightsEnabled,
roadmap: roadmapEnabled,
memoryView: memoryEnabled,
devServer: devServerEnabled,
devServerView: devServerEnabled,

View File

@@ -244,392 +244,6 @@ function mockSchedulingFetchResponse(
} as unknown as Response);
}
describe("Roadmap API wrappers", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve([]),
text: () => Promise.resolve("[]"),
} as unknown as Response);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const mockRoadmap = {
id: "RM-001",
title: "Q2 Roadmap",
description: "Q2 product roadmap",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const mockRoadmapHierarchy = {
...mockRoadmap,
milestones: [
{
id: "RMS-001",
roadmapId: "RM-001",
title: "Milestone 1",
description: "First milestone",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
features: [
{
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
description: "First feature",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
},
],
};
it("fetchRoadmaps sends GET and propagates projectId", async () => {
const { fetchRoadmaps } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve([mockRoadmap]),
text: () => Promise.resolve(JSON.stringify([mockRoadmap])),
} as unknown as Response);
const result = await fetchRoadmaps("proj_abc");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
});
it("createRoadmap sends POST with input payload", async () => {
const { createRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockRoadmap),
text: () => Promise.resolve(JSON.stringify(mockRoadmap)),
} as unknown as Response);
const result = await createRoadmap({ title: "Q2 Roadmap", description: "Q2 product roadmap" }, "proj_abc");
expect(result.id).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.title).toBe("Q2 Roadmap");
});
it("fetchRoadmap returns roadmap with hierarchy", async () => {
const { fetchRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockRoadmapHierarchy),
text: () => Promise.resolve(JSON.stringify(mockRoadmapHierarchy)),
} as unknown as Response);
const result = await fetchRoadmap("RM-001");
expect(result.id).toBe("RM-001");
expect(result.milestones).toHaveLength(1);
expect(result.milestones[0].features).toHaveLength(1);
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
});
it("updateRoadmap sends PATCH with updates", async () => {
const { updateRoadmap } = await import("../api");
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedRoadmap),
text: () => Promise.resolve(JSON.stringify(updatedRoadmap)),
} as unknown as Response);
const result = await updateRoadmap("RM-001", { title: "Updated Roadmap" });
expect(result.title).toBe("Updated Roadmap");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmap sends DELETE and returns void", async () => {
const { deleteRoadmap } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmap("RM-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("createRoadmapMilestone sends POST with milestone input", async () => {
const { createRoadmapMilestone } = await import("../api");
const mockMilestone = {
id: "RMS-001",
roadmapId: "RM-001",
title: "Milestone 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockMilestone),
text: () => Promise.resolve(JSON.stringify(mockMilestone)),
} as unknown as Response);
const result = await createRoadmapMilestone("RM-001", { title: "Milestone 1" });
expect(result.id).toBe("RMS-001");
expect(result.roadmapId).toBe("RM-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
});
it("updateRoadmapMilestone sends PATCH", async () => {
const { updateRoadmapMilestone } = await import("../api");
const updatedMilestone = {
id: "RMS-001",
roadmapId: "RM-001",
title: "Updated Milestone",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedMilestone),
text: () => Promise.resolve(JSON.stringify(updatedMilestone)),
} as unknown as Response);
const result = await updateRoadmapMilestone("RMS-001", { title: "Updated Milestone" });
expect(result.title).toBe("Updated Milestone");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmapMilestone sends DELETE", async () => {
const { deleteRoadmapMilestone } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmapMilestone("RMS-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("createRoadmapFeature sends POST with feature input", async () => {
const { createRoadmapFeature } = await import("../api");
const mockFeature = {
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockFeature),
text: () => Promise.resolve(JSON.stringify(mockFeature)),
} as unknown as Response);
const result = await createRoadmapFeature("RMS-001", { title: "Feature 1" });
expect(result.id).toBe("RF-001");
expect(result.milestoneId).toBe("RMS-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
});
it("updateRoadmapFeature sends PATCH", async () => {
const { updateRoadmapFeature } = await import("../api");
const updatedFeature = {
id: "RF-001",
milestoneId: "RMS-001",
title: "Updated Feature",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(updatedFeature),
text: () => Promise.resolve(JSON.stringify(updatedFeature)),
} as unknown as Response);
const result = await updateRoadmapFeature("RF-001", { title: "Updated Feature" });
expect(result.title).toBe("Updated Feature");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
});
it("deleteRoadmapFeature sends DELETE", async () => {
const { deleteRoadmapFeature } = await import("../api");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? null : null,
},
json: () => Promise.resolve(undefined),
text: () => Promise.resolve(""),
} as unknown as Response);
const result = await deleteRoadmapFeature("RF-001");
expect(result).toBeUndefined();
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
});
it("fetchRoadmapFeatures returns features for a milestone", async () => {
const { fetchRoadmapFeatures } = await import("../api");
const mockFeatures = [
{
id: "RF-001",
milestoneId: "RMS-001",
title: "Feature 1",
orderIndex: 0,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "RF-002",
milestoneId: "RMS-001",
title: "Feature 2",
orderIndex: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(mockFeatures),
text: () => Promise.resolve(JSON.stringify(mockFeatures)),
} as unknown as Response);
const result = await fetchRoadmapFeatures("RMS-001");
expect(result).toHaveLength(2);
expect(result[0].id).toBe("RF-001");
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
});
});
/**
* Settings API wrapper tests for FN-1712 (scope-split settings UX).
* These tests verify the API contract for:
@@ -936,332 +550,8 @@ describe("Settings API wrappers", () => {
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
});
});
describe("roadmap reorder APIs", () => {
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
const { reorderRoadmapMilestones } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-002", "RMS-001", "RMS-003"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedMilestoneIds: ["RMS-002", "RMS-001", "RMS-003"],
});
});
it("reorderRoadmapMilestones includes projectId when provided", async () => {
const { reorderRoadmapMilestones } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-001", "RMS-002"], "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder?projectId=proj_abc");
});
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
const { reorderRoadmapFeatures } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapFeatures("RMS-001", ["RF-002", "RF-001"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedFeatureIds: ["RF-002", "RF-001"],
});
});
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
const { moveRoadmapFeature } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 2);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
targetMilestoneId: "RMS-002",
targetIndex: 2,
});
});
it("moveRoadmapFeature includes projectId when provided", async () => {
const { moveRoadmapFeature } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 0, "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move?projectId=proj_xyz");
});
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] })),
} as unknown as Response);
const result = await generateFeatureSuggestions("RMS-001");
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].title).toBe("Feature 1");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
});
it("generateFeatureSuggestions includes input parameters in body", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
const [, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse((options as RequestInit).body as string);
expect(body.prompt).toBe("Focus on auth");
expect(body.count).toBe(3);
});
it("generateFeatureSuggestions includes projectId when provided", async () => {
const { generateFeatureSuggestions } = await import("../api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve({ suggestions: [] }),
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
} as unknown as Response);
await generateFeatureSuggestions("RMS-001", undefined, "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
expect(url).toContain("projectId=proj_abc");
});
});
describe("roadmap export/handoff APIs", () => {
it("exportRoadmap sends GET to export endpoint", async () => {
const { exportRoadmap } = await import("../api");
const exportData = {
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
milestones: [],
features: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
const result = await exportRoadmap("RM-001");
expect(result.roadmap.id).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
});
it("exportRoadmap includes projectId when provided", async () => {
const { exportRoadmap } = await import("../api");
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
await exportRoadmap("RM-001", "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
expect(url).toContain("projectId=proj_abc");
});
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
const { getRoadmapMissionHandoff } = await import("../api");
const handoffData = {
sourceRoadmapId: "RM-001",
title: "Test Roadmap",
milestones: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapMissionHandoff("RM-001");
expect(result.sourceRoadmapId).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/handoff/mission");
});
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
const { getRoadmapFeatureHandoff } = await import("../api");
const handoffData = {
source: {
roadmapId: "RM-001",
milestoneId: "RMS-001",
featureId: "RF-001",
roadmapTitle: "Test",
milestoneTitle: "Phase 1",
milestoneOrderIndex: 0,
featureOrderIndex: 0,
},
title: "Feature 1",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001");
expect(result.source.featureId).toBe("RF-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
});
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
const { getRoadmapFeatureHandoff } = await import("../api");
const handoffData = {
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
title: "F",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001", "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
expect(url).toContain("projectId=proj_xyz");
});
});
});
describe("Automation API scope forwarding", () => {
const originalFetch = globalThis.fetch;

View File

@@ -94,7 +94,7 @@ describe("tablet header controls", () => {
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
expect(screen.getByTitle("Agents view")).toBeDefined();
// Skills, Roadmaps, Insights are NOT inline (they're in overflow)
// Skills and Insights are NOT inline (they're in overflow)
expect(screen.queryByTitle("Skills view")).toBeNull();
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
expect(screen.queryByTitle("Insights view")).toBeNull();
@@ -106,7 +106,7 @@ describe("tablet header controls", () => {
});
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
@@ -121,7 +121,7 @@ describe("tablet header controls", () => {
});
it("closes overflow menu on tablet after selecting an item", async () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
fireEvent.click(screen.getByTestId("view-overflow-skills"));

View File

@@ -70,21 +70,6 @@ import type {
ProjectNodePathMapping,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion-plugin-examples/roadmap";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
import type { MilestoneValidationTelemetry } from "../components/mission-types";
@@ -7305,223 +7290,6 @@ export async function previewEnrichedDescription(
}
}
// ── Roadmap API ─────────────────────────────────────────────────────────────────
/** Fetch all roadmaps */
export function fetchRoadmaps(projectId?: string): Promise<Roadmap[]> {
return api<Roadmap[]>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId));
}
/** Create a new roadmap */
export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch a single roadmap with full hierarchy (milestones and features) */
export function fetchRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapWithHierarchy> {
return api<RoadmapWithHierarchy>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId));
}
/** Update roadmap metadata */
export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise<Roadmap> {
return api<Roadmap>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a roadmap */
export function deleteRoadmap(roadmapId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch milestones for a roadmap */
export function fetchRoadmapMilestones(roadmapId: string, projectId?: string): Promise<RoadmapMilestone[]> {
return api<RoadmapMilestone[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId));
}
/** Create a milestone in a roadmap */
export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone metadata */
export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise<RoadmapMilestone> {
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a milestone */
export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "DELETE",
});
}
/** Fetch features for a milestone */
export function fetchRoadmapFeatures(milestoneId: string, projectId?: string): Promise<RoadmapFeature[]> {
return api<RoadmapFeature[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId));
}
/** Create a feature in a milestone */
export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature metadata */
export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise<RoadmapFeature> {
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a feature */
export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
method: "DELETE",
});
}
/** Reorder milestones within a roadmap */
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedMilestoneIds }),
});
}
/** Reorder features within a milestone */
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedFeatureIds }),
});
}
/** Move a feature to a different milestone or position */
export function moveRoadmapFeature(
featureId: string,
targetMilestoneId: string,
targetIndex: number,
projectId?: string
): Promise<void> {
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), {
method: "POST",
body: JSON.stringify({ targetMilestoneId, targetIndex }),
});
}
/** Export a roadmap as a flat bundle for persistence/import/export */
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
return api<RoadmapExportBundle>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId));
}
/** Get mission planning handoff payload for a roadmap */
export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise<RoadmapMissionPlanningHandoff> {
return api<RoadmapMissionPlanningHandoff>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId));
}
/** Get task planning handoff payload for a single roadmap feature */
export function getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
projectId?: string
): Promise<RoadmapFeatureTaskPlanningHandoff> {
return api<RoadmapFeatureTaskPlanningHandoff>(
withProjectId(
`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
projectId
)
);
}
/** Combined handoff response type for roadmap handoff endpoint */
export interface RoadmapHandoffResponse {
mission: RoadmapMissionPlanningHandoff;
features: RoadmapFeatureTaskPlanningHandoff[];
}
/** Get both mission and feature handoff payloads for a roadmap */
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
return api<RoadmapHandoffResponse>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Generate milestone suggestions from a goal prompt */
export function generateMilestoneSuggestions(
roadmapId: string,
goalPrompt: string,
count?: number,
projectId?: string
): Promise<MilestoneSuggestionsResponse> {
return api<MilestoneSuggestionsResponse>(
withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
{
method: "POST",
body: JSON.stringify({
goalPrompt: goalPrompt.trim(),
...(count !== undefined ? { count } : {}),
}),
}
);
}
/** Response type for feature suggestions */
export interface FeatureSuggestionsResponse {
suggestions: Array<{
title: string;
description?: string;
}>;
}
/** Input for generating feature suggestions */
export interface GenerateFeatureSuggestionsInput {
/** Optional prompt to guide feature generation */
prompt?: string;
/** Number of features to generate (default 5, max 10) */
count?: number;
}
/** Generate feature suggestions for a milestone */
export function generateFeatureSuggestions(
milestoneId: string,
input?: GenerateFeatureSuggestionsInput,
projectId?: string
): Promise<FeatureSuggestionsResponse> {
return api<FeatureSuggestionsResponse>(
withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
{
method: "POST",
body: JSON.stringify({
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
...(input?.count !== undefined ? { count: input.count } : {}),
}),
}
);
}
// ── Todo API ─────────────────────────────────────────────────────────────────
/** Fetch all todo lists with their items */

View File

@@ -237,7 +237,7 @@ export interface HeaderProps {
/** Whether the current view is a remote node */
isRemote?: boolean;
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
pluginDashboardViews?: PluginDashboardViewEntry[];
shellConnectionControl?: ReactNode;
}
@@ -358,11 +358,6 @@ export function Header({
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
}, [overflowScripts]);
const hasRoadmapsPluginView = useMemo(
() => pluginDashboardViews.some((entry) => entry.pluginId === "roadmap-planner"),
[pluginDashboardViews],
);
const hasViewOverflowItems = useMemo(() => {
return !!(
onChangeView ||
@@ -376,7 +371,7 @@ export function Header({
!hideFullNav ||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
);
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews, hasRoadmapsPluginView]);
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
const getEffectiveViewport = useCallback(() => {
const vv = window.visualViewport;

View File

@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
/** Experimental feature flags controlling visibility of nav items. */
experimentalFeatures?: {
insights?: boolean;
roadmap?: boolean;
memoryView?: boolean;
devServer?: boolean;
devServerView?: boolean;

View File

@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
};
@@ -2025,7 +2025,7 @@ describe("App view switching", () => {
// Override the default mock to exclude agentsView
vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings,
experimentalFeatures: { insights: true, roadmap: true, skillsView: true }, // no agentsView
experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
});
render(<App />);
@@ -2201,7 +2201,7 @@ describe("App view switching", () => {
// Keep at least one overflow item enabled so the overflow trigger still renders.
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { insights: false, roadmap: true },
experimentalFeatures: { insights: false },
});
render(<App />);

View File

@@ -226,7 +226,7 @@ describe("Header", () => {
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
renderHeader({
onChangeView: noop,
experimentalFeatures: { roadmap: true },
experimentalFeatures: {},
pluginDashboardViews: [
{
pluginId: "fusion-plugin-roadmap",
@@ -253,7 +253,7 @@ describe("Header", () => {
renderHeader({
onChangeView: noop,
showSkillsTab: false,
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, researchView: false },
experimentalFeatures: { insights: false, memoryView: false, devServerView: false, researchView: false },
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));

View File

@@ -70,13 +70,13 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("does not render legacy roadmaps tab when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
it("does not render legacy roadmaps tab", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
});
it("keeps skills available without rendering legacy roadmaps destinations", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{}} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
@@ -86,7 +86,7 @@ describe("MobileNavBar", () => {
});
it("keeps skills top-level regardless of legacy roadmaps view value", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{}} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
@@ -387,8 +387,8 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("does not show legacy roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
it("does not show legacy roadmaps in more sheet", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
});
@@ -397,7 +397,7 @@ describe("MobileNavBar", () => {
render(
<MobileNavBar
{...createDefaultProps()}
experimentalFeatures={{ roadmap: true }}
experimentalFeatures={{}}
pluginDashboardViews={[
{
pluginId: "roadmap-planner",

View File

@@ -17,7 +17,6 @@ export interface UseAppSettingsResult {
settingsLoaded: boolean;
experimentalFeatures: Record<string, boolean>;
insightsEnabled: boolean;
roadmapEnabled: boolean;
memoryEnabled: boolean;
devServerEnabled: boolean;
todosEnabled: boolean;
@@ -46,7 +45,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
const [insightsEnabled, setInsightsEnabled] = useState(false);
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
const [memoryEnabled, setMemoryEnabled] = useState(false);
const [devServerEnabled, setDevServerEnabled] = useState(false);
const [todosEnabled, setTodosEnabled] = useState(false);
@@ -78,7 +76,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
setInsightsEnabled(features.insights === true);
setRoadmapEnabled(features.roadmap === true);
setMemoryEnabled(features.memoryView === true);
setDevServerEnabled(features.devServerView === true || features.devServer === true);
setTodosEnabled(features.todoView === true);
@@ -95,7 +92,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setSettingsLoaded(false);
setExperimentalFeatures({});
setInsightsEnabled(false);
setRoadmapEnabled(false);
setMemoryEnabled(false);
setDevServerEnabled(false);
setTodosEnabled(false);
@@ -177,7 +173,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
settingsLoaded,
experimentalFeatures,
insightsEnabled,
roadmapEnabled,
memoryEnabled,
devServerEnabled,
todosEnabled,

View File

@@ -1,612 +1,21 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { ApiError } from "../api-error.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion-plugin-examples/roadmap";
import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
// vi.mock is hoisted
vi.mock("@fusion-plugin-examples/roadmap/roadmap-suggestions", () => {
// Define error classes inside the factory - these will be used by the mocked module
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
return {
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateSuggestionInput: vi.fn(),
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
validateFeatureSuggestionInput: vi.fn(),
ValidationError: MockValidationError,
ParseError: MockParseError,
ServiceUnavailableError: MockServiceUnavailableError,
SUGGESTION_TIMEOUT_MS: 120_000,
};
});
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("../project-store-resolver.js", () => ({
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
}));
function createMockRoadmapStore(): RoadmapStore {
const roadmaps = new Map<string, Roadmap>();
const milestones = new Map<string, RoadmapMilestone>();
const features = new Map<string, RoadmapFeature>();
return {
createRoadmap: vi.fn((input: { title: string; description?: string }) => {
const id = "RM-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now };
roadmaps.set(id, roadmap);
return roadmap;
}),
getRoadmap: vi.fn((id: string) => roadmaps.get(id)),
listRoadmaps: vi.fn(() => Array.from(roadmaps.values())),
updateRoadmap: vi.fn((id: string, updates: Partial<Roadmap>) => {
const roadmap = roadmaps.get(id);
if (!roadmap) throw new Error("Roadmap " + id + " not found");
const updated = { ...roadmap, ...updates, updatedAt: new Date().toISOString() };
roadmaps.set(id, updated);
return updated;
}),
deleteRoadmap: vi.fn((id: string) => { roadmaps.delete(id); }),
createMilestone: vi.fn((roadmapId: string, input: { title: string; description?: string }) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const id = "RMS-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingMilestones = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId);
const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0;
const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
milestones.set(id, milestone);
return milestone;
}),
getMilestone: vi.fn((id: string) => milestones.get(id)),
listMilestones: vi.fn((roadmapId: string) => Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateMilestone: vi.fn((id: string, updates: Partial<RoadmapMilestone>) => {
const milestone = milestones.get(id);
if (!milestone) throw new Error("Milestone " + id + " not found");
const updated = { ...milestone, ...updates, updatedAt: new Date().toISOString() };
milestones.set(id, updated);
return updated;
}),
deleteMilestone: vi.fn((id: string) => { milestones.delete(id); }),
createFeature: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
const milestone = milestones.get(milestoneId);
if (!milestone) throw new Error("Milestone " + milestoneId + " not found");
const id = "RF-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
const now = new Date().toISOString();
const existingFeatures = Array.from(features.values()).filter((f) => f.milestoneId === milestoneId);
const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0;
const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
features.set(id, feature);
return feature;
}),
getFeature: vi.fn((id: string) => features.get(id)),
listFeatures: vi.fn((milestoneId: string) => Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex)),
updateFeature: vi.fn((id: string, updates: Partial<RoadmapFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error("Feature " + id + " not found");
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
deleteFeature: vi.fn((id: string) => { features.delete(id); }),
reorderMilestones: vi.fn((input: { roadmapId: string; orderedMilestoneIds: string[] }) => {
const { roadmapId, orderedMilestoneIds } = input;
orderedMilestoneIds.forEach((id, index) => {
const milestone = milestones.get(id);
if (milestone) milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
reorderFeatures: vi.fn((input: { roadmapId: string; milestoneId: string; orderedFeatureIds: string[] }) => {
const { milestoneId, orderedFeatureIds } = input;
orderedFeatureIds.forEach((id, index) => {
const feature = features.get(id);
if (feature) features.set(id, { ...feature, orderIndex: index, updatedAt: new Date().toISOString() });
});
return Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex);
}),
moveFeature: vi.fn((input: { roadmapId: string; featureId: string; fromMilestoneId: string; toMilestoneId: string; targetOrderIndex: number }) => {
const { featureId, toMilestoneId, targetOrderIndex } = input;
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
const updated: RoadmapFeature = { ...feature, milestoneId: toMilestoneId, orderIndex: targetOrderIndex, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return { movedFeature: updated, sourceMilestoneFeatures: [], targetMilestoneFeatures: [] };
}),
getMilestoneWithFeatures: vi.fn((id: string) => {
const milestone = milestones.get(id);
if (!milestone) return undefined;
return { ...milestone, features: [] };
}),
getRoadmapWithHierarchy: vi.fn((id: string) => {
const roadmap = roadmaps.get(id);
if (!roadmap) return undefined;
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === id).sort((a, b) => a.orderIndex - b.orderIndex);
return { ...roadmap, milestones: ms.map((m) => ({ ...m, features: [] })) };
}),
getRoadmapExport: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const allFeatures = ms.flatMap((m) => Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex));
return { roadmap, milestones: ms, features: allFeatures };
}),
getRoadmapMissionHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
getRoadmapFeatureHandoff: vi.fn((roadmapId: string, milestoneId: string, featureId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const milestone = milestones.get(milestoneId);
if (!milestone) throw new ApiError(500, "Milestone " + milestoneId + " not found");
if (milestone.roadmapId !== roadmapId) throw new ApiError(500, "Milestone " + milestoneId + " does not belong to roadmap " + roadmapId);
const feature = features.get(featureId);
if (!feature) throw new ApiError(500, "Feature " + featureId + " not found");
if (feature.milestoneId !== milestoneId) throw new ApiError(500, "Feature " + featureId + " does not belong to milestone " + milestoneId);
return {
source: {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
},
title: feature.title,
description: feature.description,
};
}),
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const handoffs = [];
for (const m of ms) {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
for (const f of fs) {
handoffs.push({
source: {
roadmapId: roadmap.id,
milestoneId: m.id,
featureId: f.id,
roadmapTitle: roadmap.title,
milestoneTitle: m.title,
milestoneOrderIndex: m.orderIndex,
featureOrderIndex: f.orderIndex,
},
title: f.title,
description: f.description,
});
}
}
return handoffs;
}),
} as unknown as RoadmapStore;
}
describe("Roadmap Routes", () => {
let app: express.Express;
let mockStore: { getRoadmapStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
let mockRoadmapStore: ReturnType<typeof createMockRoadmapStore>;
beforeEach(() => {
mockRoadmapStore = createMockRoadmapStore();
mockStore = {
getRoadmapStore: vi.fn(() => mockRoadmapStore),
getRootDir: vi.fn(() => "/test/root"),
};
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
app = express();
app.use(express.json());
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
// Add error handler for tests that check 404 responses
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("GET /api/roadmaps", () => {
it("returns empty list when no roadmaps exist", async () => {
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
describe("integrated roadmap routes removed", () => {
it("does not register a legacy /roadmaps mount", () => {
const router = express.Router();
registerIntegratedRouters({
router,
store: {} as never,
});
it("returns all roadmaps", async () => {
mockRoadmapStore.createRoadmap({ title: "Roadmap 1" });
mockRoadmapStore.createRoadmap({ title: "Roadmap 2" });
const response = await performGet(app, "/api/roadmaps");
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
});
});
const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
?.map((layer) => layer.regexp?.source ?? "")
?? [];
describe("POST /api/roadmaps", () => {
it("creates a new roadmap", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "New Roadmap" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Roadmap");
});
it("returns 400 when title is missing", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is empty", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is whitespace-only", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: " " }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title exceeds 200 characters", async () => {
const longTitle = "A".repeat(201);
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: longTitle }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("200 characters");
});
});
describe("GET /api/roadmaps/:roadmapId", () => {
it("returns roadmap with hierarchy", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Milestone 1" });
mockRoadmapStore.createFeature(milestone.id, { title: "Feature 1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(200);
expect(response.body.title).toBe("Test Roadmap");
expect(response.body.milestones).toHaveLength(1);
});
});
describe("PATCH /api/roadmaps/:roadmapId", () => {
it("updates roadmap title", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Original Title" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/" + roadmap.id, JSON.stringify({ title: "Updated Title" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated Title");
});
});
describe("DELETE /api/roadmaps/:roadmapId", () => {
it("deletes a roadmap", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/" + roadmap.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones", () => {
it("creates a milestone with auto orderIndex", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones", JSON.stringify({ title: "New Milestone" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.roadmapId).toBe(roadmap.id);
expect(response.body.orderIndex).toBe(0);
});
});
describe("POST /api/roadmaps/:roadmapId/milestones/reorder", () => {
it("reorders milestones", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const m1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "First" });
const m2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Second" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
expect(response.status).toBe(204);
});
it("returns 400 when orderedMilestoneIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedMilestoneIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: ["id1", 123, "id3"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
it("updates a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/milestones/" + milestone.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/milestones/:milestoneId", () => {
it("deletes a milestone", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/milestones/" + milestone.id);
expect(response.status).toBe(204);
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features", () => {
it("creates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({ title: "New Feature" }), { "Content-Type": "application/json" });
expect(response.status).toBe(201);
expect(response.body.title).toBe("New Feature");
});
it("returns 400 when title is missing", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features/reorder", () => {
it("returns 400 when orderedFeatureIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedFeatureIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: [123, "id2"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
});
describe("PATCH /api/roadmaps/features/:featureId", () => {
it("updates a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Original" });
const response = await performRequest(app, "PATCH", "/api/roadmaps/features/" + feature.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.title).toBe("Updated");
});
});
describe("DELETE /api/roadmaps/features/:featureId", () => {
it("deletes a feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "To Delete" });
const response = await performRequest(app, "DELETE", "/api/roadmaps/features/" + feature.id);
expect(response.status).toBe(204);
});
});
describe("projectId scoping", () => {
it("ignores projectId query param in legacy adapter", async () => {
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
});
});
describe("GET /api/roadmaps/:roadmapId/export", () => {
it("returns export bundle with all entities", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Export Test", description: "Test desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "F1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/export");
expect(response.status).toBe(200);
expect(response.body.roadmap.id).toBe(roadmap.id);
expect(response.body.roadmap.title).toBe("Export Test");
expect(response.body.milestones.length).toBe(1);
expect(response.body.features.length).toBe(1);
expect(response.body.features[0].id).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
it("returns both mission and feature handoffs", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
// Verify mission handoff structure
expect(response.body.mission).toBeDefined();
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.mission.title).toBe("Combined Handoff");
expect(response.body.mission.milestones).toHaveLength(2);
// Verify feature handoffs structure
expect(response.body.features).toBeDefined();
expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].title).toBe("Feature A");
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
expect(response.body.features[1].title).toBe("Feature B");
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
});
it("returns empty features array when roadmap has no features", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
expect(response.body.features).toHaveLength(0);
});
it("returns 404 when roadmap not found", async () => {
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
expect(response.status).toBe(404);
});
it("returns 404 for cross-project isolation", async () => {
// Create roadmap in default store
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
// Mock a different project store that returns no roadmap
mockGetOrCreateProjectStore.mockResolvedValueOnce({
getRoadmapStore: vi.fn(() => ({
getMissionPlanningHandoff: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
listFeatureTaskPlanningHandoffs: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
})),
getRootDir: vi.fn(() => "/test/root"),
});
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
expect(response.status).toBe(404);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff/mission");
expect(response.status).toBe(200);
expect(response.body.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.title).toBe("Mission Handoff");
expect(response.body.description).toBe("Mission desc");
expect(response.body.milestones.length).toBe(1);
expect(response.body.milestones[0].sourceMilestoneId).toBe(milestone.id);
expect(response.body.milestones[0].features.length).toBe(1);
expect(response.body.milestones[0].features[0].sourceFeatureId).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", () => {
it("returns task handoff payload for feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Feature Handoff" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A", description: "Feature desc" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/milestones/" + milestone.id + "/features/" + feature.id + "/handoff/task");
expect(response.status).toBe(200);
expect(response.body.source.roadmapId).toBe(roadmap.id);
expect(response.body.source.milestoneId).toBe(milestone.id);
expect(response.body.source.featureId).toBe(feature.id);
expect(response.body.source.roadmapTitle).toBe("Feature Handoff");
expect(response.body.source.milestoneTitle).toBe("Phase 1");
expect(response.body.title).toBe("Feature A");
expect(response.body.description).toBe("Feature desc");
});
});
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("AI service is not available");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
it("returns 503 when AI is unavailable", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const response = await performRequest(
app,
"POST",
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
JSON.stringify({ count: 5 }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(503);
expect(response.body.error).toContain("AI service is not available");
});
expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@ import {
} from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const ROADMAP_PLUGIN_ROUTE_NAMESPACE = "roadmap-planner";
// PluginRunner interface for optional plugin runner
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
@@ -535,8 +533,7 @@ export function createPluginRouter(
const pluginRoutes = pluginRunner.getPluginRoutes();
for (const { pluginId, route } of pluginRoutes) {
const routePluginId = pluginId === ROADMAP_PLUGIN_ID ? ROADMAP_PLUGIN_ROUTE_NAMESPACE : pluginId;
const fullPath = `/${routePluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
const handler = catchHandler(async (req: Request, res: Response) => {
// Get the plugin context

View File

@@ -1,89 +0,0 @@
import { Router, type Request, type Response } from "express";
import { type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap/server";
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
return (
typeof value === "object"
&& value !== null
&& "status" in value
&& typeof (value as { status?: unknown }).status === "number"
);
}
async function buildContext(store: TaskStore): Promise<PluginContext> {
return {
pluginId: "roadmap-planner",
taskStore: store,
settings: {},
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
emitEvent: () => {},
createAiSession: undefined,
};
}
export function createRoadmapRouter(store: TaskStore): Router {
const router = Router();
const routes = createRoadmapPluginRoutes();
for (const route of routes) {
const handler = async (req: Request, res: Response) => {
const result = await route.handler(req, await buildContext(store));
if (isRouteResponse(result)) {
if (result.status === 204) {
res.status(204).send();
return;
}
if (result.body === undefined) {
res.status(result.status).send();
return;
}
res.status(result.status).json(result.body);
return;
}
res.status(200).json(result);
};
registerRoute(router, route, handler, normalizeLegacyRoadmapPath(route.path));
}
return router;
}
function normalizeLegacyRoadmapPath(path: string): string {
if (path === "/roadmaps") return "/";
if (path.startsWith("/roadmaps/")) return path.slice("/roadmaps".length);
return path;
}
function registerRoute(
router: Router,
route: PluginRouteDefinition,
handler: (req: Request, res: Response) => Promise<void>,
normalizedPath: string,
): void {
switch (route.method) {
case "GET":
router.get(normalizedPath, handler);
break;
case "POST":
router.post(normalizedPath, handler);
break;
case "PUT":
router.put(normalizedPath, handler);
break;
case "PATCH":
router.patch(normalizedPath, handler);
break;
case "DELETE":
router.delete(normalizedPath, handler);
break;
}
}
export { createRoadmapPluginRoutes };

View File

@@ -1,15 +0,0 @@
export {
FEATURE_SUGGESTION_SYSTEM_PROMPT,
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
ParseError,
ServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
ValidationError,
__resetSuggestionState,
__setCreateAiSessionFactory,
__setCreateFnAgent,
generateFeatureSuggestions,
generateMilestoneSuggestions,
validateFeatureSuggestionInput,
validateSuggestionInput,
} from "@fusion-plugin-examples/roadmap/roadmap-suggestions";

View File

@@ -3131,7 +3131,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Keep this call at the current position to preserve precedence with
// surrounding route handlers. registerIntegratedRouters() mounts:
// - /missions
// - /roadmaps
// - /insights
// - /todos
registerIntegratedRouters({

View File

@@ -135,8 +135,9 @@ Integrated routers are mounted through `register-integrated-routers.ts` and inte
- `registerIntegratedRouters(...)` mounts:
- `createMissionRouter``/api/missions`
- `createRoadmapRouter``/api/roadmaps`
- `createInsightsRouter``/api/insights`
- `createTodoRouter``/api/todos`
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/roadmap-planner/...`.
- `registerIntegratedDevServerRouter(...)` mounts:
- `createDevServerRouter``/api/dev-server`

View File

@@ -6,7 +6,6 @@ import { createInsightsRouter } from "../insights-routes.js";
import { createEvalsRouter } from "../evals-routes.js";
import { createResearchRouter } from "../research-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -37,7 +36,6 @@ export function registerIntegratedRouters({
router.use("/evals", createEvalsRouter(store));
router.use("/research", createResearchRouter(store));
router.use("/todos", createTodoRouter(store));
router.use("/roadmaps", createRoadmapRouter(store));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {