feat(FN-5328): guard malformed mission hierarchy data in MissionManager
- test(FN-5328): assert mission detail load error logging path - test(FN-5328): mock lucide icons in mission recovery regression - test(FN-5328): add malformed mission detail recovery regression - fix(FN-5328): guard malformed mission hierarchy payloads Fusion-Task-Id: FN-5328
This commit is contained in:
committed by
gsxdsm
parent
321c170b90
commit
13b564b0f6
@@ -472,6 +472,35 @@ function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: str
|
||||
return `Last activation ${getRelativeTime(lastActivityAt)}`;
|
||||
}
|
||||
|
||||
function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHierarchy {
|
||||
if (!Array.isArray(mission.milestones)) {
|
||||
throw new Error("Malformed mission detail response: missing milestones");
|
||||
}
|
||||
|
||||
return {
|
||||
...mission,
|
||||
milestones: mission.milestones.map((milestone) => {
|
||||
if (!Array.isArray(milestone.slices)) {
|
||||
throw new Error(`Malformed mission detail response: milestone ${milestone.id} is missing slices`);
|
||||
}
|
||||
|
||||
return {
|
||||
...milestone,
|
||||
slices: milestone.slices.map((slice) => {
|
||||
if (!Array.isArray(slice.features)) {
|
||||
throw new Error(`Malformed mission detail response: slice ${slice.id} is missing features`);
|
||||
}
|
||||
|
||||
return {
|
||||
...slice,
|
||||
features: slice.features,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) {
|
||||
const isActive = isInline || isOpen;
|
||||
const cacheSuffix = projectId ?? "";
|
||||
@@ -837,13 +866,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const loadMissionDetail = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
setDetailLoading(true);
|
||||
const data = await fetchMission(missionId, projectId);
|
||||
// Guard against malformed responses (e.g. test fetch fallbacks): without
|
||||
// a milestones array the detail view crashes on `.milestones.length`.
|
||||
if (!data || !Array.isArray((data as MissionWithHierarchy).milestones)) {
|
||||
setDetailLoading(false);
|
||||
return;
|
||||
const payload = await fetchMission(missionId, projectId);
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Malformed mission detail response");
|
||||
}
|
||||
|
||||
const data = normalizeMissionHierarchy(payload as MissionWithHierarchy);
|
||||
setSelectedMission(data);
|
||||
if (data.milestones.length > 0) {
|
||||
const firstMilestoneId = data.milestones[0].id;
|
||||
@@ -901,6 +929,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
setValidationTelemetry(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[MissionManager] loadMissionDetail:", err);
|
||||
addToast(getErrorMessage(err) || "Failed to load mission details", "error");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||
import { MissionManager } from "../MissionManager";
|
||||
|
||||
const mockFetchMissions = vi.fn();
|
||||
const mockFetchMission = vi.fn();
|
||||
const mockFetchMissionsHealth = vi.fn();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
const mockFetchMissionInterviewDrafts = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
||||
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
||||
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
||||
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span>X</span>,
|
||||
Plus: () => <span>+</span>,
|
||||
Pencil: () => <span>Pencil</span>,
|
||||
Trash2: () => <span>Trash</span>,
|
||||
ChevronRight: () => <span>ChevronRight</span>,
|
||||
ChevronDown: () => <span>ChevronDown</span>,
|
||||
ChevronLeft: () => <span>ChevronLeft</span>,
|
||||
Target: () => <span>Target</span>,
|
||||
Layers: () => <span>Layers</span>,
|
||||
Package: () => <span>Package</span>,
|
||||
Box: () => <span>Box</span>,
|
||||
Check: () => <span>Check</span>,
|
||||
Loader2: () => <span>Loader</span>,
|
||||
Link: () => <span>Link</span>,
|
||||
Unlink: () => <span>Unlink</span>,
|
||||
Play: () => <span>Play</span>,
|
||||
Square: () => <span>Square</span>,
|
||||
Sparkles: () => <span>Sparkles</span>,
|
||||
Zap: () => <span>Zap</span>,
|
||||
Activity: () => <span>Activity</span>,
|
||||
FileText: () => <span>FileText</span>,
|
||||
RefreshCw: () => <span>Refresh</span>,
|
||||
}));
|
||||
|
||||
describe("MissionManager malformed mission recovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockFetchMissions.mockResolvedValue([
|
||||
{ id: "M-001", title: "Malformed Mission", description: "", status: "planning", milestones: [] },
|
||||
]);
|
||||
mockFetchMissionsHealth.mockResolvedValue({});
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
|
||||
mockFetchMission.mockResolvedValue({
|
||||
id: "M-001",
|
||||
title: "Malformed Mission",
|
||||
description: "",
|
||||
status: "planning",
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
title: "Milestone missing slices",
|
||||
description: "",
|
||||
acceptanceCriteria: "",
|
||||
status: "planning",
|
||||
missionId: "M-001",
|
||||
// intentionally malformed
|
||||
slices: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("avoids page ErrorBoundary fallback and shows toast on malformed mission detail", async () => {
|
||||
const addToast = vi.fn();
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<MissionManager isInline isOpen onClose={() => {}} addToast={addToast} />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
await screen.findByText("Malformed Mission");
|
||||
fireEvent.click(screen.getByText("Malformed Mission"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/failed to load mission details|malformed/i), "error");
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[MissionManager] loadMissionDetail:",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(screen.getByTestId("mission-header-title")).toHaveTextContent("Missions");
|
||||
expect(screen.getByText("Malformed Mission")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user