/** * 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 { beforeEach, describe, expect, it, 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, MissionEvent, MissionHealth, MissionContractAssertion, ContractAssertionCreateInput, MissionValidatorRun, MissionAssertionFailureRecord, } from "@fusion/core"; import type { AiSessionRow } from "./ai-session-store.js"; import { __resetMissionInterviewState, createMissionInterviewSession, missionInterviewStreamManager, setAiSessionStore, getMissionInterviewSession, submitMissionInterviewResponse, } from "./mission-interview.js"; import * as missionInterviewModule from "./mission-interview.js"; import * as milestoneSliceInterviewModule from "./milestone-slice-interview.js"; import * as projectStoreResolver from "./project-store-resolver.js"; // 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(); const missionEvents: Map = new Map(); const assertions: Map = new Map(); const assertionLinks: Array<{ featureId: string; assertionId: string }> = []; const validatorRuns: Map = new Map(); const runFailures: Map = new Map(); let missionCounter = 1; let milestoneCounter = 1; let sliceCounter = 1; let featureCounter = 1; let assertionCounter = 1; // Generate IDs matching the real MissionStore format: // prefix + base36(timestamp) + "-" + random alphanumeric suffix // e.g., M-MNJVKT2G-ME5Q, MS-M3N8QR-C9F1, SL-P4T2WX-D5E8, F-J6K9AB-G7H3 const generateMissionId = () => `M-MOCK${missionCounter++.toString(36).toUpperCase()}-TST`; const generateMilestoneId = () => `MS-MOCK${milestoneCounter++.toString(36).toUpperCase()}-TST`; const generateSliceId = () => `SL-MOCK${sliceCounter++.toString(36).toUpperCase()}-TST`; const generateFeatureId = () => `F-MOCK${featureCounter++.toString(36).toUpperCase()}-TST`; const generateAssertionId = () => `CA-MOCK${assertionCounter++.toString(36).toUpperCase()}-TST`; 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() ) ), listMissionsWithSummaries: vi.fn(() => Array.from(missions.values()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .map((m) => ({ ...m, summary: { totalMilestones: 0, completedMilestones: 0, totalFeatures: 0, completedFeatures: 0, progressPercent: 0, }, })) ), getMissionSummary: vi.fn((_missionId: string) => ({ totalMilestones: 0, completedMilestones: 0, totalSlices: 0, completedSlices: 0, totalFeatures: 0, completedFeatures: 0, })), getMissionEvents: vi.fn((missionId: string, options?: { limit?: number; offset?: number; eventType?: string }) => { const allEvents = missionEvents.get(missionId) ?? []; const filtered = options?.eventType ? allEvents.filter((event) => event.eventType === options.eventType) : allEvents; const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; return { events: filtered.slice(offset, offset + limit), total: filtered.length, }; }), getMissionHealth: vi.fn((missionId: string): MissionHealth | undefined => { const mission = missions.get(missionId); if (!mission) return undefined; return { missionId, status: mission.status, tasksCompleted: 0, tasksFailed: 0, tasksInFlight: 0, totalTasks: 0, currentSliceId: undefined, currentMilestoneId: undefined, estimatedCompletionPercent: 0, lastErrorAt: undefined, lastErrorDescription: undefined, autopilotState: mission.autopilotState ?? "inactive", autopilotEnabled: mission.autopilotEnabled ?? false, lastActivityAt: mission.lastAutopilotActivityAt, }; }), 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; }), updateMissionInterviewState: vi.fn((id: string, state: Mission["interviewState"]) => { const mission = missions.get(id); if (!mission) throw new Error("Mission " + id + " not found"); const updated = { ...mission, interviewState: state, 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; dependencies?: string[]; verification?: 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: input.dependencies ?? [], verification: input.verification, 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) ), updateMilestone: vi.fn((id: string, updates: Partial) => { 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; }), updateMilestoneInterviewState: vi.fn((id: string, state: Milestone["interviewState"]) => { const milestone = milestones.get(id); if (!milestone) throw new Error("Milestone " + id + " not found"); const updated = { ...milestone, interviewState: state, updatedAt: new Date().toISOString() }; milestones.set(id, updated); return updated; }), deleteMilestone: vi.fn((id: string) => { if (!milestones.has(id)) throw new Error("Milestone " + id + " not found"); milestones.delete(id); for (const slice of Array.from(slices.values())) { if (slice.milestoneId === id) { slices.delete(slice.id); for (const feature of Array.from(features.values())) { if (feature.sliceId === slice.id) { features.delete(feature.id); } } } } }), addSlice: vi.fn((milestoneId: string, input: { title: string; description?: string; verification?: 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, planState: "not_started", verification: input.verification, 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) ), updateSlice: vi.fn((id: string, updates: Partial) => { 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; }), deleteSlice: vi.fn((id: string) => { if (!slices.has(id)) throw new Error("Slice " + id + " not found"); slices.delete(id); for (const feature of Array.from(features.values())) { if (feature.sliceId === id) { features.delete(feature.id); } } }), addFeature: vi.fn((sliceId: string, input: { title: string; description?: string; acceptanceCriteria?: string }) => { const feature: MissionFeature = { id: generateFeatureId(), sliceId, title: input.title, description: input.description, acceptanceCriteria: input.acceptanceCriteria, 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)), listFeatures: vi.fn((sliceId: string) => Array.from(features.values()).filter((feature) => feature.sliceId === sliceId) ), 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); // Simulate auto-triage: when mission.autopilotEnabled OR autoAdvance is true // This matches the real MissionStore.activateSlice behavior: // autopilotEnabled is canonical, autoAdvance is legacy fallback const milestone = milestones.get(slice.milestoneId); if (milestone) { const mission = missions.get(milestone.missionId); if (mission?.autopilotEnabled === true || 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; }), 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; }), // Assertion methods addContractAssertion: vi.fn((milestoneId: string, input: ContractAssertionCreateInput) => { const milestone = milestones.get(milestoneId); if (!milestone) throw new Error("Milestone " + milestoneId + " not found"); const existingAssertions = Array.from(assertions.values()).filter(a => a.milestoneId === milestoneId); const orderIndex = existingAssertions.length > 0 ? Math.max(...existingAssertions.map(a => a.orderIndex)) + 1 : 0; const assertion: MissionContractAssertion = { id: generateAssertionId(), milestoneId, title: input.title, assertion: input.assertion, status: input.status ?? "pending", orderIndex, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; assertions.set(assertion.id, assertion); return assertion; }), getContractAssertion: vi.fn((id: string) => assertions.get(id)), listContractAssertions: vi.fn((milestoneId: string) => Array.from(assertions.values()) .filter(a => a.milestoneId === milestoneId) .sort((a, b) => a.orderIndex - b.orderIndex) ), linkFeatureToAssertion: vi.fn((featureId: string, assertionId: string) => { const feature = features.get(featureId); if (!feature) throw new Error("Feature " + featureId + " not found"); const assertion = assertions.get(assertionId); if (!assertion) throw new Error("Assertion " + assertionId + " not found"); // Check if link already exists const exists = assertionLinks.some( link => link.featureId === featureId && link.assertionId === assertionId ); if (exists) { throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`); } assertionLinks.push({ featureId, assertionId }); }), listAssertionsForFeature: vi.fn((featureId: string) => assertionLinks .filter((link) => link.featureId === featureId) .map((link) => assertions.get(link.assertionId)) .filter((assertion): assertion is MissionContractAssertion => Boolean(assertion)) ), listFeaturesForAssertion: vi.fn((assertionId: string) => assertionLinks .filter((link) => link.assertionId === assertionId) .map((link) => features.get(link.featureId)) .filter((feature): feature is MissionFeature => Boolean(feature)) ), getValidatorRunsByFeature: vi.fn((featureId: string) => Array.from(validatorRuns.values()) .filter((run) => run.featureId === featureId) .sort((a, b) => b.startedAt.localeCompare(a.startedAt)) ), getFailuresForRun: vi.fn((runId: string) => runFailures.get(runId) ?? []), getMilestoneValidationRollup: vi.fn((milestoneId: string) => ({ milestoneId, totalAssertions: 0, passedAssertions: 0, failedAssertions: 0, blockedAssertions: 0, pendingAssertions: 0, unlinkedAssertions: 0, state: "not_started", })), reorderMilestones: vi.fn((missionId: string, orderedIds: string[]) => { orderedIds.forEach((id, index) => { const milestone = milestones.get(id); if (!milestone || milestone.missionId !== missionId) { throw new Error("Milestone " + id + " not found"); } milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString(), }); }); }), reorderSlices: vi.fn((milestoneId: string, orderedIds: string[]) => { orderedIds.forEach((id, index) => { const slice = slices.get(id); if (!slice || slice.milestoneId !== milestoneId) { throw new Error("Slice " + id + " not found"); } slices.set(id, { ...slice, orderIndex: index, updatedAt: new Date().toISOString(), }); }); }), // Triage methods triageFeature: vi.fn(async (featureId: string) => { const feature = features.get(featureId); if (!feature) throw new Error("Feature " + featureId + " not found"); if (feature.status !== "defined") throw new Error("Feature " + featureId + " is already " + feature.status); const taskId = "FN-" + String(features.size + 1).padStart(3, "0"); const updated = { ...feature, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() }; features.set(featureId, updated); return updated; }), triageSlice: vi.fn(async (sliceId: string) => { const slice = slices.get(sliceId); if (!slice) throw new Error("Slice " + sliceId + " not found"); const sliceFeatures = Array.from(features.values()).filter((f) => f.sliceId === sliceId && f.status === "defined"); const triaged: MissionFeature[] = []; for (const f of sliceFeatures) { const taskId = "FN-" + String(features.size + triaged.size + 1).padStart(3, "0"); const updated = { ...f, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() }; features.set(f.id, updated); triaged.push(updated); } 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"), on: vi.fn(), off: vi.fn(), emit: vi.fn(), }; } function createMockStore(): TaskStore { return { getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), getRootDir: vi.fn().mockReturnValue("/fake/root"), getSettings: vi.fn().mockResolvedValue({ promptOverrides: {} }), pauseTask: vi.fn(), } as unknown as TaskStore; } function createMockMissionAutopilot() { return { watchMission: vi.fn(), unwatchMission: vi.fn(), isWatching: vi.fn().mockReturnValue(false), getAutopilotStatus: vi.fn().mockReturnValue({ enabled: false, state: "inactive", watched: false, lastActivityAt: undefined, }), checkAndStartMission: vi.fn().mockResolvedValue(undefined), recoverStaleMission: vi.fn().mockResolvedValue(undefined), start: vi.fn(), stop: vi.fn(), }; } function buildApp(options?: { missionAutopilot?: ReturnType; withErrorHandler?: boolean; aiSessionStore?: { acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null }; }; }) { const app = express(); app.use(express.json()); const store = createMockStore(); app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot, options?.aiSessionStore as any)); if (options?.withErrorHandler) { app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(500).json({ error: err.message }); }); } return { app, store, missionStore: store.getMissionStore() }; } class MockAiSessionStore { rows = new Map(); upsert(row: AiSessionRow): void { this.rows.set(row.id, row); } updateThinking(id: string, thinkingOutput: string): void { const row = this.rows.get(id); if (!row) { return; } this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString(), }); } delete(id: string): void { this.rows.delete(id); } get(id: string): AiSessionRow | null { return this.rows.get(id) ?? null; } listRecoverable(): AiSessionRow[] { return [...this.rows.values()].filter( (row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error", ); } on(): this { return this; } off(): this { return this; } } function buildMissionInterviewRow( overrides: Partial & Pick, ): AiSessionRow { const now = new Date().toISOString(); return { id: overrides.id, type: "mission_interview", status: overrides.status, title: overrides.title ?? "Recovered mission interview session", inputPayload: overrides.inputPayload ?? JSON.stringify({ ip: "127.0.0.1", missionId: "M-RECOVERED", missionTitle: "Recovered mission interview", }), conversationHistory: overrides.conversationHistory ?? "[]", currentQuestion: overrides.currentQuestion ?? JSON.stringify({ id: "q-existing", type: "text", question: "What are we building?", description: "context", }), result: overrides.result ?? null, thinkingOutput: overrides.thinkingOutput ?? "Recovered thinking", error: overrides.error ?? null, projectId: overrides.projectId ?? null, createdAt: overrides.createdAt ?? now, updatedAt: overrides.updatedAt ?? now, }; } 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("Mission observability endpoints", () => { it("GET /api/missions/:missionId/events returns paginated events", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Observable Mission" }); const mockEvents: MissionEvent[] = [ { id: "ME-003", missionId: mission.id, eventType: "warning", description: "Stale warning", metadata: { category: "autopilot_stale" }, timestamp: "2026-04-08T12:02:00.000Z", }, { id: "ME-002", missionId: mission.id, eventType: "error", description: "Autopilot failed", metadata: { retryCount: 3 }, timestamp: "2026-04-08T12:01:00.000Z", }, ]; missionStore.getMissionEvents.mockReturnValue({ events: mockEvents, total: 7 }); const res = await get(app, `/api/missions/${mission.id}/events`); expect(res.status).toBe(200); expect(res.body).toEqual({ events: mockEvents, total: 7, limit: 50, offset: 0, }); expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, { limit: 50, offset: 0, eventType: undefined, }); }); it("GET /api/missions/:missionId/events supports limit/offset query params", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Observable Mission" }); missionStore.getMissionEvents.mockReturnValue({ events: [], total: 42 }); const res = await get(app, `/api/missions/${mission.id}/events?limit=10&offset=5`); expect(res.status).toBe(200); expect(res.body.limit).toBe(10); expect(res.body.offset).toBe(5); expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, { limit: 10, offset: 5, eventType: undefined, }); }); it("GET /api/missions/:missionId/events supports eventType filtering", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Observable Mission" }); const filteredEvents: MissionEvent[] = [ { id: "ME-010", missionId: mission.id, eventType: "error", description: "latest error", metadata: null, timestamp: "2026-04-08T12:10:00.000Z", }, ]; missionStore.getMissionEvents.mockReturnValue({ events: filteredEvents, total: 1 }); const res = await get(app, `/api/missions/${mission.id}/events?eventType=error`); expect(res.status).toBe(200); expect(res.body.events).toEqual(filteredEvents); expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, { limit: 50, offset: 0, eventType: "error", }); }); it("GET /api/missions/:missionId/events returns 404 for unknown mission", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions/M-999/events"); expect(res.status).toBe(404); expect(res.body.error).toBe("Mission not found"); }); it("GET /api/missions/:missionId/health returns mission health", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Healthy Mission" }); const health: MissionHealth = { missionId: mission.id, status: "active", tasksCompleted: 5, tasksFailed: 1, tasksInFlight: 2, totalTasks: 8, currentSliceId: "SL-MOCK1-TST", currentMilestoneId: "MS-MOCK1-TST", estimatedCompletionPercent: 63, lastErrorAt: "2026-04-08T12:00:00.000Z", lastErrorDescription: "Most recent error", autopilotState: "watching", autopilotEnabled: true, lastActivityAt: "2026-04-08T12:05:00.000Z", }; missionStore.getMissionHealth.mockReturnValue(health); const res = await get(app, `/api/missions/${mission.id}/health`); expect(res.status).toBe(200); expect(res.body).toEqual(health); expect(missionStore.getMissionHealth).toHaveBeenCalledWith(mission.id); }); it("GET /api/missions/:missionId/health returns 404 for unknown mission", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions/M-999/health"); expect(res.status).toBe(404); expect(res.body.error).toBe("Mission not found"); }); }); 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(res.body.id).toBe(mission.id); // Verify the update was actually persisted in the store (FN-825 regression) const updated = missionStore.getMission(mission.id); expect(updated?.status).toBe("active"); expect(updated?.autoAdvance).toBe(true); expect(missionStore.updateMission).toHaveBeenCalledWith(mission.id, { status: "active", autoAdvance: true, }); }); it("should update mission title with generated-format ID", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Original Title" }); 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"); expect(res.body.id).toBe(mission.id); // Verify persistence const updated = missionStore.getMission(mission.id); expect(updated?.title).toBe("Updated Title"); }); 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 and confirm removal from store", 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); // Verify the mission is actually removed from the mock store (FN-825 regression) expect(missionStore.getMission(mission.id)).toBeUndefined(); }); it("should delete mission with generated-format ID and confirm removal", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "To Delete" }); // Generated-format IDs from mock look like M-MOCK1-TST expect(mission.id).toMatch(/^M-[A-Z0-9]+/); const res = await request(app, "DELETE", `/api/missions/${mission.id}`); expect(res.status).toBe(204); expect(missionStore.getMission(mission.id)).toBeUndefined(); }); 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 reject invalid mission ID format on DELETE", async () => { const { app } = buildApp(); const res = await request(app, "DELETE", `/api/missions/invalid-id`); expect(res.status).toBe(400); }); it("should cascade delete all children and verify removal", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "To Delete" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); const res = await request(app, "DELETE", `/api/missions/${mission.id}`); expect(res.status).toBe(204); expect(missionStore.getMission(mission.id)).toBeUndefined(); // Note: The mock store's deleteMission only removes from the mission Map. // In the real store, FK cascades would remove milestones too. // We verify the route returned success — cascade behavior is tested at the store level. expect(missionStore.deleteMission).toHaveBeenCalledWith(mission.id); }); }); 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" }); // Pre-link the feature to a task so the status transition is allowed missionStore.getFeature.mockReturnValue({ ...feature, taskId: "FN-001" }); 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 reject status transitions to execution states without taskId", 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" }); // Feature has no taskId (taskId is undefined by default) const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ status: "triaged" }), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("Cannot set status to 'triaged' without a linked task"); expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); it("should allow status transitions to execution states when taskId is present", 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" }); missionStore.getFeature.mockReturnValue({ ...feature, taskId: "FN-001" }); const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ status: "in-progress" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, { status: "in-progress", }); }); it("should reject 'done' status without taskId", 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: "done" }), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("Cannot set status to 'done' without a linked task"); expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); it("should reject 'blocked' status without taskId", 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: "blocked" }), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("Cannot set status to 'blocked' without a linked task"); expect(missionStore.updateFeature).not.toHaveBeenCalled(); }); it("should allow 'defined' status without taskId", 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" }); // Feature has no taskId, but "defined" is always allowed const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ status: "defined" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, { status: "defined", }); }); it("should allow non-status field updates without taskId", 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" }); // Updating title/description should be allowed without taskId const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ title: "Updated Title", description: "New description" }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, { title: "Updated Title", description: "New description", }); }); 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("Milestone CRUD", () => { it("GET /api/missions/:missionId/milestones returns sorted milestones and 404 for missing mission", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const first = missionStore.addMilestone(mission.id, { title: "First" }); const second = missionStore.addMilestone(mission.id, { title: "Second" }); missionStore.updateMilestone(first.id, { orderIndex: 1 }); missionStore.updateMilestone(second.id, { orderIndex: 0 }); const ok = await get(app, `/api/missions/${mission.id}/milestones`); expect(ok.status).toBe(200); expect(ok.body.map((milestone: Milestone) => milestone.id)).toEqual([second.id, first.id]); const missing = await get(app, "/api/missions/M-NOT-FOUND/milestones"); expect(missing.status).toBe(404); }); it("POST /api/missions/:missionId/milestones creates milestones and validates payload", async () => { const { app, missionStore } = buildApp({ withErrorHandler: true }); const mission = missionStore.createMission({ title: "Mission" }); const created = await request( app, "POST", `/api/missions/${mission.id}/milestones`, JSON.stringify({ title: "Milestone A", description: "Detailed milestone", dependencies: ["MS-UPSTREAM-1"], }), { "content-type": "application/json" }, ); expect(created.status).toBe(201); expect(created.body.title).toBe("Milestone A"); expect(created.body.description).toBe("Detailed milestone"); expect(created.body.dependencies).toEqual(["MS-UPSTREAM-1"]); const missingMission = await request( app, "POST", "/api/missions/M-NOT-FOUND/milestones", JSON.stringify({ title: "Milestone" }), { "content-type": "application/json" }, ); expect(missingMission.status).toBe(404); const missingTitle = await request( app, "POST", `/api/missions/${mission.id}/milestones`, JSON.stringify({ description: "No title" }), { "content-type": "application/json" }, ); expect(missingTitle.status).toBe(500); expect(missingTitle.body.error).toContain("Title is required"); const tooLongTitle = await request( app, "POST", `/api/missions/${mission.id}/milestones`, JSON.stringify({ title: "x".repeat(201) }), { "content-type": "application/json" }, ); expect(tooLongTitle.status).toBe(500); expect(tooLongTitle.body.error).toContain("Title must not exceed 200 characters"); }); it("PATCH /api/missions/milestones/:milestoneId updates individual fields", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Original" }); const updateTitle = await request( app, "PATCH", `/api/missions/milestones/${milestone.id}`, JSON.stringify({ title: "Renamed" }), { "content-type": "application/json" }, ); expect(updateTitle.status).toBe(200); expect(updateTitle.body.title).toBe("Renamed"); const updateStatus = await request( app, "PATCH", `/api/missions/milestones/${milestone.id}`, JSON.stringify({ status: "active" }), { "content-type": "application/json" }, ); expect(updateStatus.status).toBe(200); expect(updateStatus.body.status).toBe("active"); const updateDescription = await request( app, "PATCH", `/api/missions/milestones/${milestone.id}`, JSON.stringify({ description: "Updated description" }), { "content-type": "application/json" }, ); expect(updateDescription.status).toBe(200); expect(updateDescription.body.description).toBe("Updated description"); const updateDependencies = await request( app, "PATCH", `/api/missions/milestones/${milestone.id}`, JSON.stringify({ dependencies: ["MS-DEP-1"] }), { "content-type": "application/json" }, ); expect(updateDependencies.status).toBe(200); expect(updateDependencies.body.dependencies).toEqual(["MS-DEP-1"]); const noFields = await request( app, "PATCH", `/api/missions/milestones/${milestone.id}`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(noFields.status).toBe(400); expect(noFields.body.error).toContain("No valid fields to update"); const missingMilestone = await request( app, "PATCH", "/api/missions/milestones/MS-NOT-FOUND", JSON.stringify({ title: "Nope" }), { "content-type": "application/json" }, ); expect(missingMilestone.status).toBe(404); }); it("DELETE /api/missions/milestones/:milestoneId validates ID and existence", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "To Delete" }); const removed = await request(app, "DELETE", `/api/missions/milestones/${milestone.id}`); expect(removed.status).toBe(204); const missing = await request(app, "DELETE", "/api/missions/milestones/MS-NOT-FOUND"); expect(missing.status).toBe(404); const invalid = await request(app, "DELETE", "/api/missions/milestones/bad-id"); expect(invalid.status).toBe(400); expect(invalid.body.error).toContain("Invalid milestone ID format"); }); it("POST /api/missions/:missionId/milestones/reorder enforces complete ordered IDs", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const m1 = missionStore.addMilestone(mission.id, { title: "One" }); const m2 = missionStore.addMilestone(mission.id, { title: "Two" }); const ok = await request( app, "POST", `/api/missions/${mission.id}/milestones/reorder`, JSON.stringify({ orderedIds: [m2.id, m1.id] }), { "content-type": "application/json" }, ); expect(ok.status).toBe(204); expect(missionStore.reorderMilestones).toHaveBeenCalledWith(mission.id, [m2.id, m1.id]); const incomplete = await request( app, "POST", `/api/missions/${mission.id}/milestones/reorder`, JSON.stringify({ orderedIds: [m1.id] }), { "content-type": "application/json" }, ); expect(incomplete.status).toBe(400); expect(incomplete.body.error).toContain("orderedIds must include all milestones"); const wrongMissionIds = await request( app, "POST", `/api/missions/${mission.id}/milestones/reorder`, JSON.stringify({ orderedIds: [m1.id, "MS-OTHER-MISSION"] }), { "content-type": "application/json" }, ); expect(wrongMissionIds.status).toBe(400); expect(wrongMissionIds.body.error).toContain("Invalid milestone IDs in orderedIds"); }); }); describe("Slice CRUD", () => { it("GET /api/missions/milestones/:milestoneId/slices returns sorted slices and 404 for missing milestone", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const first = missionStore.addSlice(milestone.id, { title: "First" }); const second = missionStore.addSlice(milestone.id, { title: "Second" }); missionStore.updateSlice(first.id, { orderIndex: 2 }); missionStore.updateSlice(second.id, { orderIndex: 0 }); const ok = await get(app, `/api/missions/milestones/${milestone.id}/slices`); expect(ok.status).toBe(200); expect(ok.body.map((slice: Slice) => slice.id)).toEqual([second.id, first.id]); const missing = await get(app, "/api/missions/milestones/MS-NOT-FOUND/slices"); expect(missing.status).toBe(404); }); it("POST /api/missions/milestones/:milestoneId/slices handles success, 404, and missing title", async () => { const { app, missionStore } = buildApp({ withErrorHandler: true }); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const created = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices`, JSON.stringify({ title: "Slice A", description: "Slice details" }), { "content-type": "application/json" }, ); expect(created.status).toBe(201); expect(created.body.title).toBe("Slice A"); const missingMilestone = await request( app, "POST", "/api/missions/milestones/MS-NOT-FOUND/slices", JSON.stringify({ title: "Slice" }), { "content-type": "application/json" }, ); expect(missingMilestone.status).toBe(404); const missingTitle = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices`, JSON.stringify({ description: "No title" }), { "content-type": "application/json" }, ); expect(missingTitle.status).toBe(500); expect(missingTitle.body.error).toContain("Title is required"); }); it("PATCH /api/missions/slices/:sliceId updates individual fields and validates empty body", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Original" }); const titleUpdate = await request( app, "PATCH", `/api/missions/slices/${slice.id}`, JSON.stringify({ title: "Renamed" }), { "content-type": "application/json" }, ); expect(titleUpdate.status).toBe(200); expect(titleUpdate.body.title).toBe("Renamed"); const descriptionUpdate = await request( app, "PATCH", `/api/missions/slices/${slice.id}`, JSON.stringify({ description: "Updated description" }), { "content-type": "application/json" }, ); expect(descriptionUpdate.status).toBe(200); expect(descriptionUpdate.body.description).toBe("Updated description"); const statusUpdate = await request( app, "PATCH", `/api/missions/slices/${slice.id}`, JSON.stringify({ status: "active" }), { "content-type": "application/json" }, ); expect(statusUpdate.status).toBe(200); expect(statusUpdate.body.status).toBe("active"); const empty = await request( app, "PATCH", `/api/missions/slices/${slice.id}`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(empty.status).toBe(400); expect(empty.body.error).toContain("No valid fields to update"); }); it("DELETE /api/missions/slices/:sliceId validates ID and existence", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "To Delete" }); const removed = await request(app, "DELETE", `/api/missions/slices/${slice.id}`); expect(removed.status).toBe(204); const missing = await request(app, "DELETE", "/api/missions/slices/SL-NOT-FOUND"); expect(missing.status).toBe(404); const invalid = await request(app, "DELETE", "/api/missions/slices/bad-id"); expect(invalid.status).toBe(400); expect(invalid.body.error).toContain("Invalid slice ID format"); }); it("POST /api/missions/milestones/:milestoneId/slices/reorder validates IDs", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const s1 = missionStore.addSlice(milestone.id, { title: "One" }); const s2 = missionStore.addSlice(milestone.id, { title: "Two" }); const ok = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices/reorder`, JSON.stringify({ orderedIds: [s2.id, s1.id] }), { "content-type": "application/json" }, ); expect(ok.status).toBe(204); expect(missionStore.reorderSlices).toHaveBeenCalledWith(milestone.id, [s2.id, s1.id]); const incomplete = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices/reorder`, JSON.stringify({ orderedIds: [s1.id] }), { "content-type": "application/json" }, ); expect(incomplete.status).toBe(400); expect(incomplete.body.error).toContain("orderedIds must include all slices"); const invalidIds = await request( app, "POST", `/api/missions/milestones/${milestone.id}/slices/reorder`, JSON.stringify({ orderedIds: [s1.id, "SL-OTHER-MILESTONE"] }), { "content-type": "application/json" }, ); expect(invalidIds.status).toBe(400); expect(invalidIds.body.error).toContain("Invalid slice IDs in orderedIds"); }); }); describe("Feature CRUD detail", () => { it("GET /api/missions/slices/:sliceId/features returns features and 404 for missing slice", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Feature A" }); const ok = await get(app, `/api/missions/slices/${slice.id}/features`); expect(ok.status).toBe(200); expect(ok.body).toHaveLength(1); expect(ok.body[0].id).toBe(feature.id); const missing = await get(app, "/api/missions/slices/SL-NOT-FOUND/features"); expect(missing.status).toBe(404); }); it("POST /api/missions/slices/:sliceId/features supports acceptanceCriteria and missing slice", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const created = await request( app, "POST", `/api/missions/slices/${slice.id}/features`, JSON.stringify({ title: "Feature A", description: "Feature details", acceptanceCriteria: "All tests pass", }), { "content-type": "application/json" }, ); expect(created.status).toBe(201); expect(created.body.acceptanceCriteria).toBe("All tests pass"); const missingSlice = await request( app, "POST", "/api/missions/slices/SL-NOT-FOUND/features", JSON.stringify({ title: "Feature" }), { "content-type": "application/json" }, ); expect(missingSlice.status).toBe(404); }); it("PATCH /api/missions/features/:featureId updates acceptanceCriteria", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Feature" }); const res = await request( app, "PATCH", `/api/missions/features/${feature.id}`, JSON.stringify({ acceptanceCriteria: "Updated criteria" }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.acceptanceCriteria).toBe("Updated criteria"); expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, { acceptanceCriteria: "Updated criteria", }); }); it("DELETE /api/missions/features/:featureId succeeds and rejects invalid ID format", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Feature" }); const removed = await request(app, "DELETE", `/api/missions/features/${feature.id}`); expect(removed.status).toBe(204); const invalid = await request(app, "DELETE", "/api/missions/features/invalid-id"); expect(invalid.status).toBe(400); expect(invalid.body.error).toContain("Invalid feature ID format"); }); it("POST /api/missions/features/:featureId/unlink-task handles linked and unlinked features", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const linkedFeature = missionStore.addFeature(slice.id, { title: "Linked Feature" }); missionStore.linkFeatureToTask(linkedFeature.id, "FN-001"); const unlinked = await request( app, "POST", `/api/missions/features/${linkedFeature.id}/unlink-task`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(unlinked.status).toBe(200); expect(unlinked.body.taskId).toBeUndefined(); const plainFeature = missionStore.addFeature(slice.id, { title: "No Task Feature" }); const error = await request( app, "POST", `/api/missions/features/${plainFeature.id}/unlink-task`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(error.status).toBe(400); expect(error.body).toEqual({ error: "Feature is not linked to a task" }); }); it("POST /api/missions/features/:featureId/link-task validates taskId and returns 409 for already linked", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice" }); const feature = missionStore.addFeature(slice.id, { title: "Feature" }); const missingTaskId = await request( app, "POST", `/api/missions/features/${feature.id}/link-task`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(missingTaskId.status).toBe(400); const nonStringTaskId = await request( app, "POST", `/api/missions/features/${feature.id}/link-task`, JSON.stringify({ taskId: 42 }), { "content-type": "application/json" }, ); expect(nonStringTaskId.status).toBe(400); (missionStore.linkFeatureToTask as ReturnType).mockImplementationOnce(() => { throw new Error("Feature is already linked to a task"); }); const conflict = await request( app, "POST", `/api/missions/features/${feature.id}/link-task`, JSON.stringify({ taskId: "FN-123" }), { "content-type": "application/json" }, ); expect(conflict.status).toBe(409); expect(conflict.body.error).toContain("already linked"); }); }); describe("Interview state endpoints", () => { it("GET /api/missions/:missionId/interview-state returns default state and validates ids", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const ok = await get(app, `/api/missions/${mission.id}/interview-state`); expect(ok.status).toBe(200); expect(ok.body).toEqual({ state: "not_started" }); const missing = await get(app, "/api/missions/M-NOT-FOUND/interview-state"); expect(missing.status).toBe(404); const invalid = await get(app, "/api/missions/invalid-id/interview-state"); expect(invalid.status).toBe(400); expect(invalid.body.error).toContain("Invalid mission ID format"); }); it("POST /api/missions/:missionId/interview-state updates mission interview state", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const updated = await request( app, "POST", `/api/missions/${mission.id}/interview-state`, JSON.stringify({ state: "in_progress" }), { "content-type": "application/json" }, ); expect(updated.status).toBe(200); expect(updated.body.interviewState).toBe("in_progress"); expect(missionStore.updateMissionInterviewState).toHaveBeenCalledWith(mission.id, "in_progress"); expect(missionStore.getMission(mission.id)?.interviewState).toBe("in_progress"); const missing = await request( app, "POST", "/api/missions/M-NOT-FOUND/interview-state", JSON.stringify({ state: "in_progress" }), { "content-type": "application/json" }, ); expect(missing.status).toBe(404); }); it("POST /api/missions/:missionId/interview-state rejects invalid interview state values", async () => { const { app, missionStore } = buildApp({ withErrorHandler: true }); const mission = missionStore.createMission({ title: "Mission" }); const invalid = await request( app, "POST", `/api/missions/${mission.id}/interview-state`, JSON.stringify({ state: "bogus" }), { "content-type": "application/json" }, ); expect(invalid.status).toBe(500); expect(invalid.body.error).toContain("Invalid interview state"); }); it("GET/POST milestone interview-state endpoints read and update milestone state", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const getState = await get(app, `/api/missions/milestones/${milestone.id}/interview-state`); expect(getState.status).toBe(200); expect(getState.body).toEqual({ state: "not_started" }); const setState = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview-state`, JSON.stringify({ state: "completed" }), { "content-type": "application/json" }, ); expect(setState.status).toBe(200); expect(setState.body.interviewState).toBe("completed"); expect(missionStore.updateMilestoneInterviewState).toHaveBeenCalledWith(milestone.id, "completed"); const missingGet = await get(app, "/api/missions/milestones/MS-NOT-FOUND/interview-state"); expect(missingGet.status).toBe(404); const missingPost = await request( app, "POST", "/api/missions/milestones/MS-NOT-FOUND/interview-state", JSON.stringify({ state: "completed" }), { "content-type": "application/json" }, ); expect(missingPost.status).toBe(404); }); it("POST milestone interview-state rejects invalid values", async () => { const { app, missionStore } = buildApp({ withErrorHandler: true }); const mission = missionStore.createMission({ title: "Mission" }); const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" }); const invalid = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview-state`, JSON.stringify({ state: "bad" }), { "content-type": "application/json" }, ); expect(invalid.status).toBe(500); expect(invalid.body.error).toContain("Invalid interview state"); }); }); describe("Mission status endpoint", () => { it("GET /api/missions/:missionId/status returns computed status and validates errors", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Mission" }); const ok = await get(app, `/api/missions/${mission.id}/status`); expect(ok.status).toBe(200); expect(ok.body).toEqual({ status: "active" }); expect(missionStore.computeMissionStatus).toHaveBeenCalledWith(mission.id); const missing = await get(app, "/api/missions/M-NOT-FOUND/status"); expect(missing.status).toBe(404); const invalid = await get(app, "/api/missions/invalid-id/status"); expect(invalid.status).toBe(400); expect(invalid.body.error).toContain("Invalid mission ID format"); }); }); describe("Validation edge cases", () => { it("mission creation validates empty title, whitespace title, and oversized description", async () => { const { app } = buildApp({ withErrorHandler: true }); const emptyTitle = await request( app, "POST", "/api/missions", JSON.stringify({ title: "" }), { "content-type": "application/json" }, ); expect(emptyTitle.status).toBe(500); expect(emptyTitle.body.error).toContain("Title is required"); const whitespaceTitle = await request( app, "POST", "/api/missions", JSON.stringify({ title: " " }), { "content-type": "application/json" }, ); expect(whitespaceTitle.status).toBe(500); expect(whitespaceTitle.body.error).toContain("Title is required"); const oversizedDescription = await request( app, "POST", "/api/missions", JSON.stringify({ title: "Valid title", description: "x".repeat(5001) }), { "content-type": "application/json" }, ); expect(oversizedDescription.status).toBe(500); expect(oversizedDescription.body.error).toContain("Description must not exceed 5000 characters"); }); it("mission update validates invalid status values", async () => { const { app, missionStore } = buildApp({ withErrorHandler: true }); const mission = missionStore.createMission({ title: "Mission" }); const invalid = await request( app, "PATCH", `/api/missions/${mission.id}`, JSON.stringify({ status: "bogus" }), { "content-type": "application/json" }, ); expect(invalid.status).toBe(500); expect(invalid.body.error).toContain("Invalid status"); }); it("mission creation rejects non-boolean autoAdvance", async () => { const { app } = buildApp({ withErrorHandler: true }); const invalid = await request( app, "POST", "/api/missions", JSON.stringify({ title: "Mission", autoAdvance: "yes" }), { "content-type": "application/json" }, ); expect(invalid.status).toBe(500); expect(invalid.body.error).toContain("autoAdvance must be a boolean"); }); it("400-level route validation responses return explicit route messages", async () => { const { app } = buildApp(); const invalidMissionId = await get(app, "/api/missions/invalid-id/status"); expect(invalidMissionId.status).toBe(400); expect(invalidMissionId.body).toEqual({ error: "Invalid mission ID format" }); const invalidFeatureId = await request(app, "DELETE", "/api/missions/features/invalid-id"); expect(invalidFeatureId.status).toBe(400); expect(invalidFeatureId.body).toEqual({ error: "Invalid feature ID format" }); }); }); describe("Interview endpoints", () => { beforeEach(() => { __resetMissionInterviewState(); }); it("should return 400 when missionTitle is missing on interview start", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/start", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("missionTitle"); }); it("should return 400 when sessionId is missing on interview respond", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/respond", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("sessionId"); }); it("should return 400 when sessionId is missing on interview cancel", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/cancel", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("sessionId"); }); it("returns 409 when interview respond is locked by another tab", async () => { const submitSpy = vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse"); const { app } = buildApp({ aiSessionStore: { acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }), }, }); const res = await request( app, "POST", "/api/missions/interview/respond", JSON.stringify({ sessionId: "session-locked", responses: { "q-1": "answer" }, tabId: "tab-other", }), { "content-type": "application/json" }, ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); expect(submitSpy).not.toHaveBeenCalled(); }); it("returns 409 when interview cancel is locked by another tab", async () => { const cancelSpy = vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession"); const { app } = buildApp({ aiSessionStore: { acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }), }, }); const res = await request( app, "POST", "/api/missions/interview/cancel", JSON.stringify({ sessionId: "session-locked", tabId: "tab-other", }), { "content-type": "application/json" }, ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); expect(cancelSpy).not.toHaveBeenCalled(); }); it("returns 409 when interview retry is locked by another tab", async () => { const retrySpy = vi.spyOn(missionInterviewModule, "retryMissionInterviewSession"); const { app } = buildApp({ aiSessionStore: { acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }), }, }); const res = await request( app, "POST", "/api/missions/interview/session-locked/retry", JSON.stringify({ tabId: "tab-other" }), { "content-type": "application/json" }, ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); expect(retrySpy).not.toHaveBeenCalled(); }); it("allows interview respond/cancel/retry when tabId is omitted", async () => { vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse").mockResolvedValueOnce({ type: "question", data: { id: "q-next", type: "text", question: "next", description: "next", }, } as any); vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession").mockResolvedValueOnce(undefined); vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockResolvedValueOnce(undefined); const { app } = buildApp({ aiSessionStore: { acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }), }, }); const respondRes = await request( app, "POST", "/api/missions/interview/respond", JSON.stringify({ sessionId: "session-open", responses: { "q-1": "answer" } }), { "content-type": "application/json" }, ); expect(respondRes.status).toBe(200); const cancelRes = await request( app, "POST", "/api/missions/interview/cancel", JSON.stringify({ sessionId: "session-open" }), { "content-type": "application/json" }, ); expect(cancelRes.status).toBe(200); expect(cancelRes.body).toEqual({ success: true }); const retryRes = await request(app, "POST", "/api/missions/interview/session-open/retry"); expect(retryRes.status).toBe(200); expect(retryRes.body).toEqual({ success: true, sessionId: "session-open" }); }); it("retries a failed interview session", async () => { const retrySpy = vi .spyOn(missionInterviewModule, "retryMissionInterviewSession") .mockResolvedValueOnce(undefined); const { app } = buildApp(); const res = await request(app, "POST", "/api/missions/interview/session-1/retry"); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, sessionId: "session-1" }); // Default store returns {} for promptOverrides when projectId is omitted expect(retrySpy).toHaveBeenCalledWith("session-1", "/fake/root", {}); }); it("returns 404 when interview retry session is missing", async () => { vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce( new missionInterviewModule.SessionNotFoundError("Interview session missing"), ); const { app } = buildApp(); const res = await request(app, "POST", "/api/missions/interview/session-404/retry"); expect(res.status).toBe(404); expect(res.body.error).toContain("Interview session missing"); }); it("returns 400 when interview retry session is not in error state", async () => { vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockRejectedValueOnce( new missionInterviewModule.InvalidSessionStateError("Session is not in an error state"), ); const { app } = buildApp(); const res = await request(app, "POST", "/api/missions/interview/session-400/retry"); expect(res.status).toBe(400); expect(res.body.error).toContain("not in an error state"); }); it("replays buffered interview events when Last-Event-ID is provided", async () => { const { app } = buildApp(); const sessionId = await createMissionInterviewSession("127.0.0.1", "Replay Mission", "/tmp/project"); missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" }); missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "second" }); setTimeout(() => { missionInterviewStreamManager.broadcast(sessionId, { type: "complete" }); }, 0); const res = await request( app, "GET", `/api/missions/interview/${sessionId}/stream`, undefined, { "last-event-id": "1" }, ); expect(res.status).toBe(200); expect(res.body).toContain("id: 2"); expect(res.body).toContain("event: thinking"); expect(res.body).toContain("id: 3"); expect(res.body).toContain("event: complete"); expect(res.body).not.toContain("id: 1\nevent: thinking"); }); it("does not replay buffered interview events when Last-Event-ID is missing", async () => { const { app } = buildApp(); const sessionId = await createMissionInterviewSession("127.0.0.1", "No Replay Mission", "/tmp/project"); missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" }); setTimeout(() => { missionInterviewStreamManager.broadcast(sessionId, { type: "complete" }); }, 0); const res = await request( app, "GET", `/api/missions/interview/${sessionId}/stream`, ); expect(res.status).toBe(200); expect(res.body).not.toContain("id: 1\nevent: thinking"); expect(res.body).toContain("id: 2"); expect(res.body).toContain("event: complete"); }); it("gracefully ignores invalid Last-Event-ID values for interview streams", async () => { const { app } = buildApp(); const sessionId = await createMissionInterviewSession("127.0.0.1", "Invalid Replay Mission", "/tmp/project"); missionInterviewStreamManager.broadcast(sessionId, { type: "thinking", data: "first" }); setTimeout(() => { missionInterviewStreamManager.broadcast(sessionId, { type: "complete" }); }, 0); const res = await request( app, "GET", `/api/missions/interview/${sessionId}/stream`, undefined, { "last-event-id": "not-a-number" }, ); expect(res.status).toBe(200); expect(res.body).not.toContain("id: 1\nevent: thinking"); expect(res.body).toContain("id: 2"); expect(res.body).toContain("event: complete"); }); it("should return 400 when sessionId is missing on create-mission", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/create-mission", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); expect(res.body.error).toContain("sessionId"); }); it("creates mission with verification in dedicated fields and linked assertions", async () => { const { app, missionStore } = buildApp(); const mockSessionId = "test-create-mission-assertions"; // Mock the interview session with a complete summary const store = new MockAiSessionStore(); store.rows.set(mockSessionId, { id: mockSessionId, type: "mission_interview", status: "complete", title: "Test Mission", inputPayload: JSON.stringify({ ip: "127.0.0.1", missionTitle: "Test Mission" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify({ missionTitle: "Test Mission", missionDescription: "A test mission", milestones: [ { title: "First Milestone", description: "First milestone description", verification: "Verify milestone completion", slices: [ { title: "First Slice", description: "First slice description", verification: "Verify slice completion", features: [ { title: "Feature One", description: "Feature one description", acceptanceCriteria: "Feature one criteria", }, { title: "Feature Two", description: "Feature two description", // No acceptanceCriteria - should use fallback }, ], }, ], }, ], }), thinkingOutput: "", error: null, projectId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lockedByTab: null, lockedAt: null, }); setAiSessionStore(store as any); const res = await request( app, "POST", "/api/missions/interview/create-mission", JSON.stringify({ sessionId: mockSessionId }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(res.body).toBeDefined(); expect(res.body.title).toBe("Test Mission"); expect(res.body.interviewState).toBe("completed"); // Verify milestone has dedicated verification field (not concatenated into description) const milestone = res.body.milestones[0]; expect(milestone).toBeDefined(); expect(milestone.verification).toBe("Verify milestone completion"); expect(milestone.description).toBe("First milestone description"); // Verify slice has dedicated verification field const slice = milestone.slices[0]; expect(slice).toBeDefined(); expect(slice.verification).toBe("Verify slice completion"); expect(slice.description).toBe("First slice description"); // Verify features are created expect(slice.features).toHaveLength(2); // Verify assertions were created at milestone + slice + feature levels expect(missionStore.addContractAssertion).toHaveBeenCalledTimes(4); const milestoneCall = (missionStore.addContractAssertion as ReturnType).mock.calls.find( call => call[1].title === "Milestone: First Milestone" ); expect(milestoneCall).toBeDefined(); const sliceCall = (missionStore.addContractAssertion as ReturnType).mock.calls.find( call => call[1].title === "Slice: First Slice" ); expect(sliceCall).toBeDefined(); // Verify Feature One assertion uses acceptanceCriteria const featureOneCall = (missionStore.addContractAssertion as ReturnType).mock.calls.find( call => call[1].title === "Feature One" ); expect(featureOneCall).toBeDefined(); expect(featureOneCall![1].assertion).toBe("Feature one criteria"); expect(featureOneCall![1].title).toBe("Feature One"); // Verify Feature Two assertion uses description (no acceptanceCriteria, has description) const featureTwoCall = (missionStore.addContractAssertion as ReturnType).mock.calls.find( call => call[1].title === "Feature Two" ); expect(featureTwoCall).toBeDefined(); expect(featureTwoCall![1].assertion).toBe("Feature two description"); // Verify assertions are linked to features expect(missionStore.linkFeatureToAssertion).toHaveBeenCalledTimes(2); }); it("uses fallback assertion text when feature has no acceptanceCriteria or description", async () => { const { app, missionStore } = buildApp(); const mockSessionId = "test-fallback-assertion"; const store = new MockAiSessionStore(); store.rows.set(mockSessionId, { id: mockSessionId, type: "mission_interview", status: "complete", title: "Fallback Mission", inputPayload: JSON.stringify({ ip: "127.0.0.1", missionTitle: "Fallback Mission" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify({ missionTitle: "Fallback Mission", missionDescription: "A mission with fallback", milestones: [ { title: "Milestone", description: "Milestone desc", verification: "Verify milestone", slices: [ { title: "Slice", description: "Slice desc", verification: "Verify slice", features: [ { // Only title, no description, no acceptanceCriteria title: "Minimal Feature", }, ], }, ], }, ], }), thinkingOutput: "", error: null, projectId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lockedByTab: null, lockedAt: null, }); setAiSessionStore(store as any); const res = await request( app, "POST", "/api/missions/interview/create-mission", JSON.stringify({ sessionId: mockSessionId }), { "content-type": "application/json" } ); expect(res.status).toBe(201); // Verify the assertion uses the fallback text const fallbackCall = (missionStore.addContractAssertion as ReturnType).mock.calls.find( call => call[1].title === "Minimal Feature" ); expect(fallbackCall).toBeDefined(); expect(fallbackCall![1].assertion).toBe("Verify implementation of: Minimal Feature"); }); it("handles partial plans gracefully without throwing on undefined arrays", async () => { const { app } = buildApp(); const mockSessionId = "test-partial-plan"; // Mock the interview session with partial/incomplete data const store = new MockAiSessionStore(); store.rows.set(mockSessionId, { id: mockSessionId, type: "mission_interview", status: "complete", title: "Partial Mission", inputPayload: JSON.stringify({ ip: "127.0.0.1", missionTitle: "Partial Mission" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify({ // Missing milestones array entirely missionTitle: "Partial Mission", missionDescription: "A partial mission", }), thinkingOutput: "", error: null, projectId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lockedByTab: null, lockedAt: null, }); setAiSessionStore(store as any); const res = await request( app, "POST", "/api/missions/interview/create-mission", JSON.stringify({ sessionId: mockSessionId }), { "content-type": "application/json" } ); // Should fail gracefully due to missing milestones // Note: The error message is correct but Express catches ApiError as 500 // when it originates from within the try block. This is expected behavior. expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(600); expect(res.body.error).toContain("Interview session is not complete"); }); it("handles milestone with empty slices gracefully", async () => { const { app, missionStore } = buildApp(); const mockSessionId = "test-empty-slices"; const store = new MockAiSessionStore(); store.rows.set(mockSessionId, { id: mockSessionId, type: "mission_interview", status: "complete", title: "Mission with Empty Slices", inputPayload: JSON.stringify({ ip: "127.0.0.1", missionTitle: "Mission with Empty Slices" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify({ missionTitle: "Mission with Empty Slices", missionDescription: "A mission with empty slices", milestones: [ { title: "Milestone with Empty Slices", description: "This milestone has no slices", verification: "Verify no slices", slices: [], // Empty slices array }, ], }), thinkingOutput: "", error: null, projectId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lockedByTab: null, lockedAt: null, }); setAiSessionStore(store as any); const res = await request( app, "POST", "/api/missions/interview/create-mission", JSON.stringify({ sessionId: mockSessionId }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(res.body.milestones[0].slices).toHaveLength(0); // Milestone-level assertion is still created even when there are no slices expect(missionStore.addContractAssertion).toHaveBeenCalledTimes(1); }); it("captures generated thinking for the next mission interview question", async () => { const store = new MockAiSessionStore(); const sessionId = "mission-thinking-capture"; store.rows.set( sessionId, buildMissionInterviewRow({ id: sessionId, status: "awaiting_input", thinkingOutput: "First-turn mission reasoning", }), ); setAiSessionStore(store as any); const session = getMissionInterviewSession(sessionId); expect(session).toBeDefined(); if (!session) { throw new Error("Expected mission interview session to exist"); } const messages: Array<{ role: string; content: string }> = []; session.agent = { session: { state: { messages }, prompt: vi.fn(async (message: string) => { messages.push({ role: "user", content: message }); session.thinkingOutput += "Generated follow-up reasoning"; messages.push({ role: "assistant", content: JSON.stringify({ type: "question", data: { id: "q-followup", type: "text", question: "What should we deliver first?", description: "Clarify order", }, }), }); }), dispose: vi.fn(), }, } as any; const response = await submitMissionInterviewResponse( sessionId, { "q-existing": "Ship collaborative editing" }, "/tmp/project", ); expect(response.type).toBe("question"); expect(getMissionInterviewSession(sessionId)?.lastGeneratedThinking).toBe( "Generated follow-up reasoning", ); }); it("stores and persists per-turn mission interview thinking in conversation history", async () => { const store = new MockAiSessionStore(); const sessionId = "mission-thinking-history"; store.rows.set( sessionId, buildMissionInterviewRow({ id: sessionId, status: "awaiting_input", thinkingOutput: "First-turn stored reasoning", }), ); setAiSessionStore(store as any); const session = getMissionInterviewSession(sessionId); expect(session).toBeDefined(); if (!session) { throw new Error("Expected mission interview session to exist"); } const messages: Array<{ role: string; content: string }> = []; session.agent = { session: { state: { messages }, prompt: vi.fn(async (message: string) => { messages.push({ role: "user", content: message }); session.thinkingOutput += "Second-turn mission reasoning"; messages.push({ role: "assistant", content: JSON.stringify({ type: "question", data: { id: "q-next", type: "text", question: "Who owns implementation?", description: "Team ownership", }, }), }); }), dispose: vi.fn(), }, } as any; await submitMissionInterviewResponse( sessionId, { "q-existing": "Need milestone planning" }, "/tmp/project", ); const inMemorySession = getMissionInterviewSession(sessionId); expect(inMemorySession?.history[0]).toMatchObject({ question: expect.objectContaining({ id: "q-existing" }), response: { "q-existing": "Need milestone planning" }, thinkingOutput: "First-turn stored reasoning", }); const persistedRow = store.get(sessionId); expect(persistedRow).not.toBeNull(); const persistedHistory = JSON.parse(persistedRow!.conversationHistory) as Array<{ question: { id: string }; response: Record; thinkingOutput?: string; }>; expect(persistedHistory[0]).toMatchObject({ question: expect.objectContaining({ id: "q-existing" }), response: { "q-existing": "Need milestone planning" }, thinkingOutput: "First-turn stored reasoning", }); }); }); // ── Interview endpoints with projectId scoping ─────────────────────────── // // Tests that verify interview endpoints use scoped project context when projectId // is provided, including prompt override resolution from scoped settings. describe("Interview endpoints with projectId scoping", () => { const projectId = "test-project"; const scopedRootDir = "/scoped/project/path"; let scopedStore: TaskStore; beforeEach(() => { __resetMissionInterviewState(); vi.restoreAllMocks(); // Create a scoped store mock with settings support scopedStore = { getRootDir: vi.fn().mockReturnValue(scopedRootDir), getSettings: vi.fn().mockResolvedValue({ promptOverrides: { "mission-interview-system": "Scoped mission interview prompt", }, }), getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), } as unknown as TaskStore; vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); }); it("POST /api/missions/interview/start uses scoped store settings when projectId provided", async () => { const createSpy = vi .spyOn(missionInterviewModule, "createMissionInterviewSession") .mockResolvedValueOnce("scoped-session-id"); const { app } = buildApp(); const res = await request( app, "POST", `/api/missions/interview/start?projectId=${projectId}`, JSON.stringify({ missionTitle: "Scoped Mission" }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); expect(scopedStore.getRootDir()).toBe(scopedRootDir); expect(scopedStore.getSettings).toHaveBeenCalled(); expect(createSpy).toHaveBeenCalledWith( expect.any(String), "Scoped Mission", scopedRootDir, { "mission-interview-system": "Scoped mission interview prompt" }, undefined, undefined, ); }); it("POST /api/missions/interview/respond uses scoped store settings when projectId provided", async () => { const respondSpy = vi .spyOn(missionInterviewModule, "submitMissionInterviewResponse") .mockResolvedValueOnce({ type: "question", data: { id: "q-next", type: "text", question: "Next question?", description: "Continue", }, } as any); const { app } = buildApp(); const res = await request( app, "POST", `/api/missions/interview/respond?projectId=${projectId}`, JSON.stringify({ sessionId: "scoped-session", responses: { "q-1": "Answer" } }), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); expect(scopedStore.getSettings).toHaveBeenCalled(); expect(respondSpy).toHaveBeenCalledWith( "scoped-session", { "q-1": "Answer" }, scopedRootDir, { "mission-interview-system": "Scoped mission interview prompt" }, ); }); it("POST /api/missions/interview/:sessionId/retry uses scoped store settings when projectId provided", async () => { const retrySpy = vi .spyOn(missionInterviewModule, "retryMissionInterviewSession") .mockResolvedValueOnce(undefined); const { app } = buildApp(); const res = await request( app, "POST", `/api/missions/interview/scoped-retry/retry?projectId=${projectId}` ); expect(res.status).toBe(200); expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); expect(scopedStore.getSettings).toHaveBeenCalled(); expect(retrySpy).toHaveBeenCalledWith( "scoped-retry", scopedRootDir, { "mission-interview-system": "Scoped mission interview prompt" }, ); }); it("POST /api/missions/interview/start uses default store when projectId is omitted", async () => { const createSpy = vi .spyOn(missionInterviewModule, "createMissionInterviewSession") .mockResolvedValueOnce("default-session-id"); // When projectId is omitted, getOrCreateProjectStore should not be called // The scoped store spy is still active from beforeEach, so we need to mock it to return undefined vi.mocked(projectStoreResolver.getOrCreateProjectStore).mockRejectedValueOnce( new Error("Should not be called when projectId is omitted") ); const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/interview/start", JSON.stringify({ missionTitle: "Default Mission" }), { "content-type": "application/json" } ); expect(res.status).toBe(201); expect(createSpy).toHaveBeenCalledWith( expect.any(String), "Default Mission", "/fake/root", {}, undefined, undefined, ); }); it("returns 409 lock conflict for interview respond when projectId provided", async () => { // First create the session so it exists const createSpy = vi .spyOn(missionInterviewModule, "createMissionInterviewSession") .mockResolvedValueOnce("locked-session"); const { app } = buildApp({ aiSessionStore: { acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "other-tab" }), }, }); // Create the session first await request( app, "POST", `/api/missions/interview/start?projectId=${projectId}`, JSON.stringify({ missionTitle: "Locked Mission" }), { "content-type": "application/json" } ); // Now try to respond - should get 409 due to lock conflict const res = await request( app, "POST", `/api/missions/interview/respond?projectId=${projectId}`, JSON.stringify({ sessionId: "locked-session", responses: { "q-1": "answer" }, tabId: "my-tab" }), { "content-type": "application/json" } ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "other-tab", }); }); }); // ── 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); }); }); // ── Feature Triage Endpoints ──────────────────────────────────────────── describe("POST /api/missions/features/:featureId/triage", () => { it("should triage a defined feature", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; // Create mission hierarchy const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Slice" }); const feature = ms.addFeature(slice.id, { title: "Feature" }); const res = await request( app, "POST", `/api/missions/features/${feature.id}/triage`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("triaged"); expect(res.body.taskId).toBeTruthy(); }); it("should return 404 for non-existent feature", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/features/F-NONEXISTENT-XXX/triage", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(404); }); it("should return 400 for already triaged feature", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Slice" }); const feature = ms.addFeature(slice.id, { title: "Feature" }); // Triage it first await ms.triageFeature(feature.id); // Try again — should fail const res = await request( app, "POST", `/api/missions/features/${feature.id}/triage`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); }); }); describe("POST /api/missions/slices/:sliceId/triage-all", () => { it("should triage all defined features in a slice", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Slice" }); ms.addFeature(slice.id, { title: "Feature 1" }); ms.addFeature(slice.id, { title: "Feature 2" }); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/triage-all`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.count).toBe(2); expect(res.body.triaged).toHaveLength(2); expect(res.body.triaged.every((f: MissionFeature) => f.status === "triaged")).toBe(true); }); it("should return 404 for non-existent slice", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/slices/SL-NONEXISTENT-XXX/triage-all", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(404); }); }); // ── Mission Pause/Stop/Resume Endpoints ────────────────────────────────── describe("POST /api/missions/:missionId/pause", () => { it("should pause an active mission", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); // Set to active ms.updateMission(mission.id, { status: "active" }); const res = await request( app, "POST", `/api/missions/${mission.id}/pause`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("blocked"); }); it("should return 404 for non-existent mission", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/M-NONEXISTENT-XXX/pause", JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(404); }); it("should return 400 if mission is already blocked", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "blocked" }); const res = await request( app, "POST", `/api/missions/${mission.id}/pause`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); }); }); describe("POST /api/missions/:missionId/resume", () => { it("re-watches autopilot-enabled missions on resume", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true }); const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("active"); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); }); it("triggers stale recovery when active slice is already complete", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); ms.updateMilestone(milestone.id, { status: "active" }); const activeSlice = ms.addSlice(milestone.id, { title: "Active Slice" }); ms.updateSlice(activeSlice.id, { status: "active" }); const doneFeature = ms.addFeature(activeSlice.id, { title: "Done feature" }); ms.updateFeature(doneFeature.id, { status: "done" }); ms.addSlice(milestone.id, { title: "Pending Slice" }); const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); }); it("triggers stale recovery even when active slice has in-progress features", async () => { // Recovery is always triggered on resume to reconcile any inconsistent state. // recoverStaleMission handles the decision internally based on actual state. const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); const activeSlice = ms.addSlice(milestone.id, { title: "Active Slice" }); ms.updateSlice(activeSlice.id, { status: "active" }); const feature = ms.addFeature(activeSlice.id, { title: "In-progress feature" }); ms.updateFeature(feature.id, { status: "in-progress" }); const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); // recoverStaleMission is always called to reconcile state expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); }); it("skips autopilot re-engagement when mission autopilot is disabled", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: false }); const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("active"); expect(missionAutopilot.watchMission).not.toHaveBeenCalled(); expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled(); }); it("should return 400 if mission is not blocked", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); // Mission starts as "planning" const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(400); }); }); describe("POST /api/missions/:missionId/stop", () => { it("should stop a mission and return paused task IDs", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); ms.updateMission(mission.id, { status: "active" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Slice" }); const feature = ms.addFeature(slice.id, { title: "Feature" }); // Simulate a linked task ms.linkFeatureToTask(feature.id, "FN-001"); const res = await request( app, "POST", `/api/missions/${mission.id}/stop`, JSON.stringify({}), { "content-type": "application/json" } ); expect(res.status).toBe(200); expect(res.body.status).toBe("blocked"); expect(res.body.pausedTaskIds).toContain("FN-001"); }); it("should return 404 for non-existent mission", async () => { const { app } = buildApp(); const res = await request( app, "POST", "/api/missions/M-NONEXISTENT-XXX/stop", JSON.stringify({}), { "content-type": "application/json" } ); 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; // 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; 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; 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); }); }); // ── Autopilot Endpoints ────────────────────────────────────────────────── describe("autopilot endpoints", () => { describe("GET /api/missions/:missionId/autopilot", () => { it("returns autopilot status from service when provided", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Autopilot Mission" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: "2026-04-07T12:00:00.000Z", }); const res = await get(app, `/api/missions/${mission.id}/autopilot`); expect(res.status).toBe(200); expect(res.body).toEqual({ enabled: true, state: "watching", watched: true, lastActivityAt: "2026-04-07T12:00:00.000Z", }); expect(missionAutopilot.getAutopilotStatus).toHaveBeenCalledWith(mission.id); }); it("returns fallback mission status when autopilot service is unavailable", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Fallback Mission" }); missionStore.updateMission(mission.id, { autopilotEnabled: true, autopilotState: "watching", lastAutopilotActivityAt: "2026-04-07T13:00:00.000Z", }); const res = await get(app, `/api/missions/${mission.id}/autopilot`); expect(res.status).toBe(200); expect(res.body).toEqual({ enabled: true, state: "watching", watched: false, lastActivityAt: "2026-04-07T13:00:00.000Z", }); }); }); describe("PATCH /api/missions/:missionId/autopilot", () => { it("enables autopilot and starts planning missions", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Enable Autopilot" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).toHaveBeenCalledWith(mission.id); expect(missionStore.updateMission).toHaveBeenCalledWith(mission.id, { autopilotEnabled: true }); }); it("disables autopilot and unwatches mission", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Disable Autopilot" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: false, state: "inactive", watched: false, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: false }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.unwatchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); }); it("returns 400 when enabled is missing or not boolean", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Invalid Payload" }); const missingRes = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(missingRes.status).toBe(400); const invalidRes = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: "yes" }), { "content-type": "application/json" }, ); expect(invalidRes.status).toBe(400); }); it("returns fallback response without autopilot service", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "No Autopilot Service" }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ enabled: true, state: "inactive", watched: false, lastActivityAt: undefined, }); }); it("enables autopilot on already-active mission and triggers recovery", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); // Create an active mission with no active slices const mission = missionStore.createMission({ title: "Active Mission" }); missionStore.updateMission(mission.id, { status: "active" }); const milestone = missionStore.addMilestone(mission.id, { title: "MS1" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice1" }); // Slice is pending (no active slice) missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); // Should call recoverStaleMission for active missions without active slices expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning }); it("enables autopilot on active mission with completed active slice and triggers recovery", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); // Create an active mission with a completed active slice const mission = missionStore.createMission({ title: "Active Mission 2" }); missionStore.updateMission(mission.id, { status: "active" }); const milestone = missionStore.addMilestone(mission.id, { title: "MS1" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice1" }); // Mark all features as done (slice complete) const feature = missionStore.addFeature(slice.id, { title: "Feature1" }); missionStore.updateFeature(feature.id, { status: "done" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); // Should call recoverStaleMission for active missions with completed active slices expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning }); it("enables autopilot on active mission with in-progress slice (triggers recovery)", async () => { // Recovery is always triggered to reconcile any inconsistent state. // recoverStaleMission handles the decision internally based on actual state. const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); // Create an active mission with an active slice (not completed) const mission = missionStore.createMission({ title: "Active Mission 3" }); missionStore.updateMission(mission.id, { status: "active" }); const milestone = missionStore.addMilestone(mission.id, { title: "MS1" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice1" }); missionStore.updateSlice(slice.id, { status: "active" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); // recoverStaleMission is always called to reconcile state expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); }); }); describe("POST /api/missions/:missionId/autopilot/start", () => { it("starts watching when autopilot is enabled", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Start Autopilot" }); missionStore.updateMission(mission.id, { autopilotEnabled: true }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).toHaveBeenCalledWith(mission.id); }); it("returns 400 when mission autopilot is disabled", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Disabled Autopilot" }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(400); expect(res.body.error).toContain("not enabled"); }); it("returns 503 when autopilot service is unavailable", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Service Unavailable" }); missionStore.updateMission(mission.id, { autopilotEnabled: true }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(503); }); it("triggers recovery when starting autopilot on active mission", async () => { // For active missions, /autopilot/start should trigger recovery to reconcile state const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Active Mission" }); missionStore.updateMission(mission.id, { autopilotEnabled: true, status: "active", }); const milestone = missionStore.addMilestone(mission.id, { title: "MS1" }); const slice = missionStore.addSlice(milestone.id, { title: "Slice1" }); missionStore.updateSlice(slice.id, { status: "active" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); // For active missions, recoverStaleMission should be called expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning }); }); describe("POST /api/missions/:missionId/autopilot/stop", () => { it("stops watching when autopilot service is available", async () => { const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const mission = missionStore.createMission({ title: "Stop Autopilot" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "inactive", watched: false, lastActivityAt: undefined, }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/stop`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.unwatchMission).toHaveBeenCalledWith(mission.id); }); it("returns fallback status when autopilot service is unavailable", async () => { const { app, missionStore } = buildApp(); const mission = missionStore.createMission({ title: "Stop Fallback" }); missionStore.updateMission(mission.id, { autopilotEnabled: true, lastAutopilotActivityAt: "2026-04-07T15:00:00.000Z", }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/stop`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ enabled: true, state: "inactive", watched: false, lastActivityAt: "2026-04-07T15:00:00.000Z", }); }); }); describe("Stale mission recovery integration", () => { it("full re-engagement path: resume triggers recoverStaleMission which advances slice", async () => { // This tests the complete flow: blocked mission with autopilot enabled, // resume API triggers recoverStaleMission, which advances to next pending slice const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; // Create mission: first slice complete, second slice pending const mission = ms.createMission({ title: "Stale Recovery Mission" }); ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true, autopilotState: "inactive", }); const milestone = ms.addMilestone(mission.id, { title: "M1" }); const slice1 = ms.addSlice(milestone.id, { title: "S1" }); ms.updateSlice(slice1.id, { status: "complete" }); const slice2 = ms.addSlice(milestone.id, { title: "S2" }); // slice2 remains pending // The mock recoverStaleMission will advance to slice2 missionAutopilot.recoverStaleMission.mockImplementation(async (missionId: string) => { ms.updateSlice(slice2.id, { status: "active" }); }); // Resume the mission const res = await request( app, "POST", `/api/missions/${mission.id}/resume`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.status).toBe("active"); // Verify the full re-engagement path was triggered expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); // Verify slice was advanced by recoverStaleMission const updatedSlice2 = ms.getSlice(slice2.id); expect(updatedSlice2?.status).toBe("active"); }); it("enable autopilot on stalled active mission triggers recovery", async () => { // This tests enabling autopilot on an already-active mission that may be // stalled (no active work). Recovery should be triggered. const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; // Create active mission with no active slices (stalled) const mission = ms.createMission({ title: "Stalled Mission" }); ms.updateMission(mission.id, { status: "active" }); // No slices at all missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "PATCH", `/api/missions/${mission.id}/autopilot`, JSON.stringify({ enabled: true }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); }); it("autopilot/start on active mission with autopilot enabled triggers recovery", async () => { // Test the /autopilot/start endpoint on an active mission with autopilot // enabled. This should watch + recover to reconcile inconsistent state. const missionAutopilot = createMockMissionAutopilot(); const { app, missionStore } = buildApp({ missionAutopilot }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Start Test" }); ms.updateMission(mission.id, { status: "active", autopilotEnabled: true, }); const milestone = ms.addMilestone(mission.id, { title: "MS1" }); const slice = ms.addSlice(milestone.id, { title: "Slice1" }); ms.updateSlice(slice.id, { status: "complete" }); missionAutopilot.getAutopilotStatus.mockReturnValue({ enabled: true, state: "watching", watched: true, lastActivityAt: undefined, }); const res = await request( app, "POST", `/api/missions/${mission.id}/autopilot/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id); expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id); }); }); }); // ── Milestone Interview Routes ─────────────────────────────────────────────── describe("milestone interview routes", () => { function createMilestoneMockAiSessionStore() { const store = new Map(); return { store, upsert: vi.fn((row) => store.set(row.id, row)), get: vi.fn((id) => store.get(id) ?? null), delete: vi.fn((id) => store.delete(id)), listRecoverable: vi.fn(() => Array.from(store.values())), acquireLock: vi.fn().mockReturnValue({ acquired: true, currentHolder: null }), }; } it("POST /milestones/:milestoneId/interview/start creates session and returns 201", async () => { const aiSessionStore = createMilestoneMockAiSessionStore(); const { app, missionStore } = buildApp({ aiSessionStore }); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const createSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "createTargetInterviewSession" ).mockResolvedValueOnce("session-123"); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(201); expect(res.body).toHaveProperty("sessionId", "session-123"); expect(createSpy).toHaveBeenCalled(); }); it("POST /milestones/:milestoneId/interview/start returns 404 for missing milestone", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/milestones/MS-NOT-FOUND/interview/start", JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); it("POST /milestones/:milestoneId/interview/start returns 400 for invalid milestone ID", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/milestones/invalid-id/interview/start", JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(400); }); it("POST /milestones/:milestoneId/interview/respond returns 200 with question/summary", async () => { const { app } = buildApp({}); const submitSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "submitTargetInterviewResponse" ).mockResolvedValueOnce({ type: "question", data: { id: "q-1", type: "text", question: "Next question?" }, }); const res = await request( app, "POST", "/api/missions/milestones/MS-TEST1/interview/respond", JSON.stringify({ sessionId: "session-123", responses: { "q-1": "answer" } }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.type).toBe("question"); expect(submitSpy).toHaveBeenCalledWith("session-123", { "q-1": "answer" }, expect.any(String)); }); it("POST /milestones/:milestoneId/interview/respond returns 400 for missing sessionId", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/milestones/MS-TEST1/interview/respond", JSON.stringify({ responses: {} }), { "content-type": "application/json" }, ); expect(res.status).toBe(400); }); it("POST /milestones/:milestoneId/interview/apply returns 200 with updated milestone", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const applySpy = vi.spyOn( await import("./milestone-slice-interview.js"), "applyTargetInterview" ).mockReturnValueOnce({ ...milestone, planningNotes: "Interview notes", verification: "Verification criteria", interviewState: "completed", }); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/apply`, JSON.stringify({ sessionId: "session-123" }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.interviewState).toBe("completed"); expect(applySpy).toHaveBeenCalledWith("session-123", expect.anything()); }); it("POST /milestones/:milestoneId/interview/skip returns 200 with updated milestone", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const skipSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "skipTargetInterview" ).mockReturnValueOnce({ ...milestone, planningNotes: "Planned using mission-level context", interviewState: "completed", }); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/skip`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(skipSpy).toHaveBeenCalledWith("milestone", milestone.id, expect.anything()); }); }); // ── Slice Interview Routes ───────────────────────────────────────────────── describe("slice interview routes", () => { it("POST /slices/:sliceId/interview/start creates session and returns 201", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); const createSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "createTargetInterviewSession" ).mockResolvedValueOnce("session-456"); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(201); expect(res.body).toHaveProperty("sessionId", "session-456"); expect(createSpy).toHaveBeenCalled(); }); it("POST /slices/:sliceId/interview/start returns 404 for missing slice", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/slices/SL-NOT-FOUND/interview/start", JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); it("POST /slices/:sliceId/interview/respond returns 200 with question/summary", async () => { const { app } = buildApp({}); const submitSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "submitTargetInterviewResponse" ).mockResolvedValueOnce({ type: "complete", data: { title: "Refined Slice", description: "Updated description", planningNotes: "Notes", verification: "Verification", }, }); const res = await request( app, "POST", "/api/missions/slices/SL-TEST1/interview/respond", JSON.stringify({ sessionId: "session-456", responses: { "q-1": "answer" } }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.type).toBe("complete"); }); it("POST /slices/:sliceId/interview/apply returns 200 with updated slice", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); const applySpy = vi.spyOn( await import("./milestone-slice-interview.js"), "applyTargetInterview" ).mockReturnValueOnce({ ...slice, planningNotes: "Interview notes", verification: "Verification criteria", planState: "planned", }); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/apply`, JSON.stringify({ sessionId: "session-456" }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.planState).toBe("planned"); }); it("POST /slices/:sliceId/interview/skip returns 200 with updated slice", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); const skipSpy = vi.spyOn( await import("./milestone-slice-interview.js"), "skipTargetInterview" ).mockReturnValueOnce({ ...slice, planningNotes: "Planned using mission-level context", planState: "planned", }); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/skip`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(skipSpy).toHaveBeenCalledWith("slice", slice.id, expect.anything()); }); }); // ── Interview Error Mapping Tests ────────────────────────────────────────── describe("interview error mapping", () => { it("POST milestone interview/respond returns 404 for unknown session", async () => { const { app } = buildApp({}); const importMock = await import("./milestone-slice-interview.js"); vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => { const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js"); throw new TargetSessionNotFoundError("Session not found"); }); const res = await request( app, "POST", "/api/missions/milestones/MS-TEST1/interview/respond", JSON.stringify({ sessionId: "nonexistent-session", responses: {} }), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); it("POST slice interview/respond returns 404 for unknown session", async () => { const { app } = buildApp({}); const importMock = await import("./milestone-slice-interview.js"); vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => { const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js"); throw new TargetSessionNotFoundError("Session not found"); }); const res = await request( app, "POST", "/api/missions/slices/SL-TEST1/interview/respond", JSON.stringify({ sessionId: "nonexistent-session", responses: {} }), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); it("POST milestone interview/start returns 429 when rate limited", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Rate Limit Test" }); const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" }); const importMock = await import("./milestone-slice-interview.js"); vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => { const { RateLimitError } = await import("./milestone-slice-interview.js"); throw new RateLimitError("Rate limit exceeded", new Date(Date.now() + 3600000)); }); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(429); expect(res.body).toHaveProperty("error"); }); it("POST slice interview/start returns 429 when rate limited", async () => { const { app, missionStore } = buildApp({}); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Rate Limit Test" }); const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Rate Limit Slice" }); const importMock = await import("./milestone-slice-interview.js"); vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => { const { RateLimitError } = await import("./milestone-slice-interview.js"); throw new RateLimitError("Rate limit exceeded"); }); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(429); expect(res.body).toHaveProperty("error"); }); it("POST milestone interview/skip returns 404 for nonexistent milestone", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/milestones/MS-NONEXISTENT/interview/skip", JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); it("POST slice interview/skip returns 404 for nonexistent slice", async () => { const { app } = buildApp({}); const res = await request( app, "POST", "/api/missions/slices/SL-NONEXISTENT/interview/skip", JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(404); }); }); describe("GET /milestones/:milestoneId/validation-telemetry", () => { it("returns empty grouped telemetry for milestones without assertions or runs", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Telemetry Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone A" }); const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`); expect(res.status).toBe(200); expect(res.body.validationContract.assertions).toEqual([]); expect(res.body.validationContract.featureFulfillment).toEqual({}); expect(res.body.validationTelemetry.validationRounds).toEqual([]); expect(res.body.validationTelemetry.lastValidatorStatus).toBeNull(); expect(res.body.validationTelemetry.totalRuns).toBe(0); expect(res.body.fixFeatures).toEqual([]); expect(res.body.rollup.state).toBe("not_started"); }); it("returns contract assertions and feature fulfillment links", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Contract Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone B" }); const slice = ms.addSlice(milestone.id, { title: "Slice B" }); const featureOne = ms.addFeature(slice.id, { title: "Feature One" }); const featureTwo = ms.addFeature(slice.id, { title: "Feature Two" }); const assertionOne = ms.addContractAssertion(milestone.id, { title: "Assertion One", assertion: "Feature one must pass", }); const assertionTwo = ms.addContractAssertion(milestone.id, { title: "Assertion Two", assertion: "Feature two must pass", }); ms.linkFeatureToAssertion(featureOne.id, assertionOne.id); ms.linkFeatureToAssertion(featureTwo.id, assertionTwo.id); const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`); expect(res.status).toBe(200); expect(res.body.validationContract.assertions).toHaveLength(2); expect(res.body.validationContract.assertions).toEqual( expect.arrayContaining([ expect.objectContaining({ id: assertionOne.id, title: assertionOne.title, assertion: assertionOne.assertion, status: assertionOne.status, }), expect.objectContaining({ id: assertionTwo.id, title: assertionTwo.title, assertion: assertionTwo.assertion, status: assertionTwo.status, }), ]) ); expect(res.body.validationContract.featureFulfillment[featureOne.id]).toEqual({ assertionIds: [assertionOne.id], featureTitle: featureOne.title, featureStatus: featureOne.status, }); expect(res.body.validationContract.featureFulfillment[featureTwo.id]).toEqual({ assertionIds: [assertionTwo.id], featureTitle: featureTwo.title, featureStatus: featureTwo.status, }); }); it("returns validator rounds and generated fix-feature lineage", async () => { const { app, missionStore } = buildApp(); const ms = missionStore as ReturnType; const mission = ms.createMission({ title: "Validation Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Milestone C" }); const slice = ms.addSlice(milestone.id, { title: "Slice C" }); const sourceFeature = ms.addFeature(slice.id, { title: "Source Feature" }); const fixFeature = ms.addFeature(slice.id, { title: "Fix Feature" }); const assertion = ms.addContractAssertion(milestone.id, { title: "Fails assertion", assertion: "Must not regress", }); ms.updateFeature(fixFeature.id, { generatedFromFeatureId: sourceFeature.id, generatedFromRunId: "VR-FAILED-001", }); (missionStore.getValidatorRunsByFeature as ReturnType).mockImplementation((featureId: string) => { if (featureId !== sourceFeature.id) { return []; } return [ { id: "VR-FAILED-001", featureId: sourceFeature.id, milestoneId: milestone.id, sliceId: slice.id, status: "failed", implementationAttempt: 2, validatorAttempt: 2, startedAt: "2026-04-16T12:00:00.000Z", completedAt: "2026-04-16T12:02:00.000Z", createdAt: "2026-04-16T12:00:00.000Z", updatedAt: "2026-04-16T12:02:00.000Z", }, ] as MissionValidatorRun[]; }); (missionStore.getFailuresForRun as ReturnType).mockImplementation((runId: string) => { if (runId !== "VR-FAILED-001") { return []; } return [ { id: "VAF-001", runId: "VR-FAILED-001", featureId: sourceFeature.id, assertionId: assertion.id, message: "Assertion failed", createdAt: "2026-04-16T12:01:00.000Z", }, ] as MissionAssertionFailureRecord[]; }); const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`); expect(res.status).toBe(200); expect(res.body.validationTelemetry.validationRounds).toEqual( expect.arrayContaining([ expect.objectContaining({ roundId: "VR-FAILED-001", validatorStatus: "failed", failedAssertionIds: [assertion.id], generatedFixFeatureIds: [fixFeature.id], }), ]) ); expect(res.body.fixFeatures).toEqual( expect.arrayContaining([ expect.objectContaining({ id: fixFeature.id, sourceFeatureId: sourceFeature.id, runId: "VR-FAILED-001", failedAssertionIds: [assertion.id], }), ]) ); }); it("returns 404 when milestone does not exist", async () => { const { app } = buildApp(); const res = await get(app, "/api/missions/milestones/MS-MISSING-TST/validation-telemetry"); expect(res.status).toBe(404); expect(res.body.error).toBe("Milestone not found"); }); }); }); /** * Mission Interview Route Saturation-Independence Tests * * These tests verify that mission interview routes (mission, milestone, slice) * are NOT gated on task-lane saturation (maxConcurrent, semaphore, queue depth). */ describe("Mission interview routes are independent of task-lane saturation", () => { // Helper to create a mock AI session store for interview routes function createMockAiSessionStore(options?: { lockConflict?: boolean }) { const store = new Map(); return { store, upsert: vi.fn((row) => store.set(row.id, row)), get: vi.fn((id) => store.get(id) ?? null), delete: vi.fn((id) => store.delete(id)), listRecoverable: vi.fn(() => Array.from(store.values())), acquireLock: vi.fn().mockImplementation((_id: string, _tabId: string) => { if (options?.lockConflict) { return { acquired: false, currentHolder: "tab-owner" }; } return { acquired: true, currentHolder: null }; }), }; } // Helper to build an app with saturated settings function buildAppWithSaturatedSettings(options?: { aiSessionStore?: ReturnType }) { const aiSessionStore = options?.aiSessionStore ?? createMockAiSessionStore(); const { app, missionStore } = buildApp({ aiSessionStore }); const ms = missionStore as ReturnType; // Override getSettings to return saturated settings ms.getSettings = vi.fn().mockResolvedValue({ maxConcurrent: 0, // Saturated: zero task slots available promptOverrides: {}, }); return { app, missionStore: ms, aiSessionStore }; } describe("start endpoints", () => { it("POST /api/missions/interview/start succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Mock createMissionInterviewSession to return a session const createSessionMock = vi.fn().mockResolvedValue("mission-saturation-test-session"); vi.spyOn(missionInterviewModule, "createMissionInterviewSession").mockImplementation(createSessionMock); const res = await request( app, "POST", "/api/missions/interview/start", JSON.stringify({ missionTitle: "Build auth system" }), { "content-type": "application/json" }, ); expect(res.status).toBe(201); expect(res.body.sessionId).toBe("mission-saturation-test-session"); // Verify no saturation error was introduced expect(res.body.error).toBeUndefined(); }); it("POST /api/missions/milestones/:milestoneId/interview/start succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a milestone const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); // Mock createTargetInterviewSession to return a session (from milestone-slice-interview module) const createSessionMock = vi.fn().mockResolvedValue("milestone-saturation-test-session"); vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(201); expect(res.body.sessionId).toBe("milestone-saturation-test-session"); // Verify no saturation error was introduced expect(res.body.error).toBeUndefined(); }); it("POST /api/missions/slices/:sliceId/interview/start succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a slice const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); // Mock createTargetInterviewSession to return a session (from milestone-slice-interview module) const createSessionMock = vi.fn().mockResolvedValue("slice-saturation-test-session"); vi.spyOn(milestoneSliceInterviewModule, "createTargetInterviewSession").mockImplementation(createSessionMock); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/start`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(201); expect(res.body.sessionId).toBe("slice-saturation-test-session"); // Verify no saturation error was introduced expect(res.body.error).toBeUndefined(); }); }); describe("respond endpoints", () => { it("POST /api/missions/interview/respond succeeds under saturated settings", async () => { const { app } = buildAppWithSaturatedSettings(); // Mock submitMissionInterviewResponse to return a valid response const respondMock = vi.fn().mockResolvedValue({ type: "question", data: { id: "q-2", type: "text", question: "Next question?" }, }); vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse").mockImplementation(respondMock); const res = await request( app, "POST", "/api/missions/interview/respond", JSON.stringify({ sessionId: "test-session", responses: { "q-1": "answer" } }), { "content-type": "application/json" }, ); // UTILITY PATH: Respond must NOT be gated on maxConcurrent expect(res.status).toBe(200); expect(res.body.type).toBe("question"); }); it("preserves lock-conflict 409 semantics for respond under saturation", async () => { const aiSessionStore = createMockAiSessionStore({ lockConflict: true }); const { app } = buildAppWithSaturatedSettings({ aiSessionStore }); const res = await request( app, "POST", "/api/missions/interview/respond", JSON.stringify({ sessionId: "locked-session", responses: { "q-1": "answer" }, tabId: "tab-other" }), { "content-type": "application/json" }, ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); }); it("POST /api/missions/milestones/:milestoneId/interview/respond succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a milestone const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); // Mock submitTargetInterviewResponse to return a valid response const respondMock = vi.fn().mockResolvedValue({ type: "question", data: { id: "ms-q-2", type: "text", question: "Milestone question?" }, }); vi.spyOn(milestoneSliceInterviewModule, "submitTargetInterviewResponse").mockImplementation(respondMock); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/respond`, JSON.stringify({ sessionId: "milestone-test-session", responses: { "ms-q-1": "answer" } }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.type).toBe("question"); }); it("POST /api/missions/slices/:sliceId/interview/respond succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a slice const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); // Mock submitTargetInterviewResponse to return a valid response const respondMock = vi.fn().mockResolvedValue({ type: "complete", data: { title: "Slice Plan", description: "Done" }, }); vi.spyOn(milestoneSliceInterviewModule, "submitTargetInterviewResponse").mockImplementation(respondMock); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/respond`, JSON.stringify({ sessionId: "slice-test-session", responses: { "sl-q-1": "answer" } }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.type).toBe("complete"); }); }); describe("retry endpoints", () => { it("POST /api/missions/interview/:sessionId/retry succeeds under saturated settings", async () => { const { app } = buildAppWithSaturatedSettings(); // Mock retryMissionInterviewSession to succeed const retryMock = vi.fn().mockResolvedValue(undefined); vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockImplementation(retryMock); const res = await request( app, "POST", "/api/missions/interview/failed-session/retry", JSON.stringify({}), { "content-type": "application/json" }, ); // UTILITY PATH: Retry must NOT be gated on maxConcurrent expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); it("preserves lock-conflict 409 for mission retry under saturation", async () => { const aiSessionStore = createMockAiSessionStore({ lockConflict: true }); const { app } = buildAppWithSaturatedSettings({ aiSessionStore }); const res = await request( app, "POST", "/api/missions/interview/locked-retry-session/retry", JSON.stringify({ tabId: "tab-conflict" }), { "content-type": "application/json" }, ); expect(res.status).toBe(409); expect(res.body).toEqual({ error: "Session locked by another tab", lockedByTab: "tab-owner", }); }); it("POST /api/missions/milestones/:milestoneId/interview/:sessionId/retry succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a milestone const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); // Mock retryTargetInterviewSession to succeed const retryMock = vi.fn().mockResolvedValue(undefined); vi.spyOn(milestoneSliceInterviewModule, "retryTargetInterviewSession").mockImplementation(retryMock); const res = await request( app, "POST", `/api/missions/milestones/${milestone.id}/interview/milestone-retry-session/retry`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); it("POST /api/missions/slices/:sliceId/interview/:sessionId/retry succeeds under saturated settings", async () => { const { app, missionStore } = buildAppWithSaturatedSettings(); const ms = missionStore as ReturnType; // Create a slice const mission = ms.createMission({ title: "Test Mission" }); const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" }); const slice = ms.addSlice(milestone.id, { title: "Test Slice" }); // Mock retryTargetInterviewSession to succeed const retryMock = vi.fn().mockResolvedValue(undefined); vi.spyOn(milestoneSliceInterviewModule, "retryTargetInterviewSession").mockImplementation(retryMock); const res = await request( app, "POST", `/api/missions/slices/${slice.id}/interview/slice-retry-session/retry`, JSON.stringify({}), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); }); });