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:
@@ -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