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 {
useEmptyTrash,
useInfiniteTrashDocuments,
usePurgeDocument,
useRestoreDocument,
} from "@/core/trash/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("useInfiniteTrashDocuments pages the trash list under the trash key", async () => {
const client = makeClient();
const trashed = {
...DOCUMENT,
trashed_at: "2026-09-12T00:00:00+00:00",
trash_origin: null,
};
mocks.fetch
.mockResolvedValueOnce(
new Response(
JSON.stringify({
documents: [trashed],
total: 2,
limit: 100,
offset: 0,
}),
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
documents: [trashed],
total: 2,
limit: 100,
offset: 1,
}),
),
);
const { result, unmount } = renderHook(() => useInfiniteTrashDocuments(), {
wrapper: wrapperFor(client),
});
try {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.hasNextPage).toBe(true);
const [firstUrl] = mocks.fetch.mock.calls[0] as [string];
expect(firstUrl).toBe("/backend/api/trash/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/trash/documents?limit=100&offset=1");
await waitFor(() =>
expect(
result.current.data?.pages.flatMap((page) => page.documents),
).toHaveLength(2),
);
expect(result.current.hasNextPage).toBe(false);
expect(client.getQueryData(["trash", "documents"])).toEqual(
result.current.data,
);
} finally {
unmount();
client.clear();
}
});
test("useInfiniteTrashDocuments does not fire in static-demo mode", async () => {
mocks.isStatic.mockReturnValue(true);
const client = makeClient();
const { unmount } = renderHook(() => useInfiniteTrashDocuments(), {
wrapper: wrapperFor(client),
});
try {
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("useRestoreDocument posts and invalidates trash and projects caches", async () => {
const client = makeClient();
const trashKey = ["trash", "documents"];
const projectsKey = ["projects", "documents", "proj-1"];
client.setQueryData(trashKey, { documents: [], total: 0 });
client.setQueryData(projectsKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ outcome: "restored", document: DOCUMENT })),
);
const { result, unmount } = renderHook(() => useRestoreDocument(), {
wrapper: wrapperFor(client),
});
try {
await act(async () => {
await result.current.mutateAsync({ documentId: "doc-1" });
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/backend/api/trash/documents/doc-1/restore");
expect(init.method).toBe("POST");
expect(client.getQueryState(trashKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(projectsKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
test("usePurgeDocument posts and invalidates trash and projects caches", async () => {
const client = makeClient();
const trashKey = ["trash", "documents"];
const projectsKey = ["projects", "documents", "proj-1"];
client.setQueryData(trashKey, { documents: [], total: 0 });
client.setQueryData(projectsKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(new Response(null, { status: 204 }));
const { result, unmount } = renderHook(() => usePurgeDocument(), {
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/trash/documents/doc-1/purge");
expect(init.method).toBe("POST");
expect(client.getQueryState(trashKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(projectsKey)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
test("useEmptyTrash posts and invalidates only the trash cache", async () => {
const client = makeClient();
const trashKey = ["trash", "documents"];
const projectsKey = ["projects", "documents", "proj-1"];
client.setQueryData(trashKey, { documents: [], total: 0 });
client.setQueryData(projectsKey, { documents: [], total: 0 });
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ purged: 2 })));
const { result, unmount } = renderHook(() => useEmptyTrash(), {
wrapper: wrapperFor(client),
});
try {
await act(async () => {
await result.current.mutateAsync();
});
const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/backend/api/trash/purge");
expect(init.method).toBe("POST");
expect(client.getQueryState(trashKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(projectsKey)?.isInvalidated).toBe(false);
} finally {
unmount();
client.clear();
}
});