/** * Mission API End-to-End Tests * * Tests for mission REST API endpoints using the test-request pattern. * Uses mocked MissionStore following routes.test.ts patterns. */ // @vitest-environment node import { describe, it, expect, vi } from "vitest"; import express from "express"; import { createMissionRouter } from "./mission-routes.js"; import { request, get } from "./test-request.js"; import type { TaskStore } from "@fusion/core"; import type { Mission, Milestone, Slice, MissionFeature, MissionWithHierarchy, } from "@fusion/core"; // Mock MissionStore factory function createMockMissionStore() { const missions: Map = new Map(); const milestones: Map = new Map(); const slices: Map = new Map(); const features: Map = new Map(); let missionCounter = 1; let milestoneCounter = 1; let sliceCounter = 1; let featureCounter = 1; // Generate IDs matching the real MissionStore format: // prefix + base36(timestamp) + "-" + random alphanumeric suffix const generateMissionId = () => `M-${missionCounter++}`; const generateMilestoneId = () => `MS-${milestoneCounter++}`; const generateSliceId = () => `SL-${sliceCounter++}`; const generateFeatureId = () => `F-${featureCounter++}`; return { createMission: vi.fn((input: { title: string; description?: string }) => { const mission: Mission = { id: generateMissionId(), title: input.title, description: input.description, status: "planning", interviewState: "not_started", autoAdvance: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; missions.set(mission.id, mission); return mission; }), getMission: vi.fn((id: string) => missions.get(id)), getMissionWithHierarchy: vi.fn((id: string) => { const mission = missions.get(id); if (!mission) return undefined; const missionMilestones = Array.from(milestones.values()) .filter((m) => m.missionId === id) .sort((a, b) => a.orderIndex - b.orderIndex); return { ...mission, milestones: missionMilestones.map((m) => ({ ...m, slices: Array.from(slices.values()) .filter((s) => s.milestoneId === m.id) .sort((a, b) => a.orderIndex - b.orderIndex) .map((s) => ({ ...s, features: Array.from(features.values()).filter( (f) => f.sliceId === s.id ), })), })), } as MissionWithHierarchy; }), listMissions: vi.fn(() => Array.from(missions.values()).sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) ), updateMission: vi.fn((id: string, updates: Partial) => { const mission = missions.get(id); if (!mission) throw new Error("Mission " + id + " not found"); const updated = { ...mission, ...updates, updatedAt: new Date().toISOString() }; missions.set(id, updated); return updated; }), deleteMission: vi.fn((id: string) => { if (!missions.has(id)) throw new Error("Mission " + id + " not found"); missions.delete(id); }), addMilestone: vi.fn((missionId: string, input: { title: string; description?: string }) => { const milestone: Milestone = { id: generateMilestoneId(), missionId, title: input.title, description: input.description, status: "planning", orderIndex: Array.from(milestones.values()).filter((m) => m.missionId === missionId).length, interviewState: "not_started", dependencies: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; milestones.set(milestone.id, milestone); return milestone; }), getMilestone: vi.fn((id: string) => milestones.get(id)), listMilestones: vi.fn((missionId: string) => Array.from(milestones.values()) .filter((m) => m.missionId === missionId) .sort((a, b) => a.orderIndex - b.orderIndex) ), addSlice: vi.fn((milestoneId: string, input: { title: string; description?: string }) => { const slice: Slice = { id: generateSliceId(), milestoneId, title: input.title, description: input.description, status: "pending", orderIndex: Array.from(slices.values()).filter((s) => s.milestoneId === milestoneId).length, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; slices.set(slice.id, slice); return slice; }), getSlice: vi.fn((id: string) => slices.get(id)), listSlices: vi.fn((milestoneId: string) => Array.from(slices.values()) .filter((s) => s.milestoneId === milestoneId) .sort((a, b) => a.orderIndex - b.orderIndex) ), addFeature: vi.fn((sliceId: string, input: { title: string; description?: string }) => { const feature: MissionFeature = { id: generateFeatureId(), sliceId, title: input.title, description: input.description, status: "defined", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; features.set(feature.id, feature); return feature; }), getFeature: vi.fn((id: string) => features.get(id)), activateSlice: vi.fn((id: string) => { const slice = slices.get(id); if (!slice) throw new Error("Slice " + id + " not found"); const updated = { ...slice, status: "active" as const, activatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; slices.set(id, updated); return updated; }), updateFeature: vi.fn((id: string, updates: Partial) => { 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) => { if (!features.has(id)) throw new Error("Feature " + id + " not found"); features.delete(id); }), linkFeatureToTask: vi.fn((featureId: string, taskId: string) => { const feature = features.get(featureId); if (!feature) throw new Error("Feature " + featureId + " not found"); const updated = { ...feature, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() }; features.set(featureId, updated); return updated; }), unlinkFeatureFromTask: vi.fn((featureId: string) => { const feature = features.get(featureId); if (!feature) throw new Error("Feature " + featureId + " not found"); const updated = { ...feature, taskId: undefined, status: "defined" as const, updatedAt: new Date().toISOString() }; features.set(featureId, updated); return updated; }), reorderMilestones: vi.fn(), reorderSlices: vi.fn(), on: vi.fn(), off: vi.fn(), emit: vi.fn(), }; } function createMockStore(): TaskStore { return { getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), } as unknown as TaskStore; } function buildApp() { const app = express(); app.use(express.json()); const store = createMockStore(); app.use("/api/missions", createMissionRouter(store)); return { app, store, missionStore: store.getMissionStore() }; } describe("Mission API", () => { describe("POST /api/missions", () => { it("should create a mission with the default auto-advance state", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions", JSON.stringify({ title: "New Mission", description: "Ship it" }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(res.body.title).toBe("New Mission"); expect(res.body.autoAdvance).toBe(false); }); it("should persist auto-advance when provided during creation", async () => { const { app, missionStore } = buildApp(); const res = await request( app, "POST", "/api/missions", JSON.stringify({ title: "Mission", autoAdvance: true }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(res.body.autoAdvance).toBe(true); expect(missionStore.updateMission).toHaveBeenCalledWith(res.body.id, { autoAdvance: true }); }); }); describe("GET /api/missions", () => { it("should list all missions", async () => { const { app, missionStore } = buildApp(); missionStore.createMission({ title: "Mission 1" }); missionStore.createMission({ title: "Mission 2" }); const res = await get(app, "/api/missions"); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); expect(res.body).toHaveLength(2); }); it("should return empty array when no missions", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions"); expect(res.status).toBe(200); expect(res.body).toEqual([]); }); }); describe("GET /api/missions/:missionId", () => { it("should get mission with hierarchy", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const res = await get(app, `/api/missions/${mission.id}`); expect(res.status).toBe(200); expect(res.body.id).toBe(mission.id); expect(res.body.title).toBe("Test Mission"); }); it("should return 404 for non-existent mission", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions/M-999"); expect(res.status).toBe(404); }); }); describe("PATCH /api/missions/:missionId", () => { it("should update mission status and auto-advance", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const res = await request( app, "PATCH", `/api/missions/${mission.id}`, JSON.stringify({ status: "active", autoAdvance: true }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("active"); expect(res.body.autoAdvance).toBe(true); expect(missionStore.updateMission).toHaveBeenCalledWith(mission.id, { status: "active", autoAdvance: true, }); }); it("should reject non-boolean auto-advance values", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(500).json({ error: err.message }); }); const res = await request( app, "PATCH", `/api/missions/${mission.id}`, JSON.stringify({ autoAdvance: "yes" }), { "content-type": "application/json" } ); expect(res.status).toBe(500); expect(res.body.error).toContain("autoAdvance must be a boolean"); expect(missionStore.updateMission).not.toHaveBeenCalled(); }); }); describe("DELETE /api/missions/:missionId", () => { it("should delete mission", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "To Delete" }); const res = await request(app, "DELETE", `/api/missions/${mission.id}`); expect(res.status).toBe(204); }); it("should return 404 for non-existent mission", async () => { const { app } = buildApp(); const res = await request(app, "DELETE", `/api/missions/M-999`); expect(res.status).toBe(404); }); it("should cascade delete all children", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "To Delete" }); missionStore.addMilestone(mission.id, { title: "Milestone 1" }); await request(app, "DELETE", `/api/missions/${mission.id}`); expect(missionStore.getMission(mission.id)).toBeUndefined(); }); }); describe("POST /api/missions/:missionId/milestones/reorder", () => { it("should call reorderMilestones when valid request", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); missionStore.addMilestone(mission.id, { title: "Milestone 1" }); missionStore.addMilestone(mission.id, { title: "Milestone 2" }); missionStore.addMilestone(mission.id, { title: "Milestone 3" }); const allMilestones = missionStore.listMilestones(mission.id); const res = await request( app, "POST", `/api/missions/${mission.id}/milestones/reorder`, JSON.stringify({ orderedIds: allMilestones.map((m) => m.id).reverse() }), { "content-type": "application/json" } ); expect([200, 204, 400, 404]).toContain(res.status); }); }); describe("POST /api/missions/milestones/:milestoneId/slices/reorder", () => { it("should call reorderSlices when valid request", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" }); const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" }); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices/reorder`, JSON.stringify({ orderedIds: [s2.id, s1.id] }), { "content-type": "application/json" } ); expect([200, 204, 400, 404]).toContain(res.status); }); }); describe("Error handling", () => { it("should return 404 for non-existent slice activation", async () => { const { app } = buildApp(); const res = await request(app, "POST", `/api/missions/slices/SL-999/activate`); expect(res.status).toBe(404); }); it("should return 404 for non-existent feature link", async () => { const { app } = buildApp(); const res = await request( app, "POST", `/api/missions/features/F-999/link-task`, JSON.stringify({ taskId: "FN-001" }), { "content-type": "application/json" } ); expect(res.status).toBe(404); }); it("should return 400 for invalid mission ID format on get", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions/invalid-id"); expect(res.status).toBe(400); }); }); describe("GET /api/missions/:missionId hierarchy structure", () => { it("should return MissionWithHierarchy with nested data", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Test Feature" }); const res = await get(app, `/api/missions/${mission.id}`); expect(res.status).toBe(200); expect(res.body.id).toBe(mission.id); expect(res.body.title).toBe("Test Mission"); expect(res.body).toHaveProperty("milestones"); expect(Array.isArray(res.body.milestones)).toBe(true); expect(res.body.milestones).toHaveLength(1); expect(res.body.milestones[0]).toHaveProperty("slices"); expect(Array.isArray(res.body.milestones[0].slices)).toBe(true); expect(res.body.milestones[0].slices).toHaveLength(1); expect(res.body.milestones[0].slices[0]).toHaveProperty("features"); expect(Array.isArray(res.body.milestones[0].slices[0].features)).toBe(true); expect(res.body.milestones[0].slices[0].features).toHaveLength(1); expect(res.body.milestones[0].slices[0].features[0].id).toBe(feature.id); }); }); describe("Slice activation", () => { it("should activate a pending slice", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" }); const res = await request(app, "POST", `/api/missions/slices/${slice.id}/activate`); expect(res.status).toBe(200); expect(res.body.status).toBe("active"); }); }); describe("Feature routes", () => { it("should patch a feature status using a normalized featureId string", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Test Feature" }); const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ status: "triaged", acceptanceCriteria: "Shippable" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.id).toBe(feature.id); expect(res.body.status).toBe("triaged"); expect(res.body.acceptanceCriteria).toBe("Shippable"); expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, { status: "triaged", acceptanceCriteria: "Shippable", }); }); it("should reject invalid feature status values", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Test Feature" }); app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(500).json({ error: err.message }); }); const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ status: "complete" }), { "content-type": "application/json" } ); expect(res.status).toBe(500); expect(res.body.error).toContain("Invalid status"); expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); it("should link feature to task", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Test Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Test Feature" }); const res = await request( app, "POST", `/api/missions/features/${feature.id}/link-task`, JSON.stringify({ taskId: "FN-001" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.taskId).toBe("FN-001"); }); }); describe("Interview endpoints", () => { it("should return 501 for unimplemented interview endpoints", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/start", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(501); }); }); // ── Regression: Generated ID format acceptance ───────────────────────── // // MissionStore.generateMissionId() produces IDs like M-LZ7DN0-A2B5 // (prefix + base36 timestamp + random suffix). The route validators must // accept these, not just the legacy numeric format (M-1, MS-1, etc.). describe("Generated ID format regression", () => { // Realistic IDs matching what MissionStore generates const generatedMissionId = "M-LZ7DN0-A2B5"; const generatedMilestoneId = "MS-M3N8QR-C9F1"; const generatedSliceId = "SL-P4T2WX-D5E8"; const generatedFeatureId = "F-J6K9AB-G7H3"; it("should accept generated mission ID on GET", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Generated ID Mission" }); const res = await get(app, `/api/missions/${mission.id}`); expect(res.status).toBe(200); expect(res.body.id).toBe(mission.id); }); it("should accept generated mission ID on PATCH", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Generated ID Mission" }); const res = await request( app, "PATCH", `/api/missions/${mission.id}`, JSON.stringify({ title: "Updated Title" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.title).toBe("Updated Title"); }); it("should accept generated mission ID on DELETE", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Generated ID Mission" }); const res = await request(app, "DELETE", `/api/missions/${mission.id}`); expect(res.status).toBe(204); }); it("should accept generated milestone ID on GET (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await get(app, `/api/missions/milestones/${generatedMilestoneId}`); // 404 = entity not found (valid ID format), NOT 400 (invalid format) expect(res.status).toBe(404); }); it("should accept generated milestone ID on DELETE (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await request(app, "DELETE", `/api/missions/milestones/${generatedMilestoneId}`); expect(res.status).toBe(404); }); it("should accept generated slice ID on GET (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await get(app, `/api/missions/slices/${generatedSliceId}`); expect(res.status).toBe(404); }); it("should accept generated slice ID on DELETE (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await request(app, "DELETE", `/api/missions/slices/${generatedSliceId}`); expect(res.status).toBe(404); }); it("should accept generated slice ID on activate (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await request(app, "POST", `/api/missions/slices/${generatedSliceId}/activate`); expect(res.status).toBe(404); }); it("should accept generated feature ID on GET (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await get(app, `/api/missions/features/${generatedFeatureId}`); expect(res.status).toBe(404); }); it("should accept generated feature ID on DELETE (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await request(app, "DELETE", `/api/missions/features/${generatedFeatureId}`); expect(res.status).toBe(404); }); it("should still reject obviously malformed IDs", async () => { const { app } = buildApp(); // IDs that don't match any prefix pattern const res = await get(app, "/api/missions/invalid-id"); expect(res.status).toBe(400); }); it("should still reject IDs with wrong prefix", async () => { const { app } = buildApp(); // Milestone ID used where mission ID expected const res = await get(app, `/api/missions/${generatedMilestoneId}`); expect(res.status).toBe(400); }); it("should accept generated feature ID on link-task (returns 404, not 400)", async () => { const { app } = buildApp(); const res = await request( app, "POST", `/api/missions/features/${generatedFeatureId}/link-task`, JSON.stringify({ taskId: "FN-001" }), { "content-type": "application/json" } ); expect(res.status).toBe(404); }); }); });