diff --git a/.changeset/FN-5985-mission-goal-not-found-contract.md b/.changeset/FN-5985-mission-goal-not-found-contract.md new file mode 100644 index 0000000000..929b9e2e62 --- /dev/null +++ b/.changeset/FN-5985-mission-goal-not-found-contract.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mission→goal link write paths to return `400 { code: "GOAL_NOT_FOUND" }` instead of 404 for unknown goals, aligning the API, CLI, and pi tool contract. diff --git a/docs/missions.md b/docs/missions.md index 055ea02413..dc998519db 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -146,7 +146,7 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e | `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. | | `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. | -The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All goal-link write endpoints preserve the same invariant: missing goals return `404`, archived goals reject with `400 { code: "GOAL_ARCHIVED" }`, duplicate/relinked ids are no-ops, and unlink remains allowed even after a goal is archived. +The mission detail payload keeps `linkedGoals` separate from the milestone tree so read paths can surface strategy context without traversing slices/features. All goal-link write endpoints preserve the same invariant: missing goals on link write paths (`POST /api/missions`, `PATCH /api/missions/:missionId`, `PUT /api/missions/:missionId/goals`, `POST /api/missions/:missionId/goals/:goalId`) reject with `400 { code: "GOAL_NOT_FOUND" }`, archived goals reject with `400 { code: "GOAL_ARCHIVED" }`, duplicate/relinked ids are no-ops, and the `DELETE /api/missions/:missionId/goals/:goalId` unlink path treats unknown goals as a `404` while remaining allowed even after a goal is archived. ### CLI diff --git a/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts b/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts index a703236ed4..583e0bc378 100644 --- a/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts +++ b/packages/dashboard/src/__tests__/mission-goal-links-routes.test.ts @@ -104,7 +104,10 @@ describe("mission goal linkage routes", () => { { "content-type": "application/json" }, ); - expect(missingResponse.status).toBe(404); + expect(missingResponse.status).toBe(400); + expect(missingResponse.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); }); it("replaces linked goals via patch, clears with [], and ignores undefined goalIds", async () => { @@ -176,7 +179,10 @@ describe("mission goal linkage routes", () => { JSON.stringify({ goalIds: [goalB.id, "G-404"] }), { "content-type": "application/json" }, ); - expect(missingResponse.status).toBe(404); + expect(missingResponse.status).toBe(400); + expect(missingResponse.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalA.id]); }); @@ -221,7 +227,10 @@ describe("mission goal linkage routes", () => { JSON.stringify({ goalIds: [goalA.id, "G-404"] }), { "content-type": "application/json" }, ); - expect(missingResponse.status).toBe(404); + expect(missingResponse.status).toBe(400); + expect(missingResponse.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goalB.id, goalC.id]); }); @@ -247,7 +256,10 @@ describe("mission goal linkage routes", () => { expect(store.getMissionStore().listGoalIdsForMission(mission.id)).toEqual([goal.id]); const missingResponse = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`); - expect(missingResponse.status).toBe(404); + expect(missingResponse.status).toBe(400); + expect(missingResponse.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); }); it("removes a linked archived goal idempotently", async () => { @@ -305,7 +317,7 @@ describe("mission goal linkage routes", () => { expect(deleteBad.status).toBe(400); }); - it("returns 404 for missing mission or goal", async () => { + it("returns 404 for missing missions and unlinking unknown goals", async () => { const mission = store.getMissionStore().createMission({ title: "Ship mission" }); const goal = store.getGoalStore().createGoal({ title: "Goal A" }); @@ -325,7 +337,10 @@ describe("mission goal linkage routes", () => { expect(missingMissionPatch.status).toBe(404); const missingGoalAdd = await request(app, "POST", `/api/missions/${mission.id}/goals/G-404`); - expect(missingGoalAdd.status).toBe(404); + expect(missingGoalAdd.status).toBe(400); + expect(missingGoalAdd.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); const missingGoalSet = await request( app, @@ -334,7 +349,10 @@ describe("mission goal linkage routes", () => { JSON.stringify({ goalIds: [goal.id, "G-404"] }), { "content-type": "application/json" }, ); - expect(missingGoalSet.status).toBe(404); + expect(missingGoalSet.status).toBe(400); + expect(missingGoalSet.body).toMatchObject({ + details: { code: "GOAL_NOT_FOUND", goalId: "G-404" }, + }); const missingGoalDelete = await request(app, "DELETE", `/api/missions/${mission.id}/goals/G-404`); expect(missingGoalDelete.status).toBe(404); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 72e836440a..e424e5f487 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -332,7 +332,14 @@ export function createMissionRouter( } function requireLinkableGoal(goalId: string): Goal { - const goal = requireGoal(goalId); + if (!validateGoalId(goalId)) { + throw badRequest("Invalid goal ID format"); + } + + const goal = getScopedGoalStore().getGoal(goalId); + if (!goal) { + throw badRequest("Goal not found", { code: "GOAL_NOT_FOUND", goalId }); + } if (goal.status === "archived") { throw badRequest("Cannot link an archived goal", { code: "GOAL_ARCHIVED", goalId }); }