// @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, expect, it, vi } from "vitest"; import { AgentChatReadProvider, useAgentChatRead } from "./useAgentChatRead"; import { ApiError } from "@/api/client"; const markRead = vi.hoisted(() => vi.fn()); vi.mock("@/api/issues", () => ({ issuesApi: { markRead } })); let root: Root, container: HTMLDivElement, client: QueryClient; let focused = true, latest = true, visible = true; const first = "11111111-1111-4111-8111-111111111111", second = "22222222-2222-4222-8222-222222222222"; function Message({ id }: { id: string }) { const ref = useAgentChatRead(id, "2026-10-30T12:00:00.123Z", true); return
Digest
; } async function render(ids = [first], companyId = "company-1", userId = "human-1", issueId = "chat-1") { await act(async () => root.render(
{ids.map(id => )}
)); await act(async () => { window.dispatchEvent(new Event("focus")); }); } beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1)); vi.stubGlobal("cancelAnimationFrame", vi.fn()); focused = true; latest = true; visible = true; vi.spyOn(document, "hasFocus").mockImplementation(() => focused); vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visible ? "visible" : "hidden"); vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(() => latest ? 100 : 300); vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(100); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ top: 0, bottom: 100, height: 100, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON() {} }); markRead.mockReset().mockResolvedValue({}); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); client.clear(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("reads only visible foreground messages at the bottom, including arrivals sharing a millisecond", async () => { focused = false; await render(); expect(markRead).not.toHaveBeenCalled(); focused = true; latest = false; await render(); expect(markRead).not.toHaveBeenCalled(); latest = true; await render(); expect(markRead).toHaveBeenLastCalledWith("chat-1", first); await render([first, second]); expect(markRead).toHaveBeenLastCalledWith("chat-1", second); await render([first, second]); expect(markRead).toHaveBeenCalledTimes(2); }); it("does not clear concurrent unseen arrivals while a read request is pending", async () => { let finish!: () => void; markRead.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); await render(); expect(markRead).toHaveBeenCalledTimes(1); focused = false; await render([first, second]); await act(async () => finish()); expect(markRead).toHaveBeenCalledTimes(1); focused = true; await render([first, second]); expect(markRead).toHaveBeenLastCalledWith("chat-1", second); }); it("automatically retries a failed visible message without another focus event", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValueOnce(new TypeError("Network unavailable")); await render(); expect(markRead).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(markRead).toHaveBeenCalledTimes(2); expect(markRead).toHaveBeenLastCalledWith("chat-1", first); expect(container.querySelector('[role="alert"]')).toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(markRead).toHaveBeenCalledTimes(2); }); it("retries only the seen cutoff when another message arrives out of view", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValueOnce(new TypeError("Network unavailable")); await render(); focused = false; await render([first, second]); await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(markRead).toHaveBeenCalledTimes(2); expect(markRead).toHaveBeenLastCalledWith("chat-1", first); focused = true; await render([first, second]); expect(markRead).toHaveBeenLastCalledWith("chat-1", second); }); it("coalesces a newly seen message into the pending retry", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValueOnce(new TypeError("Network unavailable")); await render(); await render([first, second]); expect(markRead).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(500); }); expect(markRead).toHaveBeenCalledTimes(2); expect(markRead).toHaveBeenLastCalledWith("chat-1", second); await render([first, second]); expect(markRead).toHaveBeenCalledTimes(2); }); it("respects the server retry delay for a rate-limited read", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValueOnce(new ApiError("Rate limited", 429, {}, { retryAfterMs: 2_000 })); await render(); await act(async () => { await vi.advanceTimersByTimeAsync(1_999); }); expect(markRead).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(1); }); expect(markRead).toHaveBeenCalledTimes(2); }); it("stops after bounded retries and recovers the retained cutoff on reconnect", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValue(new ApiError("Unavailable", 503, {})); await render(); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(markRead).toHaveBeenCalledTimes(3); expect(container.querySelector('[role="alert"]')?.textContent).toBe("Couldn’t mark this chat read."); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(markRead).toHaveBeenCalledTimes(3); markRead.mockResolvedValue({}); await act(async () => { window.dispatchEvent(new Event("online")); }); expect(markRead).toHaveBeenCalledTimes(4); expect(markRead).toHaveBeenLastCalledWith("chat-1", first); expect(container.querySelector('[role="alert"]')).toBeNull(); }); it.each(["company", "human", "chat", "unmount"])("cancels a pending retry after %s changes", async kind => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValue(new TypeError("Network unavailable")); await render(); expect(markRead).toHaveBeenCalledTimes(1); focused = false; if (kind === "unmount") await act(async () => { root.render(null); }); else await render([first], kind === "company" ? "company-2" : "company-1", kind === "human" ? "human-2" : "human-1", kind === "chat" ? "chat-2" : "chat-1"); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(markRead).toHaveBeenCalledTimes(1); }); it("resumes an exhausted cutoff when the chat becomes visible again", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValue(new TypeError("Network unavailable")); await render(); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(markRead).toHaveBeenCalledTimes(3); markRead.mockResolvedValue({}); visible = false; await act(async () => { document.dispatchEvent(new Event("visibilitychange")); }); expect(markRead).toHaveBeenCalledTimes(3); visible = true; await act(async () => { document.dispatchEvent(new Event("visibilitychange")); }); expect(markRead).toHaveBeenCalledTimes(4); expect(markRead).toHaveBeenLastCalledWith("chat-1", first); }); it("does not retry a denied read receipt", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); markRead.mockRejectedValue(new ApiError("Forbidden", 403, {})); await render(); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(markRead).toHaveBeenCalledTimes(1); expect(container.querySelector('[role="alert"]')?.textContent).toBe("Couldn’t mark this chat read."); }); it("discards an in-flight failure after the human changes", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); let fail!: (cause: Error) => void; markRead.mockImplementationOnce(() => new Promise((_, reject) => { fail = reject; })); await render(); focused = false; await render([first], "company-1", "human-2"); await act(async () => { fail(new TypeError("Network unavailable")); }); await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(markRead).toHaveBeenCalledTimes(1); expect(container.querySelector('[role="alert"]')).toBeNull(); });