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:
@@ -321,4 +321,185 @@ describe("MissionManager", () => {
|
||||
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 featureCounter = 1;
|
||||
|
||||
// Generate IDs matching the real MissionStore format:
|
||||
// prefix + base36(timestamp) + "-" + random alphanumeric suffix
|
||||
const generateMissionId = () => `M-${missionCounter++}`;
|
||||
const generateMilestoneId = () => `MS-${milestoneCounter++}`;
|
||||
const generateSliceId = () => `SL-${sliceCounter++}`;
|
||||
@@ -565,4 +567,119 @@ describe("Mission API", () => {
|
||||
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 {
|
||||
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 {
|
||||
return /^MS-\d+$/.test(id);
|
||||
return /^MS-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
|
||||
}
|
||||
|
||||
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 {
|
||||
return /^F-\d+$/.test(id);
|
||||
return /^F-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i.test(id);
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user