Files
fusion/packages/dashboard/app/hooks/__tests__/useInsights.test.ts
Fusion a5188bb5eb feat(FN-1708): add InsightsView UI with section rendering and action UX
- Add InsightsView component for viewing project insights grouped by category
- Create useInsights hook with refresh, dismiss, and createTask actions
- Add InsightCategory type extensions for features, competitive_analysis, research, and trends
- Integrate InsightsView into dashboard routes with proper navigation
- Add comprehensive tests for InsightsView and useInsights hook
- Add changeset for @gsxdsm/fusion
2026-04-15 16:18:50 -07:00

441 lines
14 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";
// Mock the API module
vi.mock("../../api", () => ({
fetchInsights: vi.fn(),
dismissInsight: vi.fn(),
triggerInsightRun: vi.fn(),
fetchInsightRuns: vi.fn(),
fetchInsightRun: vi.fn(),
getInsightCreateTaskData: vi.fn(),
}));
// 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,
triggerInsightRun,
fetchInsightRuns,
fetchInsightRun,
getInsightCreateTaskData,
} from "../../api";
const mockFetchInsights = vi.mocked(fetchInsights);
const mockDismissInsight = vi.mocked(dismissInsight);
const mockTriggerInsightRun = vi.mocked(triggerInsightRun);
const mockFetchInsightRuns = vi.mocked(fetchInsightRuns);
const mockFetchInsightRun = vi.mocked(fetchInsightRun);
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 five 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(5);
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",
status: "generated",
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",
status: "confirmed",
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",
status: "generated",
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",
status: "dismissed",
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 trigger manual insight run", async () => {
const mockRun = {
id: "RUN-1",
projectId: "project-1",
trigger: "manual" as const,
status: "pending" as const,
summary: null,
error: null,
insightsCreated: 0,
insightsUpdated: 0,
inputMetadata: {},
outputMetadata: {},
createdAt: "2024-01-01T00:00:00Z",
startedAt: null,
completedAt: null,
};
mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
mockFetchInsightRuns.mockResolvedValue({ runs: [] });
mockTriggerInsightRun.mockResolvedValue(mockRun);
mockFetchInsightRun.mockResolvedValue({ ...mockRun, status: "completed" });
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");
expect(result.current.isRunInFlight).toBe(false);
});
it("should handle run errors", 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);
});
// Test that runInsights throws
await expect(result.current.runInsights()).rejects.toThrow("Run failed");
});
});
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",
status: "generated",
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",
status: "generated",
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(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",
status: "generated",
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("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",
status: "generated",
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);
});
});
});