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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
@@ -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({
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user