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:
gsxdsm
2026-06-07 20:23:07 -07:00
parent d3a2c8863d
commit 2d81a95ad9
4 changed files with 39 additions and 9 deletions

View File

@@ -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);

View File

@@ -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 });
}