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:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user