feat(FN-1691): add roadmap export and handoff API
- Add RoadmapStore.exportRoadmap() and RoadmapStore.handoffRoadmap() DTO methods with full test coverage - Add REST endpoints POST /api/roadmaps/:id/export and POST /api/roadmaps/:id/handoff in roadmap-routes.ts - Add corresponding api.ts wrappers with request/response type definitions and test coverage - Update architecture docs with roadmap export/handoff endpoint reference
This commit is contained in:
@@ -742,4 +742,205 @@ describe("RoadmapStore", () => {
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export / handoff", () => {
|
||||
describe("getRoadmapExport", () => {
|
||||
it("returns flat export bundle with all entities", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Export Test", description: "Test description" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Milestone 1" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Milestone 2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Feature 1" });
|
||||
const f2 = store.createFeature(m1.id, { title: "Feature 2" });
|
||||
const f3 = store.createFeature(m2.id, { title: "Feature 3" });
|
||||
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.roadmap.id).toBe(roadmap.id);
|
||||
expect(export_.roadmap.title).toBe("Export Test");
|
||||
expect(export_.milestones.length).toBe(2);
|
||||
expect(export_.features.length).toBe(3);
|
||||
expect(export_.features.map((f) => f.id)).toEqual([f1.id, f2.id, f3.id]);
|
||||
});
|
||||
|
||||
it("returns milestones in deterministic order", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Test" });
|
||||
// Create in reverse order to test deterministic sorting
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
|
||||
// m2 was created first (orderIndex 0), m1 was created second (orderIndex 1)
|
||||
// Deterministic order: orderIndex ASC → m2 comes first
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.milestones[0].id).toBe(m2.id);
|
||||
expect(export_.milestones[1].id).toBe(m1.id);
|
||||
});
|
||||
|
||||
it("returns features grouped by milestone in deterministic order", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Features Order" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
// Create in reverse order to test deterministic sorting
|
||||
const f2 = store.createFeature(m1.id, { title: "F2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
|
||||
// f2 was created first (orderIndex 0), f1 was created second (orderIndex 1)
|
||||
// Deterministic order: orderIndex ASC → f2 comes first
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.features.length).toBe(2);
|
||||
expect(export_.features[0].id).toBe(f2.id);
|
||||
expect(export_.features[1].id).toBe(f1.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
expect(() => store.getRoadmapExport("RM-nonexistent")).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("returns empty arrays when roadmap has no milestones", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Empty" });
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.milestones).toEqual([]);
|
||||
expect(export_.features).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoadmapMissionHandoff", () => {
|
||||
it("returns mission planning handoff with source IDs preserved", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Phase 1", description: "Phase 1 desc" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Phase 2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Task A", description: "Task A desc" });
|
||||
const f2 = store.createFeature(m2.id, { title: "Task B" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe(roadmap.id);
|
||||
expect(handoff.title).toBe("Mission Handoff");
|
||||
expect(handoff.description).toBe("Mission desc");
|
||||
expect(handoff.milestones.length).toBe(2);
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe(m1.id);
|
||||
expect(handoff.milestones[0].title).toBe("Phase 1");
|
||||
expect(handoff.milestones[0].description).toBe("Phase 1 desc");
|
||||
expect(handoff.milestones[0].features.length).toBe(1);
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[0].title).toBe("Task A");
|
||||
expect(handoff.milestones[1].features.length).toBe(1);
|
||||
expect(handoff.milestones[1].features[0].sourceFeatureId).toBe(f2.id);
|
||||
});
|
||||
|
||||
it("preserves deterministic ordering in handoff", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Check" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "First" });
|
||||
const f2 = store.createFeature(m1.id, { title: "Second" });
|
||||
const f3 = store.createFeature(m1.id, { title: "Third" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[1].sourceFeatureId).toBe(f2.id);
|
||||
expect(handoff.milestones[0].features[2].sourceFeatureId).toBe(f3.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
expect(() => store.getRoadmapMissionHandoff("RM-nonexistent")).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("includes orderIndex for milestones and features", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Index Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
// Milestones should be in deterministic order (m1 created first, so orderIndex 0)
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe(m1.id);
|
||||
expect(handoff.milestones[0].orderIndex).toBeDefined();
|
||||
expect(handoff.milestones[1].sourceMilestoneId).toBe(m2.id);
|
||||
expect(handoff.milestones[1].orderIndex).toBeDefined();
|
||||
// Features should be in deterministic order
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[0].orderIndex).toBeDefined();
|
||||
expect(handoff.milestones[1].features[0].sourceFeatureId).toBe(f2.id);
|
||||
expect(handoff.milestones[1].features[0].orderIndex).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoadmapFeatureHandoff", () => {
|
||||
it("returns task planning handoff for a feature", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Feature Handoff" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Feature A", description: "Feature A desc" });
|
||||
|
||||
const handoff = store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f1.id);
|
||||
|
||||
expect(handoff.source.roadmapId).toBe(roadmap.id);
|
||||
expect(handoff.source.milestoneId).toBe(m1.id);
|
||||
expect(handoff.source.featureId).toBe(f1.id);
|
||||
expect(handoff.source.roadmapTitle).toBe("Feature Handoff");
|
||||
expect(handoff.source.milestoneTitle).toBe("Phase 1");
|
||||
expect(handoff.source.milestoneOrderIndex).toBeDefined();
|
||||
expect(handoff.source.featureOrderIndex).toBeDefined();
|
||||
expect(handoff.title).toBe("Feature A");
|
||||
expect(handoff.description).toBe("Feature A desc");
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff("RM-nonexistent", m1.id, f1.id)).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws for non-existent milestone", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, "RMS-nonexistent", "RF-nonexistent")).toThrow("Milestone RMS-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws when milestone does not belong to roadmap", () => {
|
||||
const roadmap1 = store.createRoadmap({ title: "Roadmap 1" });
|
||||
const roadmap2 = store.createRoadmap({ title: "Roadmap 2" });
|
||||
const m2 = store.createMilestone(roadmap2.id, { title: "M2" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap1.id, m2.id, f2.id)).toThrow(`Milestone ${m2.id} does not belong to roadmap ${roadmap1.id}`);
|
||||
});
|
||||
|
||||
it("throws for non-existent feature", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, m1.id, "RF-nonexistent")).toThrow("Feature RF-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws when feature does not belong to milestone", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "M2" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f2.id)).toThrow(`Feature ${f2.id} does not belong to milestone ${m1.id}`);
|
||||
});
|
||||
|
||||
it("includes order indices in source reference", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
// M1 is created first so has orderIndex 0, M2 has orderIndex 1
|
||||
const handoff1 = store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f1.id);
|
||||
const handoff2 = store.getRoadmapFeatureHandoff(roadmap.id, m2.id, f2.id);
|
||||
|
||||
// m1 was created first so it has lower orderIndex
|
||||
expect(handoff1.source.milestoneOrderIndex).toBeLessThan(handoff2.source.milestoneOrderIndex);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapMilestoneWithFeatures,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapExportBundle,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
} from "./roadmap-types.js";
|
||||
import {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
@@ -731,4 +735,138 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Export / Handoff Operations ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures: RoadmapFeature[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
allFeatures.push(...features);
|
||||
}
|
||||
|
||||
return {
|
||||
roadmap,
|
||||
milestones,
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestones.map((milestone) => {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: features.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(
|
||||
roadmapId: string,
|
||||
milestoneId: string,
|
||||
featureId: string,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate milestone exists and belongs to roadmap
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
|
||||
// Validate feature exists and belongs to milestone
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
|
||||
// Build the source reference with ordering context
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
return {
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4372,4 +4372,146 @@ describe("Settings API wrappers", () => {
|
||||
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/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/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/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/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/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
|
||||
expect(url).toContain("projectId=proj_xyz");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,9 @@ import type {
|
||||
RoadmapFeatureCreateInput,
|
||||
RoadmapFeatureUpdateInput,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapExportBundle,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
@@ -4565,6 +4568,31 @@ export function moveRoadmapFeature(
|
||||
});
|
||||
}
|
||||
|
||||
/** Export a roadmap as a flat bundle for persistence/import/export */
|
||||
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
|
||||
return api<RoadmapExportBundle>(withProjectId(`/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(`/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(
|
||||
`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
|
||||
projectId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Response from milestone suggestion generation */
|
||||
export interface MilestoneSuggestionsResponse {
|
||||
suggestions: Array<{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } 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/core";
|
||||
|
||||
vi.mock("./roadmap-suggestions.js", () => ({
|
||||
@@ -120,6 +121,56 @@ function createMockRoadmapStore(): RoadmapStore {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
} as unknown as RoadmapStore;
|
||||
}
|
||||
|
||||
@@ -276,4 +327,56 @@ describe("Roadmap Routes", () => {
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
|
||||
});
|
||||
});
|
||||
|
||||
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/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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -575,5 +575,58 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Export / Handoff Endpoints ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/export
|
||||
* Get a flat export bundle for the roadmap.
|
||||
*/
|
||||
router.get("/:roadmapId/export", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const export_ = roadmapStore.getRoadmapExport(roadmapId);
|
||||
res.json(export_);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to export roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/handoff/mission
|
||||
* Get a mission planning handoff payload for the roadmap.
|
||||
*/
|
||||
router.get("/:roadmapId/handoff/mission", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const handoff = roadmapStore.getRoadmapMissionHandoff(roadmapId);
|
||||
res.json(handoff);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate mission handoff");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
|
||||
* Get a task planning handoff payload for a single feature.
|
||||
*/
|
||||
router.get("/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId, milestoneId, featureId } = req.params;
|
||||
|
||||
const handoff = roadmapStore.getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId);
|
||||
res.json(handoff);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate task handoff");
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user