feat(FN-1010): add mission start endpoint and UI button

- Add POST /api/missions/:missionId/start backend endpoint to start planning missions
- Add startMission() frontend API function in dashboard api.ts
- Add Start button in MissionManager component for planning missions
- Add integration tests for the startMission endpoint covering success, error, and edge cases
This commit is contained in:
gsxdsm
2026-04-06 00:08:39 -07:00
parent 521b46ccbf
commit 9de3bcd618
4 changed files with 238 additions and 0 deletions

View File

@@ -159,6 +159,14 @@ function createMockMissionStore() {
.sort((a, b) => a.orderIndex - b.orderIndex)
),
updateSlice: vi.fn((id: string, updates: Partial<Slice>) => {
const slice = slices.get(id);
if (!slice) throw new Error("Slice " + id + " not found");
const updated = { ...slice, ...updates, updatedAt: new Date().toISOString() };
slices.set(id, updated);
return updated;
}),
addFeature: vi.fn((sliceId: string, input: { title: string; description?: string }) => {
const feature: MissionFeature = {
id: generateFeatureId(),
@@ -185,6 +193,23 @@ function createMockMissionStore() {
updatedAt: new Date().toISOString(),
};
slices.set(id, updated);
// Simulate auto-triage: when mission.autoAdvance is true, triage "defined" features
const milestone = milestones.get(slice.milestoneId);
if (milestone) {
const mission = missions.get(milestone.missionId);
if (mission?.autoAdvance === true) {
const sliceFeatures = Array.from(features.values()).filter(
(f) => f.sliceId === id && f.status === "defined"
);
for (const f of sliceFeatures) {
const taskId = "FN-" + String(features.size + 1).padStart(3, "0");
const triaged = { ...f, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(f.id, triaged);
}
}
}
return updated;
}),
@@ -245,6 +270,21 @@ function createMockMissionStore() {
return triaged;
}),
findNextPendingSlice: vi.fn((missionId: string) => {
const missionMilestones = Array.from(milestones.values())
.filter((m) => m.missionId === missionId)
.sort((a, b) => a.orderIndex - b.orderIndex);
for (const milestone of missionMilestones) {
const milestoneSlices = Array.from(slices.values())
.filter((s) => s.milestoneId === milestone.id)
.sort((a, b) => a.orderIndex - b.orderIndex);
for (const slice of milestoneSlices) {
if (slice.status === "pending") return slice;
}
}
return undefined;
}),
// Mission status helpers for pause/stop
computeMissionStatus: vi.fn(() => "active"),
getMilestone: vi.fn((id: string) => milestones.get(id)),
@@ -1050,4 +1090,117 @@ describe("Mission API", () => {
expect(res.status).toBe(404);
});
});
// ── Mission Start Endpoint ────────────────────────────────────────────────
describe("POST /api/missions/:missionId/start", () => {
it("should start a planning mission and activate the first slice", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
// Create mission with milestone, slice, and defined features
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone 1" });
const slice = ms.addSlice(milestone.id, { title: "Slice 1" });
const feature1 = ms.addFeature(slice.id, { title: "Feature 1" });
const feature2 = ms.addFeature(slice.id, { title: "Feature 2" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/start`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
// Verify mission status is active
expect(res.body.status).toBe("active");
// Verify autoAdvance is true
expect(res.body.autoAdvance).toBe(true);
// Verify hierarchy is returned
expect(res.body.milestones).toBeDefined();
expect(res.body.milestones.length).toBe(1);
// Verify the slice was activated
const activatedSlice = res.body.milestones[0].slices[0];
expect(activatedSlice.status).toBe("active");
expect(activatedSlice.activatedAt).toBeDefined();
// Verify features were triaged (auto-triage via activateSlice)
const triagedFeatures = activatedSlice.features;
expect(triagedFeatures.length).toBe(2);
for (const f of triagedFeatures) {
expect(f.status).toBe("triaged");
expect(f.taskId).toBeDefined();
}
});
it("should return 409 for already-active mission", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Active Mission" });
ms.updateMission(mission.id, { status: "active" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/start`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("planning");
});
it("should return 400 when no pending slices exist", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Empty Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Active Slice" });
// Mark the slice as active (not pending)
ms.updateSlice(slice.id, { status: "active" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/start`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("No pending slices");
});
it("should return 404 for non-existent mission", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/M-NONEXISTENT-XXX/start",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
it("should return 400 for invalid mission ID format", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/bad-id/start",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
});