import { afterEach, expect, rs, test } from "@rstest/core";
import {
QueryClient,
QueryClientProvider,
useQuery,
} 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() }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
import { useArchiveThread } from "@/core/threads/archive";
import { usePinThread } from "@/core/threads/hooks";
const original = {
thread_id: "chat",
metadata: { deerflow_pinned: true },
values: { title: "Report" },
updated_at: "2026-01-01T00:00:00Z",
};
afterEach(() => {
cleanup();
rs.clearAllMocks();
});
function setup() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const key = ["thread", "metadata", "chat", false];
client.setQueryData(key, original);
client.setQueryData(["threads", "searchInfinite", { archived: false }], {
pages: [[original]],
pageParams: [0],
});
const wrapper = ({ children }: PropsWithChildren) => (
{children}
);
return { client, key, ...renderHook(() => useArchiveThread(), { wrapper }) };
}
test("archive preserves the active snapshot and resets list pagination after success", async () => {
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { client, key, result } = setup();
await act(async () => {
await result.current.mutateAsync({ threadId: "chat", archived: true });
});
expect(mocks.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/threads/chat"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ metadata: { deerflow_archived: true } }),
}),
);
expect(client.getQueryData(key)).toEqual({
...original,
metadata: { deerflow_pinned: true, deerflow_archived: true },
});
expect(
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
).toBeUndefined();
client.clear();
});
test("failed archive keeps the visible thread and metadata unchanged", async () => {
mocks.fetch.mockRejectedValue(new Error("Unavailable"));
const { client, key, result } = setup();
await act(async () => {
await expect(
result.current.mutateAsync({ threadId: "chat", archived: true }),
).rejects.toThrow("Unavailable");
});
expect(client.getQueryData(key)).toEqual(original);
expect(
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
).toEqual({ pages: [[original]], pageParams: [0] });
client.clear();
});
test("archive restarts an initial metadata read cancelled by the mutation", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
let reads = 0;
const wrapper = ({ children }: PropsWithChildren) => (
{children}
);
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { result } = renderHook(
() => ({
metadata: useQuery({
queryKey: ["thread", "metadata", "chat", false],
queryFn: async () => {
reads += 1;
if (reads === 1) return new Promise(() => undefined);
return {
...original,
metadata: { ...original.metadata, deerflow_archived: true },
};
},
}),
mutation: useArchiveThread(),
}),
{ wrapper },
);
await waitFor(() => expect(reads).toBe(1));
await act(async () => {
await result.current.mutation.mutateAsync({
threadId: "chat",
archived: true,
});
});
await waitFor(() =>
expect(result.current.metadata.data?.metadata).toEqual({
deerflow_pinned: true,
deerflow_archived: true,
}),
);
client.clear();
});
test("a late pin response cannot roll back the confirmed archive flag", async () => {
const { client, key, result: archive } = setup();
const wrapper = ({ children }: PropsWithChildren) => (
{children}
);
const { result: pin } = renderHook(() => usePinThread(), { wrapper });
let finishPin!: (value: Response) => void;
mocks.fetch.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishPin = resolve;
}),
);
let pendingPin!: Promise;
act(() => {
pendingPin = pin.current.mutateAsync({ threadId: "chat", pinned: true });
});
await waitFor(() => expect(finishPin).toBeDefined());
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
await act(async () => {
await archive.current.mutateAsync({ threadId: "chat", archived: true });
});
await act(async () => {
finishPin(
new Response(
JSON.stringify({
metadata: { deerflow_pinned: true, deerflow_archived: false },
}),
),
);
await pendingPin;
});
expect(client.getQueryData(key)).toEqual({
...original,
metadata: { deerflow_pinned: true, deerflow_archived: true },
});
client.clear();
});
test("archive invalidates project-scoped thread lists on success", async () => {
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { client, result } = setup();
const projectListKey = [...PROJECTS_QUERY_KEY, "threads", "proj-1"];
client.setQueryData(projectListKey, {
pages: [[{ ...original }]],
pageParams: [0],
});
const invalidate = rs.spyOn(client, "invalidateQueries");
await act(async () => {
await result.current.mutateAsync({ threadId: "chat", archived: true });
});
// The project page's thread list changes membership with the archive flag;
// every other thread mutation invalidates this prefix (regression: archive
// was the lone path leaving an open project page stale).
const keys = invalidate.mock.calls.map(([filters]) => filters?.queryKey);
expect(keys).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
expect(client.getQueryState(projectListKey)?.isInvalidated).toBe(true);
client.clear();
});