Files
kandev/apps/web/components/task/chat/use-session-read-tracking.test.ts
T
2026-09-07 19:22:57 +01:00

381 lines
17 KiB
TypeScript

import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskSession } from "@/lib/types/http";
const mockMarkSessionRead = vi.fn();
const mockUpdateSessionReadCursor = vi.fn();
type MockState = {
userSettings: { unreadDivider: boolean };
taskSessions: { items: Record<string, TaskSession> };
updateSessionReadCursor: typeof mockUpdateSessionReadCursor;
};
let mockState: MockState;
function session(overrides: Partial<TaskSession> = {}): TaskSession {
return {
id: "session-1",
task_id: "task-1",
state: "RUNNING",
started_at: "2026-06-27T00:00:00Z",
updated_at: "2026-06-27T00:00:00Z",
...overrides,
} as TaskSession;
}
const mockStoreApi = { getState: () => mockState };
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: MockState) => unknown) => selector(mockState),
useAppStoreApi: () => mockStoreApi,
}));
vi.mock("@/lib/api/domains/session-api", () => ({
markSessionRead: (...args: unknown[]) => mockMarkSessionRead(...args),
}));
import { useSessionReadTracking } from "./use-session-read-tracking";
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
mockState = {
userSettings: { unreadDivider: true },
taskSessions: { items: {} },
updateSessionReadCursor: mockUpdateSessionReadCursor,
};
mockMarkSessionRead.mockResolvedValue({ session_id: "session-1", last_read_message_id: "m2" });
});
describe("useSessionReadTracking", () => {
it("returns null while not visible and does not mark the session read", () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result } = renderHook(() => useSessionReadTracking("session-1", false, "m2"));
expect(result.current).toBeNull();
expect(mockMarkSessionRead).not.toHaveBeenCalled();
});
it("returns null and does not mark the session read when the user disables the divider", async () => {
mockState.userSettings.unreadDivider = false;
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result } = renderHook(() => useSessionReadTracking("session-1", true, "m2"));
expect(result.current).toBeNull();
await act(async () => {
await Promise.resolve();
});
expect(mockMarkSessionRead).not.toHaveBeenCalled();
});
it("does not capture an anchor before the session record has loaded into the store, capturing correctly once it does", async () => {
// Session absent entirely (still fetching) — must not treat this the
// same as "loaded, and legitimately has no prior cursor" (see the
// next test). Locking in a capture here would use undefined?.last_
// read_message_id ?? null as the answer, when the real answer isn't
// known yet.
const { result, rerender } = renderHook(
({ latest }: { latest: string | null }) => useSessionReadTracking("session-1", true, latest),
{ initialProps: { latest: null as string | null } },
);
expect(result.current).toBeNull();
expect(mockMarkSessionRead).not.toHaveBeenCalled();
// The session finishes loading with a real prior cursor already set
// (e.g. a fetch that resolved after this hook's first render).
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
rerender({ latest: "m3" });
expect(result.current).toBe("m1");
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m3"));
});
it("does not dispatch mark-read while the session hasn't loaded yet, even if latestMessageId already resolved", async () => {
// Reproduces a real mobile bug: unlike the dockview panel path (isVisible
// lags behind usePanelActive's async resolution, giving the session
// fetch a head start), mobile's isVisible is true from render one — so
// if the *messages* list resolves before the *session metadata* fetch
// does, latestMessageId can already be non-null while the session
// record itself is still missing from the store. The mark-read
// dispatch effect must not race ahead of the capture above and advance
// the cursor before this visit's real "prior" boundary is ever read —
// that would silently swallow the divider with no error.
const { rerender } = renderHook(
({ latest }: { latest: string | null }) => useSessionReadTracking("session-1", true, latest),
{ initialProps: { latest: "m5" as string | null } },
);
await act(async () => {
await Promise.resolve();
});
expect(mockMarkSessionRead).not.toHaveBeenCalled();
// Session loads with the real prior cursor — the capture (during
// render, before this commit's effects) must see this value, not
// whatever a premature dispatch might have already advanced it to.
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
rerender({ latest: "m5" });
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m5"));
});
it("captures null when the session has loaded but genuinely has no prior cursor (first-ever visit)", () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: undefined });
const { result } = renderHook(() => useSessionReadTracking("session-1", true, "m1"));
expect(result.current).toBeNull();
});
it("does not activate a divider when a new message arrives after opening at the cursor", () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result, rerender } = renderHook(
({ latest }: { latest: string }) => useSessionReadTracking("session-1", true, latest),
{ initialProps: { latest: "m1" } },
);
// Opening at the transcript tail has no unread boundary.
expect(result.current).toBeNull();
// The user stays on this chat panel and sends a new prompt. It must not
// turn the tail cursor captured at visit start into a "New" divider.
rerender({ latest: "m2" });
expect(result.current).toBeNull();
});
it("waits for the initial message load before preserving a genuine unread boundary", () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const trackRead = useSessionReadTracking as unknown as (
sessionId: string | null,
isVisible: boolean,
latestMessageId: string | null,
initialMessagesLoading: boolean,
) => string | null;
const { result, rerender } = renderHook(
({
latest,
initialMessagesLoading,
}: {
latest: string | null;
initialMessagesLoading: boolean;
}) => trackRead("session-1", true, latest, initialMessagesLoading),
{ initialProps: { latest: null as string | null, initialMessagesLoading: true } },
);
expect(result.current).toBeNull();
// The first complete transcript includes messages that pre-date this visit.
rerender({ latest: "m3", initialMessagesLoading: false });
expect(result.current).toBe("m1");
});
it("freezes the divider anchor at the cursor value from before this visit's advance", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result } = renderHook(() => useSessionReadTracking("session-1", true, "m3"));
// Anchor is the value that was current the instant the session became
// visible — "m1" — not wherever the cursor ends up after advancing.
expect(result.current).toBe("m1");
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m3"));
});
it("advances the cursor as new messages arrive while still visible, without moving the divider", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result, rerender } = renderHook(
({ latest }: { latest: string }) => useSessionReadTracking("session-1", true, latest),
{ initialProps: { latest: "m2" } },
);
expect(result.current).toBe("m1");
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m2"));
// Simulate the store reflecting the server's response to the first call.
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m2" });
rerender({ latest: "m4" });
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m4"));
// Divider anchor is unchanged — still the value from when the visit started.
expect(result.current).toBe("m1");
});
});
describe("useSessionReadTracking — mark-read dispatch", () => {
it("logs the session-scoped mark-read lifecycle", async () => {
const consoleDebug = vi.spyOn(console, "debug").mockImplementation(() => undefined);
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
try {
renderHook(() => useSessionReadTracking("session-1", true, "m2"));
await waitFor(() =>
expect(mockUpdateSessionReadCursor).toHaveBeenCalledWith("session-1", "m2"),
);
const output = consoleDebug.mock.calls.flat().join("\n");
expect(output).toContain("[messages:read-tracking] response applied sessionId=session-1");
} finally {
consoleDebug.mockRestore();
}
});
it("does not call markSessionRead again once the cursor already matches the latest message", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m2" });
renderHook(() => useSessionReadTracking("session-1", true, "m2"));
await act(async () => {
await Promise.resolve();
});
expect(mockMarkSessionRead).not.toHaveBeenCalled();
});
it("re-captures a fresh anchor after leaving and re-entering the session", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
const { result, rerender } = renderHook(
({ visible, latest }: { visible: boolean; latest: string }) =>
useSessionReadTracking("session-1", visible, latest),
{ initialProps: { visible: true, latest: "m2" } },
);
expect(result.current).toBe("m1");
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledTimes(1));
// Leave: cursor is now advanced to m2 server-side. Advance the clock
// past the hide-debounce (see use-session-read-tracking.ts) so the
// reset that lets the next show re-capture actually commits — a real
// navigate-away is far slower than the debounce window, and fake
// timers drive that deterministically rather than a real sleep.
vi.useFakeTimers();
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m2" });
rerender({ visible: false, latest: "m2" });
act(() => {
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
expect(result.current).toBeNull();
// More messages arrive while away, then the user navigates back in.
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m2" });
rerender({ visible: true, latest: "m4" });
// New anchor reflects where the user actually left off (m2), not the
// stale m1 from the first visit.
expect(result.current).toBe("m2");
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m4"));
});
it("logs and swallows a failed mark-read call instead of throwing", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
mockMarkSessionRead.mockRejectedValue(new Error("network error"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
renderHook(() => useSessionReadTracking("session-1", true, "m2"));
await waitFor(() => expect(consoleError).toHaveBeenCalled());
consoleError.mockRestore();
});
});
describe("useSessionReadTracking — response ordering", () => {
it("discards a stale mark-read response that resolves after a newer one, so the local cursor never regresses", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
// Two overlapping requests: an older m2 whose response we control via a
// deferred promise, and a newer m3 that resolves immediately. m3's
// response lands first, then m2's stale response resolves last.
type MarkReadResult = { session_id: string; last_read_message_id: string };
let resolveM2: ((value: MarkReadResult) => void) | undefined;
const m2Response = new Promise<MarkReadResult>((resolve) => {
resolveM2 = resolve;
});
mockMarkSessionRead.mockImplementation((_sessionId: string, messageId: string) => {
if (messageId === "m2") return m2Response;
return Promise.resolve({ session_id: "session-1", last_read_message_id: messageId });
});
const { rerender } = renderHook(
({ latest }: { latest: string }) => useSessionReadTracking("session-1", true, latest),
{ initialProps: { latest: "m2" } },
);
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m2"));
// A newer message arrives while m2's request is still in flight — this
// dispatches and resolves the m3 request before m2 settles.
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
rerender({ latest: "m3" });
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m3"));
await waitFor(() =>
expect(mockUpdateSessionReadCursor).toHaveBeenCalledWith("session-1", "m3"),
);
mockUpdateSessionReadCursor.mockClear();
// The delayed, now-stale m2 response finally resolves. It must be
// discarded rather than regressing the store back to m2.
resolveM2?.({ session_id: "session-1", last_read_message_id: "m2" });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockUpdateSessionReadCursor).not.toHaveBeenCalled();
});
// @covers AC-OFFICE-UNREAD-DIVIDER-001.3
// @covers AC-OFFICE-UNREAD-DIVIDER-001.4
it("applies a session response after another session dispatches", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
mockState.taskSessions.items["session-2"] = session({ last_read_message_id: "n1" });
type MarkReadResult = { session_id: string; last_read_message_id: string };
let resolveSessionOne: ((value: MarkReadResult) => void) | undefined;
const sessionOneResponse = new Promise<MarkReadResult>((resolve) => {
resolveSessionOne = resolve;
});
mockMarkSessionRead.mockImplementation((sessionId: string, messageId: string) => {
if (sessionId === "session-1") return sessionOneResponse;
return Promise.resolve({ session_id: sessionId, last_read_message_id: messageId });
});
const { rerender } = renderHook(
({ sessionId, latest }: { sessionId: string; latest: string }) =>
useSessionReadTracking(sessionId, true, latest),
{ initialProps: { sessionId: "session-1", latest: "m2" } },
);
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m2"));
rerender({ sessionId: "session-2", latest: "n2" });
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-2", "n2"));
await waitFor(() =>
expect(mockUpdateSessionReadCursor).toHaveBeenCalledWith("session-2", "n2"),
);
mockUpdateSessionReadCursor.mockClear();
resolveSessionOne?.({ session_id: "session-1", last_read_message_id: "m2" });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockUpdateSessionReadCursor).toHaveBeenCalledWith("session-1", "m2");
});
it("discards a response when an external hydration advances the cached cursor", async () => {
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m1" });
type MarkReadResult = { session_id: string; last_read_message_id: string };
let resolveSessionOne: ((value: MarkReadResult) => void) | undefined;
const sessionOneResponse = new Promise<MarkReadResult>((resolve) => {
resolveSessionOne = resolve;
});
mockMarkSessionRead.mockReturnValue(sessionOneResponse);
renderHook(() => useSessionReadTracking("session-1", true, "m2"));
await waitFor(() => expect(mockMarkSessionRead).toHaveBeenCalledWith("session-1", "m2"));
// A reconnect or another tab can hydrate a newer cursor while the HTTP
// response is in flight. The delayed response must not regress that cache.
mockState.taskSessions.items["session-1"] = session({ last_read_message_id: "m3" });
resolveSessionOne?.({ session_id: "session-1", last_read_message_id: "m2" });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockUpdateSessionReadCursor).not.toHaveBeenCalled();
});
});