Files
fusion/packages/dashboard/app/hooks/__tests__/useInsights.test.ts
Fusion 74e7aa8f0c feat(FN-3826): canonicalize reports plugin manifest ID
Canonicalizes the Fusion reports plugin's manifest ID and exports a reports schema alias from the package index, with tests aligned to the new identifiers.

Fusion-Task-Id: FN-3826
2026-05-11 06:47:43 -07:00

708 lines
23 KiB
TypeScript

/**
* useInsights Hook Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useInsights, INSIGHT_CATEGORIES } from "../useInsights";
import type { InsightCategory, InsightStatus } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInsights: vi.fn(),
dismissInsight: vi.fn(),
archiveInsight: vi.fn(),
unarchiveInsight: vi.fn(),
triggerInsightRun: vi.fn(),
fetchInsightRun: vi.fn(),
fetchInsightRuns: vi.fn(),
getInsightCreateTaskData: vi.fn(),
ApiRequestError: class ApiRequestError extends Error {
status: number;
details?: Record<string, unknown>;
constructor(message: string, status: number, details?: Record<string, unknown>) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.details = details;
}
},
}));
// Mock lucide-react icons used in the view
vi.mock("lucide-react", () => ({
RefreshCw: () => "RefreshCw",
Play: () => "Play",
X: () => "X",
Plus: () => "Plus",
AlertCircle: () => "AlertCircle",
CheckCircle: () => "CheckCircle",
Sparkles: () => "Sparkles",
Lightbulb: () => "Lightbulb",
Building: () => "Building",
Users: () => "Users",
LineChart: () => "LineChart",
TrendingUp: () => "TrendingUp",
MoreVertical: () => "MoreVertical",
ExternalLink: () => "ExternalLink",
Archive: () => "Archive",
Clock: () => "Clock",
FileText: () => "FileText",
}));
import {
fetchInsights,
dismissInsight,
archiveInsight,
unarchiveInsight,
triggerInsightRun,
fetchInsightRun,
fetchInsightRuns,
getInsightCreateTaskData,
ApiRequestError,
} from "../../api";
const mockFetchInsights = vi.mocked(fetchInsights);
const mockDismissInsight = vi.mocked(dismissInsight);
const mockArchiveInsight = vi.mocked(archiveInsight);
const mockUnarchiveInsight = vi.mocked(unarchiveInsight);
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
const mockFetchInsightRun = vi.mocked(fetchInsightRun);
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
const mockGetInsightCreateTaskData = vi.mocked(getInsightCreateTaskData);
describe("useInsights", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("should start with loading state", async () => {
mockFetchInsights.mockImplementation(() => new Promise(() => {})); // Never resolves
mockFetchInsightRuns.mockImplementation(() => new Promise(() => {}));
const { result } = renderHook(() => useInsights("project-1"));
expect(result.current.loading).toBe(true);
expect(result.current.sections).toHaveLength(INSIGHT_CATEGORIES.length);
expect(result.current.isRunInFlight).toBe(false);
});
it("should initialize with all canonical sections", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.sections).toHaveLength(INSIGHT_CATEGORIES.length);
expect(result.current.sections.map((s) => s.category)).toEqual(INSIGHT_CATEGORIES);
});
});
describe("fetching insights", () => {
it("should fetch and group insights by category", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Feature Insight 1",
content: "Content 1",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: "RUN-1",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
{
id: "INS-2",
projectId: "project-1",
title: "Architecture Insight 1",
content: "Content 2",
category: "architecture" as InsightCategory,
status: "confirmed" as InsightStatus,
fingerprint: "fp2",
provenance: { trigger: "manual" },
lastRunId: "RUN-1",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 2 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const featuresSection = result.current.sections.find((s) => s.category === "features");
const architectureSection = result.current.sections.find((s) => s.category === "architecture");
expect(featuresSection?.items).toHaveLength(1);
expect(featuresSection?.items[0].id).toBe("INS-1");
expect(architectureSection?.items).toHaveLength(1);
expect(architectureSection?.items[0].id).toBe("INS-2");
});
it("should filter out dismissed insights", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Active Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
{
id: "INS-2",
projectId: "project-1",
title: "Dismissed Insight",
content: "Content",
category: "features" as InsightCategory,
status: "dismissed" as InsightStatus,
fingerprint: "fp2",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const featuresSection = result.current.sections.find((s) => s.category === "features");
expect(featuresSection?.items).toHaveLength(1);
expect(featuresSection?.items[0].id).toBe("INS-1");
});
it("should handle fetch errors", async () => {
mockFetchInsights.mockRejectedValue(new Error("Network error"));
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("Network error");
});
});
describe("runInsights", () => {
it("should set latestRun from the newest run returned by fetchInsightRuns during refresh", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({
runs: [
{
id: "RUN-2",
projectId: "project-1",
trigger: "manual" as const,
status: "running" as const,
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:05Z",
completedAt: null,
},
{
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "completed" as const,
summary: "done",
error: null,
insightsCreated: 1,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: "2024-01-01T00:00:10Z",
},
],
});
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.latestRun?.id).toBe("RUN-2");
expect(result.current.latestRun?.status).toBe("running");
});
it("should trigger manual insight run and refresh when run completes", async () => {
const completedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "completed" as const,
summary: "Generated insights",
error: null,
insightsCreated: 3,
insightsUpdated: 1,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(completedRun);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.runInsights();
});
expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1", undefined, undefined);
expect(result.current.latestRun?.status).toBe("completed");
expect(result.current.isRunInFlight).toBe(false);
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchInsightRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("should surface failed run errors from triggerInsightRun response", async () => {
const failedRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "failed" as const,
summary: null,
error: "No working memory to analyze",
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:10Z",
completedAt: "2024-01-01T00:01:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(failedRun);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.runInsights();
});
expect(result.current.runError).toBe("No working memory to analyze");
expect(result.current.isRunInFlight).toBe(false);
// Initial load only; failed run should not trigger refresh.
expect(mockFetchInsights).toHaveBeenCalledTimes(1);
});
it("should hydrate latestRun and set friendly error on structured active-run conflict", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockFetchInsightRun.mockResolvedValue({
id: "RUN-2",
projectId: "project-1",
trigger: "manual",
status: "running",
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: null,
});
mockTriggerInsightRun.mockRejectedValue(
new ApiRequestError("backend raw conflict", 409, {
code: "ACTIVE_RUN_CONFLICT",
activeRunId: "RUN-2",
activeRunStatus: "running",
}),
);
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await expect(result.current.runInsights()).rejects.toBeInstanceOf(Error);
});
expect(mockFetchInsightRun).toHaveBeenCalledWith("RUN-2", "project-1");
expect(result.current.latestRun?.id).toBe("RUN-2");
expect(result.current.runError).toBe("Insight generation is already running");
expect(result.current.isRunInFlight).toBe(false);
});
it("should clear stale runError on orphan-recovery success path", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun
.mockRejectedValueOnce(new Error("Previous failure"))
.mockResolvedValueOnce({
id: "RUN-3",
projectId: "project-1",
trigger: "manual",
status: "completed",
summary: "Recovered and completed",
error: null,
insightsCreated: 1,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: "2024-01-01T00:00:01Z",
completedAt: "2024-01-01T00:00:10Z",
});
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await expect(result.current.runInsights()).rejects.toThrow("Previous failure");
});
expect(result.current.runError).toBe("Previous failure");
await act(async () => {
await result.current.runInsights();
});
expect(result.current.latestRun?.id).toBe("RUN-3");
expect(result.current.runError).toBeNull();
});
it("should propagate trigger errors and always clear in-flight state", async () => {
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockRejectedValue(new Error("Run failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let thrown: unknown = null;
await act(async () => {
try {
await result.current.runInsights();
} catch (error) {
thrown = error;
}
});
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toBe("Run failed");
expect(result.current.runError).toBe("Run failed");
expect(result.current.isRunInFlight).toBe(false);
});
});
describe("dismiss", () => {
it("should remove dismissed insight from local state", async () => {
const mockInsights = [
{
id: "INS-1",
projectId: "project-1",
title: "Test Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];
mockFetchInsights.mockResolvedValue({ insights: mockInsights, count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockDismissInsight.mockResolvedValue({ ...mockInsights[0], status: "dismissed" });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.dismiss("INS-1");
});
expect(mockDismissInsight).toHaveBeenCalledWith("INS-1", "project-1");
const featuresSection = result.current.sections.find((s) => s.category === "features");
expect(featuresSection?.items).toHaveLength(0);
});
it("should set error state on dismiss failure", async () => {
mockFetchInsights.mockResolvedValue({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "Test",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockDismissInsight.mockRejectedValue(new Error("Dismiss failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Test that dismiss throws
await expect(result.current.dismiss("INS-1")).rejects.toThrow("Dismiss failed");
});
});
describe("createTask", () => {
it("should return task data from insight", async () => {
const mockInsight = {
id: "INS-1",
projectId: "project-1",
title: "Implement Feature X",
content: "Detailed description of the feature",
category: "features" as const,
status: "generated" as const,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [mockInsight], count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockGetInsightCreateTaskData.mockResolvedValue({
success: true,
insight: mockInsight,
suggestedTitle: "Implement Feature X",
suggestedDescription: "Detailed description of the feature",
});
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let taskData: { title: string; description: string } | null = null;
await act(async () => {
taskData = await result.current.createTask("INS-1");
});
expect(mockGetInsightCreateTaskData).toHaveBeenCalledWith("INS-1", "project-1");
expect(mockArchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
expect(taskData).toEqual({
title: "Implement Feature X",
description: "Detailed description of the feature",
});
});
it("should set error state on createTask failure", async () => {
mockFetchInsights.mockResolvedValue({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "Test",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockGetInsightCreateTaskData.mockRejectedValue(new Error("Create task failed"));
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Test that createTask throws
await expect(result.current.createTask("INS-1")).rejects.toThrow("Create task failed");
});
});
describe("archive lifecycle", () => {
it("archives and unarchives an insight", async () => {
const insight = {
id: "INS-1",
projectId: "project-1",
title: "Keep me",
content: "Content",
category: "features" as InsightCategory,
status: "confirmed" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [insight], count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockArchiveInsight.mockResolvedValue({ ...insight, status: "archived" as InsightStatus });
mockUnarchiveInsight.mockResolvedValue({ ...insight, status: "confirmed" as InsightStatus });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.archive("INS-1");
});
expect(mockArchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
expect(result.current.archivedCount).toBe(1);
await act(async () => {
await result.current.unarchive("INS-1");
});
expect(mockUnarchiveInsight).toHaveBeenCalledWith("INS-1", "project-1");
expect(result.current.archivedCount).toBe(0);
});
it("toggleShowArchived hides and shows archived insights", async () => {
const archivedInsight = {
id: "INS-A",
projectId: "project-1",
title: "Archived insight",
content: "Hidden by default",
category: "features" as InsightCategory,
status: "archived" as InsightStatus,
fingerprint: "fpA",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
mockFetchInsights.mockResolvedValue({ insights: [archivedInsight], count: 1 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights("project-1"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.archivedCount).toBe(1);
expect(result.current.totalCount).toBe(0);
act(() => {
result.current.toggleShowArchived();
});
expect(result.current.showArchived).toBe(true);
expect(result.current.totalCount).toBe(1);
});
});
describe("refresh", () => {
it("should reload insights data", async () => {
mockFetchInsights
.mockResolvedValueOnce({ insights: [], count: 0 })
.mockResolvedValueOnce({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
const { result } = renderHook(() => useInsights());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Update mock for refresh
mockFetchInsights.mockResolvedValueOnce({
insights: [
{
id: "INS-1",
projectId: "project-1",
title: "New Insight",
content: "Content",
category: "features" as InsightCategory,
status: "generated" as InsightStatus,
fingerprint: "fp1",
provenance: { trigger: "manual" },
lastRunId: null,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
],
count: 1,
});
await act(async () => {
await result.current.refresh();
});
expect(mockFetchInsights).toHaveBeenCalledTimes(2);
});
});
});