Files
fusion/packages/dashboard/app/hooks/__tests__/useRemoteNodeData.test.ts
gsxdsm 5c95d30c86 feat(FN-1529): implement search query propagation to remote node data
- Add search query propagation so remote nodes receive and use query filters
- Update api-node.ts to accept and forward search query parameters
- Add useRemoteNodeData query key dependency for proper re-fetching on filter changes
- Add api-node.test.ts with tests for search query propagation
- Add comprehensive tests for App component including remote data filtering
- Document search query propagation pitfall in .fusion/memory.md
2026-04-10 02:59:38 -07:00

521 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useRemoteNodeData } from "../useRemoteNodeData";
import * as apiNodeModule from "../../api-node";
vi.mock("../../api-node", () => ({
fetchRemoteNodeHealth: vi.fn(),
fetchRemoteNodeProjects: vi.fn(),
fetchRemoteNodeTasks: vi.fn(),
fetchRemoteNodeProjectHealth: vi.fn(),
}));
const mockFetchRemoteNodeHealth = vi.mocked(apiNodeModule.fetchRemoteNodeHealth);
const mockFetchRemoteNodeProjects = vi.mocked(apiNodeModule.fetchRemoteNodeProjects);
const mockFetchRemoteNodeTasks = vi.mocked(apiNodeModule.fetchRemoteNodeTasks);
const mockFetchRemoteNodeProjectHealth = vi.mocked(apiNodeModule.fetchRemoteNodeProjectHealth);
describe("useRemoteNodeData search query propagation", () => {
beforeEach(() => {
mockFetchRemoteNodeHealth.mockReset();
mockFetchRemoteNodeProjects.mockReset();
mockFetchRemoteNodeTasks.mockReset();
mockFetchRemoteNodeProjectHealth.mockReset();
// Default mock setup for successful fetch
mockFetchRemoteNodeHealth.mockResolvedValueOnce({
status: "online",
version: "1.0.0",
nodeId: "node_abc",
});
mockFetchRemoteNodeProjects.mockResolvedValueOnce([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it("forwards searchQuery to fetchRemoteNodeTasks when provided", async () => {
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks.mockResolvedValueOnce(mockTasks);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery: "test" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", "test");
expect(result.current.tasks).toEqual(mockTasks);
});
it("does not forward searchQuery when undefined", async () => {
mockFetchRemoteNodeTasks.mockResolvedValueOnce([]);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", undefined);
});
it("refetches tasks when searchQuery changes", async () => {
const initialTasks = [
{
id: "FN-001",
title: "Initial Task",
description: "Initial description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const filteredTasks = [
{
id: "FN-002",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(initialTasks)
.mockResolvedValueOnce(filteredTasks);
const { result, rerender } = renderHook(
({ searchQuery }: { searchQuery?: string }) =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery }),
{ initialProps: { searchQuery: undefined } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", undefined);
expect(result.current.tasks).toEqual(initialTasks);
// Update searchQuery
rerender({ searchQuery: "filtered" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "filtered");
expect(result.current.tasks).toEqual(filteredTasks);
});
it("refetches tasks when searchQuery is cleared", async () => {
const filteredTasks = [
{
id: "FN-001",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const allTasks = [
{
id: "FN-001",
title: "Filtered Task",
description: "Filtered description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "FN-002",
title: "Other Task",
description: "Other description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(filteredTasks)
.mockResolvedValueOnce(allTasks);
const { result, rerender } = renderHook(
({ searchQuery }: { searchQuery?: string }) =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery }),
{ initialProps: { searchQuery: "filtered" } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "filtered");
expect(result.current.tasks).toEqual(filteredTasks);
// Clear searchQuery
rerender({ searchQuery: "" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "");
expect(result.current.tasks).toEqual(allTasks);
});
it("refresh function re-fetches with current searchQuery", async () => {
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeTasks
.mockResolvedValueOnce(mockTasks)
.mockResolvedValueOnce(mockTasks);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001", searchQuery: "test" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "test");
// Call refresh
result.current.refresh();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeTasks).toHaveBeenLastCalledWith("node_abc", "proj_001", "test");
});
});
describe("useRemoteNodeData", () => {
beforeEach(() => {
mockFetchRemoteNodeHealth.mockReset();
mockFetchRemoteNodeProjects.mockReset();
mockFetchRemoteNodeTasks.mockReset();
mockFetchRemoteNodeProjectHealth.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("when nodeId is null", () => {
it("returns empty state without fetching", () => {
const { result } = renderHook(() => useRemoteNodeData(null));
expect(result.current.projects).toEqual([]);
expect(result.current.tasks).toEqual([]);
expect(result.current.health).toBe(null);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
// No API calls should have been made
expect(mockFetchRemoteNodeHealth).not.toHaveBeenCalled();
expect(mockFetchRemoteNodeProjects).not.toHaveBeenCalled();
});
it("returns empty state with projectId option but no nodeId", () => {
const { result } = renderHook(() => useRemoteNodeData(null, { projectId: "proj_001" }));
expect(result.current.projects).toEqual([]);
expect(result.current.tasks).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
// No API calls should have been made
expect(mockFetchRemoteNodeHealth).not.toHaveBeenCalled();
expect(mockFetchRemoteNodeTasks).not.toHaveBeenCalled();
});
});
describe("when nodeId is provided", () => {
it("fetches health and projects on mount", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(mockProjects);
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledWith("node_abc");
expect(mockFetchRemoteNodeProjects).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeProjects).toHaveBeenCalledWith("node_abc");
expect(result.current.health).toEqual(mockHealth);
expect(result.current.projects).toEqual(mockProjects);
});
it("fetches tasks and project health when projectId option is provided", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockTasks = [
{
id: "FN-001",
title: "Test Task",
description: "Test description",
column: "todo" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
},
];
const mockProjectHealth = {
activeTaskCount: 5,
inFlightAgentCount: 2,
status: "active" as const,
};
mockFetchRemoteNodeHealth.mockResolvedValueOnce(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(mockProjects);
mockFetchRemoteNodeTasks.mockResolvedValueOnce(mockTasks);
mockFetchRemoteNodeProjectHealth.mockResolvedValueOnce(mockProjectHealth);
const { result } = renderHook(() =>
useRemoteNodeData("node_abc", { projectId: "proj_001" }),
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeTasks).toHaveBeenCalledWith("node_abc", "proj_001", undefined);
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledTimes(1);
expect(mockFetchRemoteNodeProjectHealth).toHaveBeenCalledWith("node_abc", "proj_001");
expect(result.current.tasks).toEqual(mockTasks);
});
it("handles errors gracefully", async () => {
mockFetchRemoteNodeHealth.mockResolvedValueOnce({
status: "online",
version: "1.0.0",
nodeId: "node_abc",
});
mockFetchRemoteNodeProjects.mockRejectedValueOnce(new Error("Failed to fetch projects"));
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain("Failed to fetch projects");
});
it("handles health fetch errors", async () => {
mockFetchRemoteNodeHealth.mockRejectedValueOnce(new Error("Health check failed"));
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain("Health check failed");
});
it("refresh function re-fetches data", async () => {
const initialHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const initialProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(initialHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(initialProjects);
const { result } = renderHook(() => useRemoteNodeData("node_abc"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.health).toEqual(initialHealth);
// Set up new responses for refresh
const refreshedHealth = { status: "online", version: "1.1.0", nodeId: "node_abc" };
const refreshedProjects = [
{
id: "proj_002",
name: "New Project",
path: "/new/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValueOnce(refreshedHealth);
mockFetchRemoteNodeProjects.mockResolvedValueOnce(refreshedProjects);
// Call refresh
result.current.refresh();
await waitFor(() => {
expect(result.current.health).toEqual(refreshedHealth);
});
expect(result.current.projects).toEqual(refreshedProjects);
});
it("refetches when nodeId changes", async () => {
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
const mockProjects = [
{
id: "proj_001",
name: "Test Project",
path: "/test/path",
status: "active" as const,
isolationMode: "in-process" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
mockFetchRemoteNodeHealth.mockResolvedValue(mockHealth);
mockFetchRemoteNodeProjects.mockResolvedValue(mockProjects);
const { result, rerender } = renderHook(
({ nodeId }: { nodeId: string | null }) => useRemoteNodeData(nodeId),
{ initialProps: { nodeId: "node_abc" } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(1);
// Change nodeId
rerender({ nodeId: "node_xyz" });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should have fetched for the new nodeId
expect(mockFetchRemoteNodeHealth).toHaveBeenCalledTimes(2);
expect(mockFetchRemoteNodeHealth).toHaveBeenLastCalledWith("node_xyz");
});
});
});