Files
fusion/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts
Fusion 184b1f9667 feat(FN-1842): add Documents view with search and grouping
- Add GET /documents API endpoint in dashboard routes
- Add getAllDocuments store method with search support in core
- Add useDocuments hook and API wrapper for frontend consumption
- Add DocumentsView component with search, grouping by type/status, and sortable columns
- Add Documents nav item to Header and MobileNavBar with route /documents
- Integrate DocumentsView into App routing
- Add tests for DocumentsView and useDocuments hook
- Add changeset for @gsxdsm/fusion
2026-04-16 08:52:01 -07:00

285 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useDocuments } from "../useDocuments";
import type { TaskDocumentWithTask } from "@fusion/core";
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
describe("useDocuments", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("loads documents on mount", async () => {
const mockDocuments: TaskDocumentWithTask[] = [
{
id: "doc-1",
taskId: "KB-001",
key: "plan",
content: "Plan content",
revision: 1,
author: "user",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
taskTitle: "Task One",
taskColumn: "triage",
},
{
id: "doc-2",
taskId: "KB-002",
key: "notes",
content: "Notes content",
revision: 1,
author: "agent",
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
taskTitle: "Task Two",
taskColumn: "in-progress",
},
];
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, mockDocuments));
const { result } = renderHook(() => useDocuments());
// Initially loading should be true
expect(result.current.loading).toBe(true);
expect(result.current.documents).toEqual([]);
expect(result.current.error).toBeNull();
// Wait for the initial fetch to complete
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.documents).toHaveLength(2);
expect(result.current.documents[0].key).toBe("plan");
expect(result.current.documents[0].taskTitle).toBe("Task One");
});
it("handles empty documents list", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, []));
const { result } = renderHook(() => useDocuments());
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.documents).toEqual([]);
});
it("handles fetch error", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(false, { error: "Server error" }, 500));
const { result } = renderHook(() => useDocuments());
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("Server error");
expect(result.current.documents).toEqual([]);
});
it("refreshes documents manually", async () => {
const initialDocs: TaskDocumentWithTask[] = [
{
id: "doc-1",
taskId: "KB-001",
key: "plan",
content: "Initial content",
revision: 1,
author: "user",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
];
const updatedDocs: TaskDocumentWithTask[] = [
...initialDocs,
{
id: "doc-2",
taskId: "KB-001",
key: "notes",
content: "New content",
revision: 1,
author: "user",
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
},
];
// Mock for initial fetch + debounce effect (2 calls)
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(mockFetchResponse(true, initialDocs))
.mockResolvedValueOnce(mockFetchResponse(true, initialDocs))
.mockResolvedValueOnce(mockFetchResponse(true, updatedDocs));
const { result } = renderHook(() => useDocuments());
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.documents).toHaveLength(1);
// Trigger refresh
await act(async () => {
await result.current.refresh();
});
expect(result.current.documents).toHaveLength(2);
// Loading should still be false after manual refresh
expect(result.current.loading).toBe(false);
});
it("filters documents by search query with debounce", async () => {
const allDocs: TaskDocumentWithTask[] = [
{
id: "doc-1",
taskId: "KB-001",
key: "plan",
content: "Plan content",
revision: 1,
author: "user",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
taskTitle: "Task One",
},
{
id: "doc-2",
taskId: "KB-002",
key: "notes",
content: "Notes content",
revision: 1,
author: "user",
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
taskTitle: "Task Two",
},
];
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, allDocs));
const { result, rerender } = renderHook(
({ searchQuery }) => useDocuments({ searchQuery }),
{ initialProps: { searchQuery: undefined as string | undefined } }
);
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.documents).toHaveLength(2);
// Update search query - this triggers debounce
rerender({ searchQuery: "plan" });
// Advance past the debounce timer (300ms)
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should have called fetch multiple times (initial + debounced)
expect(globalThis.fetch).toHaveBeenCalled();
});
it("uses projectId for scoped fetching", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockFetchResponse(true, []));
renderHook(() => useDocuments({ projectId: "proj-123" }));
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalled();
});
// Verify the URL contains the projectId
const fetchCall = globalThis.fetch.mock.calls[0][0] as Request;
const url = fetchCall instanceof Request ? fetchCall.url : fetchCall;
expect(String(url)).toContain("projectId=proj-123");
});
it("cancels in-flight request on unmount", async () => {
const abortMock = vi.fn();
const originalAbortController = globalThis.AbortController;
// Mock AbortController to track abort calls
globalThis.AbortController = vi.fn().mockImplementation(() => ({
signal: {},
abort: abortMock,
})) as unknown as typeof AbortController;
globalThis.fetch = vi.fn().mockReturnValue(
new Promise(() => {
// Never resolve - simulating a pending request
})
);
const { unmount } = renderHook(() => useDocuments());
await act(async () => {
await Promise.resolve();
});
unmount();
expect(abortMock).toHaveBeenCalled();
// Restore
globalThis.AbortController = originalAbortController;
});
});