// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { getRecentTasksStorageKey, readRecentTasks, recordRecentTask, RECENT_TASKS_UPDATED_EVENT, } from "@/lib/recent-tasks"; import { RECENT_TASK_SUMMARY_CONCURRENCY, useRecentTasks } from "./useRecentTasks"; const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() })); vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } })); (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({ id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status, updatedAt: new Date(updatedAt), hiddenAt: null, projectId: null, }); const storageKey = getRecentTasksStorageKey("company-1", "user-1"); // Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim. // jsdom's Storage proxy does not support spying on instance methods. const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage; describe("recent task synchronization", () => { let container: HTMLDivElement; let root: Root; let clients: QueryClient[]; let renders: number; function RecentTasks({ name, companyId = "company-1", userId = "user-1" }: { name: string; companyId?: string; userId?: string }) { const { entries } = useRecentTasks({ companyId, userId }); renders += 1; // Fail promptly if a regression restores the cross-cache render loop. if (renders > 100) throw new Error("Recent Tasks did not settle"); return
{entries.map((entry) => `${entry.title}:${entry.status}`).join(",")}
; } function client(snapshot?: ReturnType) { const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); if (snapshot) { value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot); value.setQueryData(queryKeys.issues.summaryBatch("company-1", "user-1", [snapshot.id]), [snapshot]); } clients.push(value); return value; } async function flush() { await act(async () => { for (let index = 0; index < 5; index += 1) await new Promise((resolve) => setTimeout(resolve, 0)); }); } beforeEach(() => { localStorage.clear(); getIssue.mockReset(); summaries.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); clients = []; renders = 0; }); afterEach(async () => { await act(async () => root.unmount()); clients.forEach((value) => value.clear()); container.remove(); vi.restoreAllMocks(); }); it("settles independent stale and fresh query caches and displays the newer snapshot in both", async () => { const older = task(10, "Old title"); const newer = task(20, "New title", "done"); recordRecentTask(older, "user-1"); const staleClient = client(older); const freshClient = client(newer); const setItem = vi.spyOn(storageMethods, "setItem"); await act(async () => root.render(<> )); await flush(); expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("New title:done"); expect(container.querySelector('[data-tab="fresh"]')?.textContent).toBe("New title:done"); expect(setItem).toHaveBeenCalledTimes(1); expect(getIssue).not.toHaveBeenCalled(); const settledRenders = renders; await flush(); expect(renders).toBe(settledRenders); // A real query update still propagates after the two caches settle. await act(async () => { freshClient.setQueryData(queryKeys.issues.detail(newer.id), task(30, "Renamed", "done")); }); await flush(); expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("Renamed:done"); expect(readRecentTasks(storageKey, "company-1")[0]?.snapshotUpdatedAt).toBe(30); expect(setItem).toHaveBeenCalledTimes(2); }); it("receives storage updates without republishing cached data", async () => { const cached = task(20, "Cached", "done"); recordRecentTask(cached, "user-1"); await act(async () => root.render( , )); await flush(); // Simulate a concurrent write from another tab with an older snapshot. const incoming = { ...readRecentTasks(storageKey, "company-1")[0], title: "Older", snapshotUpdatedAt: 10 }; localStorage.setItem(storageKey, JSON.stringify([incoming])); const setItem = vi.spyOn(storageMethods, "setItem"); const publish = vi.fn(); window.addEventListener(RECENT_TASKS_UPDATED_EVENT, publish); try { await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); }); await flush(); expect(container.textContent).toBe("Cached:done"); expect(setItem).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); } finally { window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, publish); } }); it("ignores writes from the old application after migration", async () => { const legacyKey = "paperclip.recentTasks:company-1:user-1"; localStorage.setItem(legacyKey, JSON.stringify([{ ...task(10, "Legacy"), recordedAt: 100 }])); await act(async () => root.render( , )); await flush(); expect(container.textContent).toBe("Current:done"); const settledRenders = renders; await act(async () => { localStorage.setItem(legacyKey, "[]"); window.dispatchEvent(new StorageEvent("storage", { key: legacyKey })); window.dispatchEvent(new CustomEvent(RECENT_TASKS_UPDATED_EVENT, { detail: { storageKey: legacyKey, entries: [] } })); }); await flush(); expect(renders).toBe(settledRenders); expect(container.textContent).toBe("Current:done"); expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100); }); it.each([403, 500])("retains snapshots when the batch fails with %s", async (status) => { recordRecentTask(task(10, "Unavailable"), "user-1"); summaries.mockRejectedValue(new ApiError("Unavailable", status, null)); await act(async () => root.render( , )); await flush(); expect(readRecentTasks(storageKey, "company-1")).toHaveLength(1); expect(container.textContent).toBe("Unavailable:todo"); expect(summaries).toHaveBeenCalledTimes(1); expect(getIssue).not.toHaveBeenCalled(); }); it("fetches five tasks once without activating detail queries", async () => { const tasks = Array.from({ length: 5 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` })); tasks.forEach(issue => recordRecentTask(issue, "user-1")); summaries.mockResolvedValue(tasks); const cache = client(); await act(async () => root.render()); await flush(); expect(summaries).toHaveBeenCalledTimes(1); expect(summaries.mock.calls[0][1]).toEqual(tasks.map(issue => issue.id).sort()); expect(getIssue).not.toHaveBeenCalled(); expect(cache.getQueryCache().findAll({ queryKey: ["issues", "detail"] })).toEqual([]); }); it("does not fetch empty history", async () => { await act(async () => root.render()); await flush(); expect(summaries).not.toHaveBeenCalled(); }); it("fetches a 25-task history in one request", async () => { const tasks = Array.from({ length: 25 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(2, "0")}` })); tasks.forEach(issue => recordRecentTask(issue, "user-1")); summaries.mockResolvedValue(tasks); await act(async () => root.render()); await flush(); expect(summaries).toHaveBeenCalledTimes(1); expect(summaries.mock.calls[0][1]).toHaveLength(25); }); it.each([false, true])("refreshes long histories in parallel bounded requests without pruning on a partial failure (%s)", async fails => { // Three chunks, all in flight together within the concurrency limit. const count = ISSUE_SUMMARIES_MAX_IDS * 2 + 3; const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` })); tasks.forEach(issue => recordRecentTask(issue, "user-1")); const pending: Array<() => void> = []; summaries.mockImplementation(async (_company, ids: string[]) => { expect(ids.length).toBeLessThanOrEqual(ISSUE_SUMMARIES_MAX_IDS); const call = summaries.mock.calls.length; // Each chunk waits until the test releases it, so all three must be in flight together. await new Promise(resolve => pending.push(resolve)); if (fails && call === 2) throw new ApiError("Unavailable", 500, null); return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(10_000) })); }); await act(async () => root.render()); await flush(); expect(summaries).toHaveBeenCalledTimes(3); expect(pending).toHaveLength(3); pending.forEach(resolve => resolve()); await flush(); const entries = readRecentTasks(storageKey, "company-1"); expect(entries).toHaveLength(count); expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails); expect(getIssue).not.toHaveBeenCalled(); }); it("limits how many summary chunks are in flight at once", async () => { const count = ISSUE_SUMMARIES_MAX_IDS * 5; const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` })); tasks.forEach(issue => recordRecentTask(issue, "user-1")); const pending: Array<() => void> = []; summaries.mockImplementation(async (_company, ids: string[]) => { await new Promise(resolve => pending.push(resolve)); return tasks.filter(issue => ids.includes(issue.id)); }); await act(async () => root.render()); await flush(); expect(summaries).toHaveBeenCalledTimes(RECENT_TASK_SUMMARY_CONCURRENCY); while (pending.length) { pending.splice(0).forEach(resolve => resolve()); await flush(); } expect(summaries).toHaveBeenCalledTimes(5); expect(readRecentTasks(storageKey, "company-1")).toHaveLength(count); }); it("prunes successful omissions without resurrecting cached full details", async () => { const missing = task(10, "Removed"); recordRecentTask(missing, "user-1"); const cache = client(); cache.setQueryData(queryKeys.issues.detail(missing.id), task(30, "Cached secret")); summaries.mockResolvedValue([]); await act(async () => root.render()); await flush(); expect(readRecentTasks(storageKey, "company-1")).toEqual([]); expect(container.textContent).toBe(""); }); it("does not prune a task added while an older batch is pending", async () => { recordRecentTask(task(10, "Missing old task"), "user-1"); let resolveFirst!: (rows: unknown[]) => void; summaries.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; })) .mockReturnValue(new Promise(() => {})); await act(async () => root.render()); const added = { ...task(20, "New task"), id: "issue-2" }; await act(async () => { recordRecentTask(added, "user-1"); }); await act(async () => { resolveFirst([]); }); await flush(); expect(readRecentTasks(storageKey, "company-1").some(entry => entry.id === added.id)).toBe(true); expect(container.textContent).toContain("New task"); }); it("isolates an account switch while the old request is pending", async () => { recordRecentTask(task(10, "Old account task"), "user-1"); recordRecentTask({ ...task(20, "New account task"), id: "issue-2" }, "user-2"); let resolveOld!: (rows: unknown[]) => void; summaries.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; })) .mockResolvedValue([{ ...task(20, "New account task"), id: "issue-2" }]); const cache = client(); await act(async () => root.render()); await act(async () => root.render()); await act(async () => { resolveOld([task(30, "Old response")]); }); await flush(); expect(container.textContent).toBe("New account task:todo"); expect(readRecentTasks(getRecentTasksStorageKey("company-1", "user-2"), "company-1")[0]?.id).toBe("issue-2"); expect(summaries.mock.calls.map(call => call[1])).toEqual([["issue-1"], ["issue-2"]]); }); it("refreshes a cached omission when the user revisits an accessible task", async () => { const first = task(10, "First"); const second = { ...task(20, "Second"), id: "issue-2" }; recordRecentTask(first, "user-1"); recordRecentTask(second, "user-1"); summaries.mockResolvedValue([second]); const cache = client(); await act(async () => root.render()); await flush(); expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [second.id]))?.status).toBe("success"); summaries.mockResolvedValue([first, second]); await act(async () => { recordRecentTask(first, "user-1", 30); }); await flush(); expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]); expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); }); it("keeps a revisited task when refreshing its cached omission fails", async () => { const first = task(10, "First"); const second = { ...task(20, "Second"), id: "issue-2" }; recordRecentTask(first, "user-1"); recordRecentTask(second, "user-1"); summaries.mockResolvedValue([second]); const cache = client(); await act(async () => root.render()); await flush(); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); summaries.mockRejectedValue(new ApiError("Unavailable", 500, null)); await act(async () => { recordRecentTask(first, "user-1", 30); }); await flush(); expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]); expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [first.id, second.id]))?.status).toBe("error"); expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); }); it("prunes a revisited task when a fresh response repeats the cached omission", async () => { const first = task(10, "First"); const second = { ...task(20, "Second"), id: "issue-2" }; recordRecentTask(first, "user-1"); recordRecentTask(second, "user-1"); const rows = [second]; summaries.mockResolvedValue(rows); const cache = client(); await act(async () => root.render()); await flush(); await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); await act(async () => { recordRecentTask(first, "user-1", 30); }); await flush(); expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]); }); });