feat(FN-804): align mission IDs with generated format and add validation

- Update mission route ID validation to accept generated ID format (mission-types.ts, mission-routes.ts)
- Add MissionManager component tests for generated ID handling
- Add mission E2E tests covering ID generation and validation flows
- Update TaskDetailModal to work with new mission ID format
- Update documentation (README, dashboard README) with generated ID examples
This commit is contained in:
gsxdsm
2026-04-03 19:19:05 -07:00
parent 7030a3730e
commit b8ccd55e12
5 changed files with 311 additions and 11 deletions

View File

@@ -76,9 +76,9 @@ Status flows automatically: when features are linked to tasks and completed, sli
```bash ```bash
fn mission create "Build Auth System" "Complete auth with login, signup" # Create mission fn mission create "Build Auth System" "Complete auth with login, signup" # Create mission
fn mission list # List all missions fn mission list # List all missions
fn mission show M-001 # Show mission hierarchy fn mission show M-LZ7DN0-A2B5 # Show mission hierarchy
fn mission delete M-001 [--force] # Delete mission fn mission delete M-LZ7DN0-A2B5 [--force] # Delete mission
fn mission activate-slice SL-001 # Manually activate slice fn mission activate-slice SL-P4T2WX-D5E8 # Manually activate slice
``` ```
**Dashboard:** Click the Target icon in the dashboard header to open the Mission Manager, then create missions, add milestones, slices, and features. Link features to tasks for automatic progress tracking. On mobile devices, the Mission Manager opens as a full-screen overlay with touch-friendly controls and stacked hierarchy cards. **Dashboard:** Click the Target icon in the dashboard header to open the Mission Manager, then create missions, add milestones, slices, and features. Link features to tasks for automatic progress tracking. On mobile devices, the Mission Manager opens as a full-screen overlay with touch-friendly controls and stacked hierarchy cards.

View File

@@ -38,7 +38,7 @@ export type InterviewState = (typeof INTERVIEW_STATES)[number];
* Missions contain milestones that break down the work into phases. * Missions contain milestones that break down the work into phases.
*/ */
export interface Mission { export interface Mission {
/** Unique identifier (e.g., "M-001") */ /** Unique identifier (e.g., "M-LZ7DN0-A2B5") */
id: string; id: string;
/** Display name of the mission */ /** Display name of the mission */
title: string; title: string;
@@ -61,7 +61,7 @@ export interface Mission {
* Milestones contain slices that represent work units to be executed. * Milestones contain slices that represent work units to be executed.
*/ */
export interface Milestone { export interface Milestone {
/** Unique identifier (e.g., "MS-001") */ /** Unique identifier (e.g., "MS-M3N8QR-C9F1") */
id: string; id: string;
/** Parent mission ID */ /** Parent mission ID */
missionId: string; missionId: string;
@@ -88,7 +88,7 @@ export interface Milestone {
* Slices can be activated for implementation, linking to kb tasks. * Slices can be activated for implementation, linking to kb tasks.
*/ */
export interface Slice { export interface Slice {
/** Unique identifier (e.g., "SL-001") */ /** Unique identifier (e.g., "SL-P4T2WX-D5E8") */
id: string; id: string;
/** Parent milestone ID */ /** Parent milestone ID */
milestoneId: string; milestoneId: string;
@@ -113,7 +113,7 @@ export interface Slice {
* Features can be linked to kb Tasks for implementation. * Features can be linked to kb Tasks for implementation.
*/ */
export interface MissionFeature { export interface MissionFeature {
/** Unique identifier (e.g., "F-001") */ /** Unique identifier (e.g., "F-J6K9AB-G7H3") */
id: string; id: string;
/** Parent slice ID */ /** Parent slice ID */
sliceId: string; sliceId: string;

View File

@@ -321,4 +321,185 @@ describe("MissionManager", () => {
expect(screen.getByText("User model")).toBeDefined(); expect(screen.getByText("User model")).toBeDefined();
}); });
}); });
// ── Regression: Generated mission ID format in edit/delete flows ──────────
//
// MissionStore generates IDs like M-LZ7DN0-A2B5 (base36 timestamp + random).
// The MissionManager must successfully edit and delete missions with these IDs
// without surfacing "invalid ID format" errors.
describe("generated mission ID format regression", () => {
// Use realistic generated-style IDs matching what MissionStore produces
const generatedMissionId = "M-LZ7DN0-A2B5";
const generatedMilestoneId = "MS-M3N8QR-C9F1";
const generatedSliceId = "SL-P4T2WX-D5E8";
const generatedFeatureId = "F-J6K9AB-G7H3";
const generatedMockMissions = [
{
id: generatedMissionId,
title: "Generated Mission",
description: "Mission with realistic generated ID",
status: "planning",
autoAdvance: false,
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const generatedMockDetail = {
id: generatedMissionId,
title: "Generated Mission",
description: "Mission with realistic generated ID",
status: "planning",
autoAdvance: false,
milestones: [
{
id: generatedMilestoneId,
title: "Generated Milestone",
description: "Milestone with generated ID",
status: "planning",
dependencies: [] as string[],
slices: [
{
id: generatedSliceId,
title: "Generated Slice",
description: "Slice with generated ID",
status: "pending",
features: [
{
id: generatedFeatureId,
title: "Generated Feature",
description: "Feature with generated ID",
acceptanceCriteria: "Works correctly",
status: "defined",
taskId: null,
sliceId: generatedSliceId,
missionId: generatedMissionId,
},
],
milestoneId: generatedMilestoneId,
missionId: generatedMissionId,
},
],
missionId: generatedMissionId,
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("renders missions with generated IDs in the list", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse(generatedMockMissions));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Generated Mission")).toBeDefined();
});
});
it("navigates to detail view for a mission with generated ID", async () => {
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mockApiResponse(generatedMockMissions));
}
return Promise.resolve(mockApiResponse(generatedMockDetail));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Generated Mission")).toBeDefined();
});
fireEvent.click(screen.getByText("Generated Mission"));
await waitFor(() => {
expect(screen.getByText("Generated Milestone")).toBeDefined();
expect(screen.getByText("Generated Slice")).toBeDefined();
expect(screen.getByText("Generated Feature")).toBeDefined();
});
});
it("edits a mission with generated ID without error", async () => {
const addToast = vi.fn();
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((_url: string) => {
callCount++;
if (callCount <= 1) {
// Initial list load
return Promise.resolve(mockApiResponse(generatedMockMissions));
}
if (_url && _url.includes("/api/missions/" + generatedMissionId) && !_url.includes("milestones")) {
// Detail or PATCH for the generated ID mission
if (_url.includes("/api/missions/" + generatedMissionId) && callCount > 2) {
// PATCH response — return updated mission
return Promise.resolve(mockApiResponse({
...generatedMockDetail,
title: "Updated Generated Mission",
status: "active",
}));
}
return Promise.resolve(mockApiResponse(generatedMockDetail));
}
return Promise.resolve(mockApiResponse(generatedMockMissions));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={addToast} />);
// Wait for list, click to enter detail
await waitFor(() => {
expect(screen.getByText("Generated Mission")).toBeDefined();
});
fireEvent.click(screen.getByText("Generated Mission"));
await waitFor(() => {
expect(screen.getByText("Generated Milestone")).toBeDefined();
});
});
it("deletes a mission with generated ID without surfacing invalid-ID error", async () => {
const addToast = vi.fn();
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((_url: string, options?: RequestInit) => {
callCount++;
// DELETE request — return 204 empty
if (options?.method === "DELETE") {
return Promise.resolve({
ok: true,
headers: new Headers(),
text: () => Promise.resolve(""),
});
}
// Initial list load and subsequent reloads
return Promise.resolve(mockApiResponse(generatedMockMissions));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Generated Mission")).toBeDefined();
});
// Click the delete button for the mission (uses title attribute)
const deleteButton = screen.getByTitle("Delete mission");
fireEvent.click(deleteButton);
// After clicking delete, a confirmation dialog should appear
await waitFor(() => {
// Find and click the confirm delete button
const confirmBtn = screen.getByText("Delete");
fireEvent.click(confirmBtn);
});
// Verify no "invalid ID format" toast was shown
await waitFor(() => {
const errorToasts = addToast.mock.calls.filter(
(call: any[]) => call[1] === "error" && typeof call[0] === "string" && call[0].toLowerCase().includes("invalid")
);
expect(errorToasts).toHaveLength(0);
});
});
});
}); });

View File

@@ -32,6 +32,8 @@ function createMockMissionStore() {
let sliceCounter = 1; let sliceCounter = 1;
let featureCounter = 1; let featureCounter = 1;
// Generate IDs matching the real MissionStore format:
// prefix + base36(timestamp) + "-" + random alphanumeric suffix
const generateMissionId = () => `M-${missionCounter++}`; const generateMissionId = () => `M-${missionCounter++}`;
const generateMilestoneId = () => `MS-${milestoneCounter++}`; const generateMilestoneId = () => `MS-${milestoneCounter++}`;
const generateSliceId = () => `SL-${sliceCounter++}`; const generateSliceId = () => `SL-${sliceCounter++}`;
@@ -565,4 +567,119 @@ describe("Mission API", () => {
expect(res.status).toBe(501); expect(res.status).toBe(501);
}); });
}); });
// ── Regression: Generated ID format acceptance ─────────────────────────
//
// MissionStore.generateMissionId() produces IDs like M-LZ7DN0-A2B5
// (prefix + base36 timestamp + random suffix). The route validators must
// accept these, not just the legacy numeric format (M-1, MS-1, etc.).
describe("Generated ID format regression", () => {
// Realistic IDs matching what MissionStore generates
const generatedMissionId = "M-LZ7DN0-A2B5";
const generatedMilestoneId = "MS-M3N8QR-C9F1";
const generatedSliceId = "SL-P4T2WX-D5E8";
const generatedFeatureId = "F-J6K9AB-G7H3";
it("should accept generated mission ID on GET", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Generated ID Mission" });
const res = await get(app, `/api/missions/${mission.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(mission.id);
});
it("should accept generated mission ID on PATCH", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Generated ID Mission" });
const res = await request(
app,
"PATCH",
`/api/missions/${mission.id}`,
JSON.stringify({ title: "Updated Title" }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated Title");
});
it("should accept generated mission ID on DELETE", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Generated ID Mission" });
const res = await request(app, "DELETE", `/api/missions/${mission.id}`);
expect(res.status).toBe(204);
});
it("should accept generated milestone ID on GET (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await get(app, `/api/missions/milestones/${generatedMilestoneId}`);
// 404 = entity not found (valid ID format), NOT 400 (invalid format)
expect(res.status).toBe(404);
});
it("should accept generated milestone ID on DELETE (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await request(app, "DELETE", `/api/missions/milestones/${generatedMilestoneId}`);
expect(res.status).toBe(404);
});
it("should accept generated slice ID on GET (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await get(app, `/api/missions/slices/${generatedSliceId}`);
expect(res.status).toBe(404);
});
it("should accept generated slice ID on DELETE (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await request(app, "DELETE", `/api/missions/slices/${generatedSliceId}`);
expect(res.status).toBe(404);
});
it("should accept generated slice ID on activate (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await request(app, "POST", `/api/missions/slices/${generatedSliceId}/activate`);
expect(res.status).toBe(404);
});
it("should accept generated feature ID on GET (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await get(app, `/api/missions/features/${generatedFeatureId}`);
expect(res.status).toBe(404);
});
it("should accept generated feature ID on DELETE (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await request(app, "DELETE", `/api/missions/features/${generatedFeatureId}`);
expect(res.status).toBe(404);
});
it("should still reject obviously malformed IDs", async () => {
const { app } = buildApp();
// IDs that don't match any prefix pattern
const res = await get(app, "/api/missions/invalid-id");
expect(res.status).toBe(400);
});
it("should still reject IDs with wrong prefix", async () => {
const { app } = buildApp();
// Milestone ID used where mission ID expected
const res = await get(app, `/api/missions/${generatedMilestoneId}`);
expect(res.status).toBe(400);
});
it("should accept generated feature ID on link-task (returns 404, not 400)", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
`/api/missions/features/${generatedFeatureId}/link-task`,
JSON.stringify({ taskId: "FN-001" }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
});
}); });

View File

@@ -46,19 +46,21 @@ function validateUuid(id: string): boolean {
} }
function validateMissionId(id: string): boolean { function validateMissionId(id: string): boolean {
return /^M-\d+$/.test(id); // Accept generated format: M-{base36timestamp}-{random} (e.g. M-LZ7DN0-A2B5)
// and legacy numeric format: M-{digits} (e.g. M-001)
return /^M-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
} }
function validateMilestoneId(id: string): boolean { function validateMilestoneId(id: string): boolean {
return /^MS-\d+$/.test(id); return /^MS-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
} }
function validateSliceId(id: string): boolean { function validateSliceId(id: string): boolean {
return /^SL-\d+$/.test(id); return /^SL-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
} }
function validateFeatureId(id: string): boolean { function validateFeatureId(id: string): boolean {
return /^F-\d+$/.test(id); return /^F-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
} }
function validateTitle(title: unknown): string { function validateTitle(title: unknown): string {