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;
|
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) => {
|
linkFeatureToTask: vi.fn((featureId: string, taskId: string) => {
|
||||||
const feature = features.get(featureId);
|
const feature = features.get(featureId);
|
||||||
if (!feature) throw new Error("Feature " + featureId + " not found");
|
if (!feature) throw new Error("Feature " + featureId + " not found");
|
||||||
@@ -183,6 +196,14 @@ function createMockMissionStore() {
|
|||||||
return updated;
|
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(),
|
reorderMilestones: vi.fn(),
|
||||||
reorderSlices: 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 () => {
|
it("should link feature to task", async () => {
|
||||||
const { app, missionStore } = buildApp();
|
const { app, missionStore } = buildApp();
|
||||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ import type {
|
|||||||
MilestoneCreateInput,
|
MilestoneCreateInput,
|
||||||
SliceCreateInput,
|
SliceCreateInput,
|
||||||
FeatureCreateInput,
|
FeatureCreateInput,
|
||||||
MissionStatus,
|
|
||||||
MilestoneStatus,
|
|
||||||
SliceStatus,
|
|
||||||
FeatureStatus,
|
|
||||||
InterviewState,
|
InterviewState,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -83,14 +79,14 @@ function validateDescription(desc: unknown): string | undefined {
|
|||||||
return desc.trim() || 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") {
|
if (!status || typeof status !== "string") {
|
||||||
throw new Error(`Status is required and must be one of: ${allowedStatuses.join(", ")}`);
|
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(", ")}`);
|
throw new Error(`Invalid status. Must be one of: ${allowedStatuses.join(", ")}`);
|
||||||
}
|
}
|
||||||
return status;
|
return status as TStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateInterviewState(state: unknown): InterviewState {
|
function validateInterviewState(state: unknown): InterviewState {
|
||||||
@@ -228,7 +224,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
updates.description = validateDescription(description);
|
updates.description = validateDescription(description);
|
||||||
}
|
}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
|
updates.status = validateStatus(status, MISSION_STATUSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
@@ -510,7 +506,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
updates.description = validateDescription(description);
|
updates.description = validateDescription(description);
|
||||||
}
|
}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updates.status = validateStatus(status, MILESTONE_STATUSES) as MilestoneStatus;
|
updates.status = validateStatus(status, MILESTONE_STATUSES);
|
||||||
}
|
}
|
||||||
if (dependencies !== undefined) {
|
if (dependencies !== undefined) {
|
||||||
updates.dependencies = validateStringArray(dependencies, "dependencies");
|
updates.dependencies = validateStringArray(dependencies, "dependencies");
|
||||||
@@ -768,7 +764,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
updates.description = validateDescription(description);
|
updates.description = validateDescription(description);
|
||||||
}
|
}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updates.status = validateStatus(status, SLICE_STATUSES) as SliceStatus;
|
updates.status = validateStatus(status, SLICE_STATUSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
@@ -955,7 +951,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
|
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
|
||||||
}
|
}
|
||||||
if (status !== undefined) {
|
if (status !== undefined) {
|
||||||
updates.status = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
|
updates.status = validateStatus(status, FEATURE_STATUSES);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
|
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user