FN-5985: align mission goal link errors
Align mission goal link write paths on the documented GOAL_NOT_FOUND contract. - return 400 GOAL_NOT_FOUND from linkable goal validation instead of 404 when a goal is missing - update mission goal route tests to assert the new error payload across create, patch, set, and add flows - document the link-vs-unlink missing-goal behavior and add a patch changeset for the published CLI package Files changed: .../FN-5985-mission-goal-not-found-contract.md | 5 ++++ docs/missions.md | 2 +- .../__tests__/mission-goal-links-routes.test.ts | 32 +++++++++++++++++----- packages/dashboard/src/mission-routes.ts | 9 +++++- 4 files changed, 39 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-5985 Fusion-Task-Lineage: 5649266e-5813-41b4-8a54-c7c46df61a35
This commit is contained in:
5
.changeset/FN-5985-mission-goal-not-found-contract.md
Normal file
5
.changeset/FN-5985-mission-goal-not-found-contract.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user