Files
fusion/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts
gsxdsm efa9580b93 FN-8366: enforce end-to-end project scoping
Ensure asynchronous dashboard data and real-time streams remain bound to their active project context.

- Resolve SSE and badge WebSocket stores through the canonical project resolver
- Guard agents, artifacts, and documents hooks against stale project responses and events
- Add scoped cache handling, regression coverage, and architecture documentation

Files changed:
 docs/architecture.md                               |  7 ++++
 .../app/hooks/__tests__/useAgents.test.ts          | 24 +++++++++++
 .../app/hooks/__tests__/useArtifacts.test.ts       | 47 ++++++++++++++++++++++
 .../app/hooks/__tests__/useDocuments.test.ts       | 34 ++++++++++++++++
 packages/dashboard/app/hooks/useAgents.ts          | 47 +++++++++++++++++-----
 packages/dashboard/app/hooks/useArtifacts.ts       | 26 +++++++++---
 packages/dashboard/app/hooks/useDocuments.ts       | 18 ++++++++-
 .../dashboard/app/hooks/useProjectContextGuard.ts  | 38 +++++++++++++++++
 .../routes-context-project-identity.test.ts        | 12 ++++++
 packages/dashboard/src/server.ts                   | 21 +++++++---
 10 files changed, 251 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-8366
Fusion-Task-Lineage: 0fcd38c1-727f-4511-8209-5d70cbf9eb0d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-19 16:29:39 -07:00

289 lines
8.6 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";
import type { MarkdownFileEntry } from "../../api";
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);
}
function createDocumentsFetchMock(options: {
documents: TaskDocumentWithTask[];
projectFiles: MarkdownFileEntry[];
failProjectFiles?: boolean;
failDocuments?: boolean;
}) {
return vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/files/markdown-list")) {
if (options.failProjectFiles) {
return mockFetchResponse(false, { error: "Project files failed" }, 500);
}
return mockFetchResponse(true, { files: options.projectFiles });
}
if (url.includes("/documents")) {
if (options.failDocuments) {
return mockFetchResponse(false, { error: "Documents failed" }, 500);
}
return mockFetchResponse(true, options.documents);
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
}
describe("useDocuments", () => {
const originalFetch = globalThis.fetch;
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",
},
];
const mockProjectFiles: MarkdownFileEntry[] = [
{
path: "README.md",
name: "README.md",
size: 1024,
mtime: "2024-01-03T00:00:00.000Z",
},
{
path: "docs/CONTRIBUTING.md",
name: "CONTRIBUTING.md",
size: 900,
mtime: "2024-01-04T00:00:00.000Z",
},
];
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("loads task documents and project markdown files on mount", async () => {
globalThis.fetch = createDocumentsFetchMock({
documents: mockDocuments,
projectFiles: mockProjectFiles,
});
const { result } = renderHook(() => useDocuments());
expect(result.current.loading).toBe(true);
expect(result.current.documents).toEqual([]);
expect(result.current.projectFiles).toEqual([]);
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeNull();
expect(result.current.documents).toHaveLength(2);
expect(result.current.projectFiles).toHaveLength(2);
expect(result.current.projectFiles[0].name).toBe("README.md");
});
it("continues rendering documents when project file fetch fails", async () => {
globalThis.fetch = createDocumentsFetchMock({
documents: mockDocuments,
projectFiles: [],
failProjectFiles: true,
});
const { result } = renderHook(() => useDocuments());
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.documents).toHaveLength(2);
expect(result.current.projectFiles).toEqual([]);
expect(result.current.error).toBeNull();
});
it("surfaces task document fetch errors", async () => {
globalThis.fetch = createDocumentsFetchMock({
documents: [],
projectFiles: mockProjectFiles,
failDocuments: true,
});
const { result } = renderHook(() => useDocuments());
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("Documents failed");
expect(result.current.documents).toEqual([]);
expect(result.current.projectFiles).toHaveLength(2);
});
it("passes search query to document endpoint and filters project files client-side", async () => {
globalThis.fetch = createDocumentsFetchMock({
documents: mockDocuments,
projectFiles: mockProjectFiles,
});
const { rerender } = renderHook(
({ searchQuery }) => useDocuments({ searchQuery }),
{ initialProps: { searchQuery: undefined as string | undefined } },
);
await act(async () => {
await vi.runAllTimersAsync();
});
rerender({ searchQuery: "readme" });
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
await waitFor(() => {
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
const urls = mockFetch.mock.calls.map((call: unknown[]) => String(call[0]));
expect(urls.some((url) => url.includes("/documents?q=readme"))).toBe(true);
expect(urls.some((url) => url.includes("/files/markdown-list") && !url.includes("q=readme"))).toBe(true);
expect(urls.some((url) => url.includes("/project-files/md"))).toBe(false);
});
});
it("uses projectId for both document and project file requests", async () => {
globalThis.fetch = createDocumentsFetchMock({
documents: mockDocuments,
projectFiles: mockProjectFiles,
});
renderHook(() => useDocuments({ projectId: "proj-123" }));
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => {
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>;
const urls = mockFetch.mock.calls.map((call: unknown[]) => String(call[0]));
expect(urls.some((url) => url.includes("/documents") && url.includes("projectId=proj-123"))).toBe(true);
expect(urls.some((url) => url.includes("/files/markdown-list") && url.includes("projectId=proj-123"))).toBe(true);
});
});
it("clears project A workspace files immediately when switching to project B", async () => {
let resolveProjectBFileRequest: ((response: Response) => void) | undefined;
globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("projectId=project-b")) {
if (url.includes("/files/markdown-list")) {
return new Promise<Response>((resolve) => {
resolveProjectBFileRequest = resolve;
});
}
return new Promise<Response>(() => {});
}
return mockFetchResponse(true, url.includes("/files/markdown-list")
? { files: mockProjectFiles }
: mockDocuments);
});
const { result, rerender } = renderHook(
({ projectId }) => useDocuments({ projectId }),
{ initialProps: { projectId: "project-a" } },
);
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => expect(result.current.projectFiles).toEqual(mockProjectFiles));
rerender({ projectId: "project-b" });
await waitFor(() => expect(result.current.projectFiles).toEqual([]));
await act(async () => {
resolveProjectBFileRequest?.(await mockFetchResponse(true, { files: [] }));
});
});
it("cancels in-flight request on unmount", async () => {
const abortMock = vi.fn();
const originalAbortController = globalThis.AbortController;
globalThis.AbortController = class MockAbortController {
signal = {} as AbortSignal;
abort = abortMock;
} as unknown as typeof AbortController;
globalThis.fetch = vi.fn().mockReturnValue(
new Promise(() => {
// Keep pending to simulate in-flight requests
}),
);
const { unmount } = renderHook(() => useDocuments());
await act(async () => {
await Promise.resolve();
});
unmount();
expect(abortMock).toHaveBeenCalled();
globalThis.AbortController = originalAbortController;
});
});