feat(FN-676): fix mission route typing and add regression coverage

- Tighten mission route status validation with generic typing to preserve exact status unions
- Expand mission API end-to-end coverage for feature patching, invalid statuses, and task unlink support in the mock store
- Point dashboard Vitest core alias at the package index so mission route tests resolve the full mission store API
- Add a changeset for the sticky New Task modal action button position fix
This commit is contained in:
gsxdsm
2026-04-01 12:44:48 -07:00
parent 91b3bbf564
commit 3279da3db8
3 changed files with 79 additions and 13 deletions

View File

@@ -175,6 +175,19 @@ function createMockMissionStore() {
return updated;
}),
updateFeature: vi.fn((id: string, updates: Partial<MissionFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error("Feature " + id + " not found");
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
deleteFeature: vi.fn((id: string) => {
if (!features.has(id)) throw new Error("Feature " + id + " not found");
features.delete(id);
}),
linkFeatureToTask: vi.fn((featureId: string, taskId: string) => {
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
@@ -183,6 +196,14 @@ function createMockMissionStore() {
return updated;
}),
unlinkFeatureFromTask: vi.fn((featureId: string) => {
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
const updated = { ...feature, taskId: undefined, status: "defined" as const, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return updated;
}),
reorderMilestones: vi.fn(),
reorderSlices: vi.fn(),
@@ -391,7 +412,56 @@ describe("Mission API", () => {
});
});
describe("Feature linking", () => {
describe("Feature routes", () => {
it("should patch a feature status using a normalized featureId string", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
const res = await request(
app,
"PATCH",
`/api/missions/features/${feature.id}`,
JSON.stringify({ status: "triaged", acceptanceCriteria: "Shippable" }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.id).toBe(feature.id);
expect(res.body.status).toBe("triaged");
expect(res.body.acceptanceCriteria).toBe("Shippable");
expect(missionStore.updateFeature).toHaveBeenCalledWith(feature.id, {
status: "triaged",
acceptanceCriteria: "Shippable",
});
});
it("should reject invalid feature status values", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(500).json({ error: err.message });
});
const res = await request(
app,
"PATCH",
`/api/missions/features/${feature.id}`,
JSON.stringify({ status: "complete" }),
{ "content-type": "application/json" }
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Invalid status");
expect(missionStore.updateFeature).not.toHaveBeenCalled();
});
it("should link feature to task", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Test Mission" });

View File

@@ -23,10 +23,6 @@ import type {
MilestoneCreateInput,
SliceCreateInput,
FeatureCreateInput,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "@fusion/core";
import {
@@ -83,14 +79,14 @@ function validateDescription(desc: unknown): string | undefined {
return desc.trim() || undefined;
}
function validateStatus(status: unknown, allowedStatuses: readonly string[]): string {
function validateStatus<TStatus extends string>(status: unknown, allowedStatuses: readonly TStatus[]): TStatus {
if (!status || typeof status !== "string") {
throw new Error(`Status is required and must be one of: ${allowedStatuses.join(", ")}`);
}
if (!allowedStatuses.includes(status)) {
if (!allowedStatuses.includes(status as TStatus)) {
throw new Error(`Invalid status. Must be one of: ${allowedStatuses.join(", ")}`);
}
return status;
return status as TStatus;
}
function validateInterviewState(state: unknown): InterviewState {
@@ -228,7 +224,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
updates.status = validateStatus(status, MISSION_STATUSES);
}
if (Object.keys(updates).length === 0) {
@@ -510,7 +506,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, MILESTONE_STATUSES) as MilestoneStatus;
updates.status = validateStatus(status, MILESTONE_STATUSES);
}
if (dependencies !== undefined) {
updates.dependencies = validateStringArray(dependencies, "dependencies");
@@ -768,7 +764,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.description = validateDescription(description);
}
if (status !== undefined) {
updates.status = validateStatus(status, SLICE_STATUSES) as SliceStatus;
updates.status = validateStatus(status, SLICE_STATUSES);
}
if (Object.keys(updates).length === 0) {
@@ -955,7 +951,7 @@ export function createMissionRouter(store: TaskStore): Router {
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
}
if (status !== undefined) {
updates.status = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
updates.status = validateStatus(status, FEATURE_STATUSES);
}
if (Object.keys(updates).length === 0) {

View File

@@ -8,7 +8,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
},
},
test: {