import { afterEach, expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
const mocks = rs.hoisted(() => ({
fetch: rs.fn(),
isStatic: rs.fn(() => false),
}));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "/backend" }));
rs.mock("@/core/static-mode", () => ({
isStaticWebsiteOnly: mocks.isStatic,
}));
import {
useAttachProjectDocument,
useDeleteProjectDocument,
useInfiniteProjectDocuments,
useInfiniteProjectThreadFiles,
useProjectsConfig,
usePromoteThreadFile,
useUploadProjectDocument,
} from "@/core/projects/hooks";
const DOCUMENT = {
id: "doc-1",
name: "q3-report.pdf",
size_bytes: 1024,
sha256: "abc123",
content_missing: false,
source_thread_id: null,
source_kind: null,
source_name: null,
created_at: "2026-09-10T00:00:00+00:00",
updated_at: "2026-09-10T00:00:00+00:00",
};
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}
function wrapperFor(client: QueryClient) {
return function QueryWrapper({ children }: PropsWithChildren) {
return (
{children}
);
};
}
afterEach(() => {
cleanup();
rs.resetAllMocks();
mocks.isStatic.mockReturnValue(false);
});
test("useInfiniteProjectDocuments pages the shelf under the documents key", async () => {
const client = makeClient();
mocks.fetch
.mockResolvedValueOnce(
new Response(
JSON.stringify({
documents: [DOCUMENT],
total: 3,
limit: 100,
offset: 0,
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
documents: [DOCUMENT, DOCUMENT],
total: 3,
limit: 100,
offset: 1,
}),
),
);
const { result, unmount } = renderHook(
() => useInfiniteProjectDocuments("proj-1"),
{ wrapper: wrapperFor(client) },
);
try {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// total (3) exceeds the loaded count (1): another page exists.
expect(result.current.hasNextPage).toBe(true);
const [firstUrl] = mocks.fetch.mock.calls[0] as [string];
expect(firstUrl).toBe(
"/backend/api/projects/proj-1/documents?limit=100&offset=0",
);
await act(async () => {
await result.current.fetchNextPage();
});
const [nextUrl] = mocks.fetch.mock.calls[1] as [string];
expect(nextUrl).toBe(
"/backend/api/projects/proj-1/documents?limit=100&offset=1",
);
// All 3 rows are loaded across the two pages: no further page.
await waitFor(() =>
expect(
result.current.data?.pages.flatMap((page) => page.documents),
).toHaveLength(3),
);
expect(result.current.hasNextPage).toBe(false);
expect(client.getQueryData(["projects", "documents", "proj-1"])).toEqual(
result.current.data,
);
} finally {
unmount();
client.clear();
}
});
test("useProjectsConfig fetches the server limits under the config key", async () => {
const client = makeClient();
const body = { instructions_max_bytes: 16384, trash_retention_days: 7 };
mocks.fetch.mockResolvedValue(new Response(JSON.stringify(body)));
const { result, unmount } = renderHook(() => useProjectsConfig(), {
wrapper: wrapperFor(client),
});
try {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [url] = mocks.fetch.mock.calls[0] as [string];
expect(url).toBe("/backend/api/projects/config");
expect(client.getQueryData(["projects", "config"])).toEqual(body);
} finally {
unmount();
client.clear();
}
});
test("useInfiniteProjectThreadFiles pages the conversation-files view under its key", async () => {
const client = makeClient();
const body = { groups: [], next_offset: null, truncated: false };
mocks.fetch.mockResolvedValue(new Response(JSON.stringify(body)));
const { result, unmount } = renderHook(
() =>
useInfiniteProjectThreadFiles("proj-1", {
thread_limit: 10,
file_limit: 25,
}),
{ wrapper: wrapperFor(client) },
);
try {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [url] = mocks.fetch.mock.calls[0] as [string];
expect(url).toBe(
"/backend/api/projects/proj-1/thread-files?offset=0&thread_limit=10&file_limit=25",
);
expect(result.current.hasNextPage).toBe(false);
expect(
client.getQueryData([
"projects",
"thread-files",
"proj-1",
{ thread_limit: 10, file_limit: 25 },
]),
).toEqual(result.current.data);
} finally {
unmount();
client.clear();
}
});
test("useInfiniteProjectThreadFiles refetches every retained page on invalidation", async () => {
const client = makeClient();
const groupA = {
thread_id: "t-a",
display_name: "Chat A",
updated_at: "2026-09-10T00:00:00Z",
files: [],
truncated: false,
};
const groupB = {
thread_id: "t-b",
display_name: "Chat B",
updated_at: "2026-09-09T00:00:00Z",
files: [],
truncated: false,
};
const envelope = (groups: (typeof groupA)[], nextOffset: number | null) => ({
groups,
next_offset: nextOffset,
truncated: false,
});
mocks.fetch
.mockResolvedValueOnce(new Response(JSON.stringify(envelope([groupA], 1))))
.mockResolvedValueOnce(
new Response(JSON.stringify(envelope([groupB], null))),
);
const { result, unmount } = renderHook(
() => useInfiniteProjectThreadFiles("proj-1"),
{ wrapper: wrapperFor(client) },
);
const urls = () => mocks.fetch.mock.calls.map(([url]) => url as string);
try {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasNextPage).toBe(true);
await act(async () => {
await result.current.fetchNextPage();
});
await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
expect(result.current.hasNextPage).toBe(false);
expect(urls()).toEqual([
"/backend/api/projects/proj-1/thread-files?offset=0",
"/backend/api/projects/proj-1/thread-files?offset=1",
]);
// t-a is moved out of the project between page loads: the shifted
// boundary now answers page 1 with groupB and page 2 with nothing.
mocks.fetch
.mockResolvedValueOnce(
new Response(JSON.stringify(envelope([groupB], 1))),
)
.mockResolvedValueOnce(new Response(JSON.stringify(envelope([], null))));
await act(async () => {
await client.invalidateQueries({
queryKey: ["projects", "thread-files", "proj-1"],
});
});
// BOTH retained pages refetch — never only the current offset.
await waitFor(() => expect(urls()).toHaveLength(4));
expect(urls().slice(2)).toEqual([
"/backend/api/projects/proj-1/thread-files?offset=0",
"/backend/api/projects/proj-1/thread-files?offset=1",
]);
// The moved-out thread's group is gone, and the shifted boundary did
// not duplicate the surviving group.
const groups = result.current.data!.pages.flatMap((page) => page.groups);
expect(groups.map((group) => group.thread_id)).toEqual(["t-b"]);
} finally {
unmount();
client.clear();
}
});
test("document queries do not fire in static-demo mode", async () => {
mocks.isStatic.mockReturnValue(true);
const client = makeClient();
const { unmount } = renderHook(
() => ({
documents: useInfiniteProjectDocuments("proj-1"),
threadFiles: useInfiniteProjectThreadFiles("proj-1"),
}),
{ wrapper: wrapperFor(client) },
);
try {
// Give any (erroneously) enabled query a macrotask to fire.
await act(async () => {
// ES2022 lib has no Promise.withResolvers typing; executor form required.
await new Promise((resolve) => setTimeout(() => resolve(), 20));
});
expect(mocks.fetch).not.toHaveBeenCalled();
} finally {
unmount();
client.clear();
}
});
test("useUploadProjectDocument posts multipart and invalidates the projects prefix", async () => {
const client = makeClient();
const shelfKey = ["projects", "documents", "proj-1"];
client.setQueryData(shelfKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ document: DOCUMENT, deduplicated: false }), {
status: 201,
}),
);
const { result, unmount } = renderHook(
() => useUploadProjectDocument("proj-1"),
{ wrapper: wrapperFor(client) },
);
try {
await act(async () => {
await result.current.mutateAsync({
file: new File(["hello"], "notes.txt"),
});
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/backend/api/projects/proj-1/documents");
expect(init.method).toBe("POST");
expect(init.body).toBeInstanceOf(FormData);
expect(client.getQueryState(shelfKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
test("usePromoteThreadFile posts the from-thread body and invalidates projects", async () => {
const client = makeClient();
const shelfKey = ["projects", "documents", "proj-1"];
client.setQueryData(shelfKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ document: DOCUMENT, deduplicated: false })),
);
const { result, unmount } = renderHook(() => usePromoteThreadFile("proj-1"), {
wrapper: wrapperFor(client),
});
try {
await act(async () => {
await result.current.mutateAsync({
thread_id: "thread-1",
kind: "output",
name: "report.md",
});
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/backend/api/projects/proj-1/documents/from-thread");
expect(JSON.parse(init.body as string)).toEqual({
thread_id: "thread-1",
kind: "output",
name: "report.md",
});
expect(client.getQueryState(shelfKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
test("useAttachProjectDocument invalidates projects and threads caches", async () => {
const client = makeClient();
const shelfKey = ["projects", "documents", "proj-1"];
const threadsKey = ["threads", "searchInfinite", {}];
client.setQueryData(shelfKey, { documents: [], total: 0 });
client.setQueryData(threadsKey, { pages: [], pageParams: [] });
mocks.fetch.mockResolvedValue(
new Response(
JSON.stringify({
filename: "q3-report.pdf",
size_bytes: 1024,
virtual_path: "/mnt/user-data/uploads/q3-report.pdf",
artifact_url: "/api/threads/thread-1/artifacts/...",
}),
),
);
const { result, unmount } = renderHook(
() => useAttachProjectDocument("proj-1"),
{ wrapper: wrapperFor(client) },
);
try {
await act(async () => {
await result.current.mutateAsync({
documentId: "doc-1",
threadId: "thread-1",
});
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"/backend/api/projects/proj-1/documents/doc-1/attach-to-thread/thread-1",
);
expect(init.method).toBe("POST");
expect(client.getQueryState(shelfKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(threadsKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
test("useDeleteProjectDocument deletes and invalidates the projects prefix", async () => {
const client = makeClient();
const shelfKey = ["projects", "documents", "proj-1"];
client.setQueryData(shelfKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(new Response(null, { status: 204 }));
const { result, unmount } = renderHook(
() => useDeleteProjectDocument("proj-1"),
{ wrapper: wrapperFor(client) },
);
try {
await act(async () => {
await result.current.mutateAsync("doc-1");
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/backend/api/projects/proj-1/documents/doc-1");
expect(init.method).toBe("DELETE");
expect(client.getQueryState(shelfKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});