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:
@@ -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" });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user