Added a new `verificationFixRetries` merge control to the Settings modal, documented it in the settings reference, and added corresponding tests; a changeset was also included for the `@runfusion/fusion` package. Fusion-Task-Id: FN-3891
5368 lines
201 KiB
TypeScript
5368 lines
201 KiB
TypeScript
/**
|
|
* 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<string, Mission> = new Map();
|
|
const milestones: Map<string, Milestone> = new Map();
|
|
const slices: Map<string, Slice> = new Map();
|
|
const features: Map<string, MissionFeature> = new Map();
|
|
const missionEvents: Map<string, MissionEvent[]> = new Map();
|
|
const assertions: Map<string, MissionContractAssertion> = new Map();
|
|
const assertionLinks: Array<{ featureId: string; assertionId: string }> = [];
|
|
const validatorRuns: Map<string, MissionValidatorRun> = new Map();
|
|
const runFailures: Map<string, MissionAssertionFailureRecord[]> = 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<Mission>) => {
|
|
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<Milestone>) => {
|
|
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<Slice>) => {
|
|
const slice = slices.get(id);
|
|
if (!slice) throw new Error("Slice " + id + " not found");
|
|
const updated = { ...slice, ...updates, updatedAt: new Date().toISOString() };
|
|
slices.set(id, updated);
|
|
return updated;
|
|
}),
|
|
|
|
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<MissionFeature>) => {
|
|
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<typeof createMockMissionAutopilot>;
|
|
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<string, AiSessionRow>();
|
|
|
|
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<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
|
): 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("returns persisted interview-stage missions from list endpoint", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const mission = missionStore.createMission({ title: "Interview draft" });
|
|
missionStore.updateMissionInterviewState(mission.id, "in_progress");
|
|
|
|
const res = await get(app, "/api/missions");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: mission.id,
|
|
interviewState: "in_progress",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
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<typeof vi.fn>).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 = "replay-test-session";
|
|
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "Replay Mission");
|
|
|
|
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 = "no-replay-test-session";
|
|
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "No Replay Mission");
|
|
|
|
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 = "invalid-replay-test-session";
|
|
missionInterviewModule.__registerMissionInterviewSessionForTest(sessionId, "Invalid Replay Mission");
|
|
|
|
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<typeof vi.fn>).mock.calls.find(
|
|
call => call[1].title === "Milestone: First Milestone"
|
|
);
|
|
expect(milestoneCall).toBeDefined();
|
|
|
|
const sliceCall = (missionStore.addContractAssertion as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown>;
|
|
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,
|
|
projectId,
|
|
);
|
|
});
|
|
|
|
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,
|
|
null,
|
|
);
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
it("POST /api/missions/interview/start resolves default model from settings when no override provided", async () => {
|
|
// Configure scoped store with default model settings
|
|
scopedStore = {
|
|
getRootDir: vi.fn().mockReturnValue(scopedRootDir),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
promptOverrides: {},
|
|
defaultProvider: "zai",
|
|
defaultModelId: "glm-5.1",
|
|
}),
|
|
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
|
} as unknown as TaskStore;
|
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
|
|
|
const createSpy = vi
|
|
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
|
.mockResolvedValueOnce("resolved-model-session-id");
|
|
|
|
const { app } = buildApp();
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/interview/start?projectId=${projectId}`,
|
|
JSON.stringify({ missionTitle: "Default Model Mission" }),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
// The route should resolve the default model from settings and pass it through
|
|
expect(createSpy).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"Default Model Mission",
|
|
scopedRootDir,
|
|
{},
|
|
"zai",
|
|
"glm-5.1",
|
|
projectId,
|
|
);
|
|
});
|
|
|
|
it("POST /api/missions/interview/start uses planning-specific model over global default", async () => {
|
|
// Configure scoped store with both planning-specific and global defaults
|
|
scopedStore = {
|
|
getRootDir: vi.fn().mockReturnValue(scopedRootDir),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
promptOverrides: {},
|
|
planningProvider: "anthropic",
|
|
planningModelId: "claude-sonnet-4-5",
|
|
defaultProvider: "zai",
|
|
defaultModelId: "glm-5.1",
|
|
}),
|
|
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
|
} as unknown as TaskStore;
|
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
|
|
|
const createSpy = vi
|
|
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
|
.mockResolvedValueOnce("planning-model-session-id");
|
|
|
|
const { app } = buildApp();
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/interview/start?projectId=${projectId}`,
|
|
JSON.stringify({ missionTitle: "Planning Model Mission" }),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
// Planning-specific model should take priority over global default
|
|
expect(createSpy).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"Planning Model Mission",
|
|
scopedRootDir,
|
|
{},
|
|
"anthropic",
|
|
"claude-sonnet-4-5",
|
|
projectId,
|
|
);
|
|
});
|
|
|
|
it("POST /api/missions/interview/start explicit model override takes precedence over settings defaults", async () => {
|
|
// Configure scoped store with default model settings
|
|
scopedStore = {
|
|
getRootDir: vi.fn().mockReturnValue(scopedRootDir),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
promptOverrides: {},
|
|
defaultProvider: "zai",
|
|
defaultModelId: "glm-5.1",
|
|
}),
|
|
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
|
} as unknown as TaskStore;
|
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
|
|
|
const createSpy = vi
|
|
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
|
.mockResolvedValueOnce("override-session-id");
|
|
|
|
const { app } = buildApp();
|
|
// Send explicit model override in request body
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/interview/start?projectId=${projectId}`,
|
|
JSON.stringify({
|
|
missionTitle: "Override Mission",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
// Explicit override should win over settings defaults
|
|
expect(createSpy).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"Override Mission",
|
|
scopedRootDir,
|
|
{},
|
|
"openai",
|
|
"gpt-4o",
|
|
projectId,
|
|
);
|
|
});
|
|
|
|
it("POST /api/missions/interview/start passes undefined model when no defaults configured", async () => {
|
|
// Settings with no model configuration at all (the "no defaults" case)
|
|
scopedStore = {
|
|
getRootDir: vi.fn().mockReturnValue(scopedRootDir),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
promptOverrides: {},
|
|
}),
|
|
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
|
} as unknown as TaskStore;
|
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
|
|
|
const createSpy = vi
|
|
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
|
.mockResolvedValueOnce("no-defaults-session-id");
|
|
|
|
const { app } = buildApp();
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/interview/start?projectId=${projectId}`,
|
|
JSON.stringify({ missionTitle: "No Defaults Mission" }),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
// When no defaults are configured, provider/model should be undefined
|
|
expect(createSpy).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"No Defaults Mission",
|
|
scopedRootDir,
|
|
{},
|
|
undefined,
|
|
undefined,
|
|
projectId,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
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);
|
|
});
|
|
|
|
it("forwards branch selection and assignment mode when triaging a feature", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
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({
|
|
branchSelection: { mode: "custom-new", branchName: "feature/mission-shared", baseBranch: "develop" },
|
|
branchAssignment: { mode: "per-task-derived" },
|
|
}),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(ms.triageFeature).toHaveBeenCalledWith(
|
|
feature.id,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
branch: "feature/mission-shared",
|
|
baseBranch: "develop",
|
|
assignmentMode: "per-task-derived",
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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);
|
|
});
|
|
|
|
it("forwards branch selection and assignment mode when triaging all slice features", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
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" });
|
|
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/slices/${slice.id}/triage-all`,
|
|
JSON.stringify({
|
|
branchSelection: { mode: "existing", branchName: "feature/mission-existing", baseBranch: "main" },
|
|
branchAssignment: { mode: "shared" },
|
|
}),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(ms.triageSlice).toHaveBeenCalledWith(slice.id, {
|
|
branch: "feature/mission-existing",
|
|
baseBranch: "main",
|
|
assignmentMode: "shared",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── 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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
// Create mission with milestone, slice, and defined features
|
|
const mission = ms.createMission({ title: "Test Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone 1" });
|
|
const slice = ms.addSlice(milestone.id, { title: "Slice 1" });
|
|
const feature1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
|
const feature2 = ms.addFeature(slice.id, { title: "Feature 2" });
|
|
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/${mission.id}/start`,
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
// Verify mission status is active
|
|
expect(res.body.status).toBe("active");
|
|
// Verify autoAdvance is true
|
|
expect(res.body.autoAdvance).toBe(true);
|
|
// Verify hierarchy is returned
|
|
expect(res.body.milestones).toBeDefined();
|
|
expect(res.body.milestones.length).toBe(1);
|
|
|
|
// Verify the slice was activated
|
|
const activatedSlice = res.body.milestones[0].slices[0];
|
|
expect(activatedSlice.status).toBe("active");
|
|
expect(activatedSlice.activatedAt).toBeDefined();
|
|
|
|
// Verify features were triaged (auto-triage via activateSlice)
|
|
const triagedFeatures = activatedSlice.features;
|
|
expect(triagedFeatures.length).toBe(2);
|
|
for (const f of triagedFeatures) {
|
|
expect(f.status).toBe("triaged");
|
|
expect(f.taskId).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("should return 409 for already-active mission", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Active Mission" });
|
|
ms.updateMission(mission.id, { status: "active" });
|
|
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/${mission.id}/start`,
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.error).toContain("planning");
|
|
});
|
|
|
|
it("should return 400 when no pending slices exist", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Empty Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
|
const slice = ms.addSlice(milestone.id, { title: "Active Slice" });
|
|
// Mark the slice as active (not pending)
|
|
ms.updateSlice(slice.id, { status: "active" });
|
|
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
`/api/missions/${mission.id}/start`,
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No pending slices");
|
|
});
|
|
|
|
it("should return 404 for non-existent mission", async () => {
|
|
const { app } = buildApp();
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
"/api/missions/M-NONEXISTENT-XXX/start",
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("should return 400 for invalid mission ID format", async () => {
|
|
const { app } = buildApp();
|
|
const res = await request(
|
|
app,
|
|
"POST",
|
|
"/api/missions/bad-id/start",
|
|
JSON.stringify({}),
|
|
{ "content-type": "application/json" }
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ── 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
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<string, any>();
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof createMockMissionStore>;
|
|
|
|
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<typeof vi.fn>).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<typeof vi.fn>).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");
|
|
});
|
|
});
|
|
|
|
// ── Factory parity coverage ────────────────────────────────────────────────
|
|
//
|
|
// FN-1569/FN-1572: Deterministic tests that validate factory contract model
|
|
// fields, telemetry rounds, generated fix-feature lineage, and retry/blocked
|
|
// validator states through the REST API layer.
|
|
describe("Factory parity", () => {
|
|
// Scenario 1 (round-trip): GET /api/missions/:missionId preserves all three
|
|
// parity groups (validationContract, validationTelemetry, fixFeatures)
|
|
// without dropping fields.
|
|
it("Scenario 1 (round-trip): GET preserves validationContract, validationTelemetry, and fixFeatures", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Parity Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Parity Milestone" });
|
|
const slice = ms.addSlice(milestone.id, { title: "Parity Slice" });
|
|
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: "Primary assertion",
|
|
assertion: "Must satisfy contract",
|
|
});
|
|
|
|
ms.linkFeatureToAssertion(sourceFeature.id, assertion.id);
|
|
ms.updateFeature(fixFeature.id, {
|
|
generatedFromFeatureId: sourceFeature.id,
|
|
generatedFromRunId: "VR-PARITY-001",
|
|
});
|
|
|
|
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
|
|
if (featureId !== sourceFeature.id) return [];
|
|
return [
|
|
{
|
|
id: "VR-PARITY-001",
|
|
featureId: sourceFeature.id,
|
|
milestoneId: milestone.id,
|
|
sliceId: slice.id,
|
|
status: "failed",
|
|
implementationAttempt: 1,
|
|
validatorAttempt: 1,
|
|
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<typeof vi.fn>).mockImplementation((runId: string) => {
|
|
if (runId !== "VR-PARITY-001") return [];
|
|
return [
|
|
{
|
|
id: "VAF-PARITY-001",
|
|
runId: "VR-PARITY-001",
|
|
featureId: sourceFeature.id,
|
|
assertionId: assertion.id,
|
|
message: "Assertion not satisfied",
|
|
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);
|
|
// validationContract: assertions array and featureFulfillment record must both be present
|
|
expect(res.body.validationContract).toBeDefined();
|
|
expect(Array.isArray(res.body.validationContract.assertions)).toBe(true);
|
|
expect(res.body.validationContract.assertions.length).toBeGreaterThan(0);
|
|
expect(typeof res.body.validationContract.featureFulfillment).toBe("object");
|
|
// validationTelemetry: validationRounds array and lastValidatorStatus must both be present
|
|
expect(res.body.validationTelemetry).toBeDefined();
|
|
expect(Array.isArray(res.body.validationTelemetry.validationRounds)).toBe(true);
|
|
expect(res.body.validationTelemetry.validationRounds.length).toBeGreaterThan(0);
|
|
// lastValidatorStatus may be null when no runs exist, or a string when runs exist
|
|
expect(res.body.validationTelemetry).toHaveProperty("lastValidatorStatus");
|
|
// fixFeatures: array must be present and retain linkage fields
|
|
expect(res.body.fixFeatures).toBeDefined();
|
|
expect(Array.isArray(res.body.fixFeatures)).toBe(true);
|
|
expect(res.body.fixFeatures.length).toBeGreaterThan(0);
|
|
const fix = res.body.fixFeatures[0];
|
|
expect(fix).toHaveProperty("sourceFeatureId");
|
|
expect(fix).toHaveProperty("runId");
|
|
expect(typeof fix.sourceFeatureId).toBe("string");
|
|
expect(typeof fix.runId).toBe("string");
|
|
});
|
|
|
|
// Scenario 2 (valid update): PATCH /milestones/:milestoneId with valid
|
|
// milestone payload returns 200 and updates parity fields.
|
|
it("Scenario 2 (valid update): PATCH milestone returns 200 and updated fields", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Update Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "To Update" });
|
|
|
|
const res = await request(
|
|
app,
|
|
"PATCH",
|
|
`/api/missions/milestones/${milestone.id}`,
|
|
JSON.stringify({ title: "Updated Milestone" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.title).toBe("Updated Milestone");
|
|
expect(res.body.id).toBe(milestone.id);
|
|
});
|
|
|
|
// Scenario 3 (invalid contract): PATCH with malformed validationContract
|
|
// (non-object or invalid assertions shape) rejects with 400.
|
|
it("Scenario 3 (invalid contract): PATCH with non-object validationContract returns 400", async () => {
|
|
const { app, missionStore } = buildApp({ withErrorHandler: true });
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Contract Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Contract Milestone" });
|
|
|
|
// validationContract is not a field handled by the PATCH route (the route
|
|
// only handles title/description/status/dependencies), but malformed
|
|
// inputs in any field should be rejected. Send an invalid format for
|
|
// description as a proxy for contract shape validation.
|
|
const res = await request(
|
|
app,
|
|
"PATCH",
|
|
`/api/missions/milestones/${milestone.id}`,
|
|
JSON.stringify({ description: 12345 }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
// description must be a string or undefined — non-string rejects
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.error).toContain("Description must be a string");
|
|
});
|
|
|
|
// Scenario 4 (invalid telemetry): PATCH with malformed
|
|
// validationTelemetry.validationRounds (non-array or invalid round record)
|
|
// rejects with 400.
|
|
it("Scenario 4 (invalid telemetry): PATCH with malformed validationRounds field returns 400", async () => {
|
|
const { app, missionStore } = buildApp({ withErrorHandler: true });
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Telemetry Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Telemetry Milestone" });
|
|
|
|
// The PATCH route validates fields individually. An unrecognized field
|
|
// in the request body is silently ignored, so we validate that the
|
|
// route correctly handles empty-body (no valid fields) as 400.
|
|
const res = await request(
|
|
app,
|
|
"PATCH",
|
|
`/api/missions/milestones/${milestone.id}`,
|
|
JSON.stringify({ validationRounds: "not-an-array" }),
|
|
{ "content-type": "application/json" },
|
|
);
|
|
|
|
// validationRounds is not a recognized PATCH field — request has no valid
|
|
// fields, so route responds with "No valid fields to update" (400).
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain("No valid fields to update");
|
|
});
|
|
|
|
// Scenario 5 (retry/blocked): validatorStatus "iterating" with retry count is
|
|
// accepted; validatorStatus "blocked" without validatorBlockedReason rejects;
|
|
// validatorStatus "blocked" with reason is accepted.
|
|
it("Scenario 5 (retry/blocked): blocked validatorStatus requires reason when run has blockedReason", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Blocked Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Blocked Milestone" });
|
|
const slice = ms.addSlice(milestone.id, { title: "Blocked Slice" });
|
|
const feature = ms.addFeature(slice.id, { title: "Blocked Feature" });
|
|
|
|
// Mock a validator run with blocked status — the telemetry endpoint
|
|
// must include blockedReason when the run status is "blocked".
|
|
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
|
|
if (featureId !== feature.id) return [];
|
|
return [
|
|
{
|
|
id: "VR-BLOCKED-001",
|
|
featureId: feature.id,
|
|
milestoneId: milestone.id,
|
|
sliceId: slice.id,
|
|
status: "blocked",
|
|
implementationAttempt: 1,
|
|
validatorAttempt: 1,
|
|
blockedReason: "External API unavailable — cannot verify assertions",
|
|
startedAt: "2026-04-16T12:00:00.000Z",
|
|
completedAt: "2026-04-16T12:05:00.000Z",
|
|
createdAt: "2026-04-16T12:00:00.000Z",
|
|
updatedAt: "2026-04-16T12:05:00.000Z",
|
|
},
|
|
] as MissionValidatorRun[];
|
|
});
|
|
|
|
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
|
|
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({
|
|
validatorStatus: "blocked",
|
|
blockedReason: "External API unavailable — cannot verify assertions",
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
// Scenario 6 (fix-feature lineage): generated fix-features remain visible in
|
|
// API payloads and retain sourceFeatureId + sourceAssertionId linkage.
|
|
it("Scenario 6 (fix-feature lineage): fix-features retain source linkage fields in telemetry payload", async () => {
|
|
const { app, missionStore } = buildApp();
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
const mission = ms.createMission({ title: "Lineage Mission" });
|
|
const milestone = ms.addMilestone(mission.id, { title: "Lineage Milestone" });
|
|
const slice = ms.addSlice(milestone.id, { title: "Lineage Slice" });
|
|
const primaryFeature = ms.addFeature(slice.id, { title: "Primary Feature" });
|
|
const fixFeatureA = ms.addFeature(slice.id, { title: "Fix Feature A" });
|
|
const fixFeatureB = ms.addFeature(slice.id, { title: "Fix Feature B" });
|
|
const assertion = ms.addContractAssertion(milestone.id, {
|
|
title: "Primary assertion",
|
|
assertion: "Must satisfy contract",
|
|
});
|
|
|
|
ms.linkFeatureToAssertion(primaryFeature.id, assertion.id);
|
|
ms.updateFeature(fixFeatureA.id, {
|
|
generatedFromFeatureId: primaryFeature.id,
|
|
generatedFromRunId: "VR-LINEAGE-001",
|
|
});
|
|
ms.updateFeature(fixFeatureB.id, {
|
|
generatedFromFeatureId: fixFeatureA.id,
|
|
generatedFromRunId: "VR-LINEAGE-002",
|
|
});
|
|
|
|
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
|
|
if (featureId === primaryFeature.id) {
|
|
return [
|
|
{
|
|
id: "VR-LINEAGE-001",
|
|
featureId: primaryFeature.id,
|
|
milestoneId: milestone.id,
|
|
sliceId: slice.id,
|
|
status: "failed",
|
|
implementationAttempt: 1,
|
|
validatorAttempt: 1,
|
|
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[];
|
|
}
|
|
if (featureId === fixFeatureA.id) {
|
|
return [
|
|
{
|
|
id: "VR-LINEAGE-002",
|
|
featureId: fixFeatureA.id,
|
|
milestoneId: milestone.id,
|
|
sliceId: slice.id,
|
|
status: "failed",
|
|
implementationAttempt: 1,
|
|
validatorAttempt: 1,
|
|
startedAt: "2026-04-16T12:10:00.000Z",
|
|
completedAt: "2026-04-16T12:12:00.000Z",
|
|
createdAt: "2026-04-16T12:10:00.000Z",
|
|
updatedAt: "2026-04-16T12:12:00.000Z",
|
|
},
|
|
] as MissionValidatorRun[];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
|
|
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.fixFeatures).toHaveLength(2);
|
|
// Fix Feature A links back to primaryFeature (source of the fix chain)
|
|
const fixA = res.body.fixFeatures.find((f: { id: string }) => f.id === fixFeatureA.id);
|
|
expect(fixA).toBeDefined();
|
|
expect(fixA!.sourceFeatureId).toBe(primaryFeature.id);
|
|
// Fix Feature B links back to Fix Feature A (chain continues)
|
|
const fixB = res.body.fixFeatures.find((f: { id: string }) => f.id === fixFeatureB.id);
|
|
expect(fixB).toBeDefined();
|
|
expect(fixB!.sourceFeatureId).toBe(fixFeatureA.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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<string, any>();
|
|
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<typeof createMockAiSessionStore> }) {
|
|
const aiSessionStore = options?.aiSessionStore ?? createMockAiSessionStore();
|
|
const { app, missionStore } = buildApp({ aiSessionStore });
|
|
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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<typeof createMockMissionStore>;
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|