- 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
521 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|