import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChatWorkspace, ReadFailureAlert } from "./chat-workspace"; const mock = vi.hoisted(() => ({ channel: { id: "channel", kind: "public", name: "General", archivedAt: null as string | null, }, channels: { data: [] as unknown[], isLoading: false, isError: false, error: null as unknown, refetch: vi.fn(), }, resolved: { data: { id: "workspace" }, isLoading: false, isError: false, refetch: vi.fn(), }, history: { data: { messages: [] as unknown[], hasMore: false }, isLoading: false, isError: false, error: null as unknown, refetch: vi.fn(), }, thread: { data: null, isError: false, error: null, refetch: vi.fn() }, catchUp: { data: null, isError: false, error: null, refetch: vi.fn() }, summary: { data: { channels: [] as unknown[] }, error: null as unknown }, catchUpQuery: vi.fn(), replace: vi.fn(), recovery: { messages: [] as unknown[], error: null as unknown, refresh: vi.fn() }, })); vi.mock("./use-chat-mutation-recovery", () => ({ useChatMutationRecovery: () => mock.recovery })); vi.mock("next-auth/react", () => ({ useSession: () => ({ data: { user: { id: "user" } } }), })); vi.mock("@/lib/use-chat-subscription", () => ({ useChatSubscription: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mock.replace }), })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ cancelQueries: vi.fn(), removeQueries: vi.fn() }), })); vi.mock("@/lib/trpc", () => ({ api: { useUtils: () => ({ chat: {} }), workspaces: { resolve: { useQuery: () => mock.resolved } }, chat: { viewer: { useQuery: () => ({ data: { role: "member" } }) }, editMessage: { useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), }, deleteMessage: { useMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), }, listChannels: { useQuery: () => mock.channels }, unreadSummary: { useQuery: () => mock.summary }, history: { useQuery: () => mock.history }, threadHistory: { useQuery: () => mock.thread }, catchUp: { useQuery: (...args: unknown[]) => { mock.catchUpQuery(...args); return mock.catchUp; }, }, send: { useMutation: () => ({ mutateAsync: vi.fn() }) }, markChannelRead: { useMutation: () => ({ mutate: vi.fn() }) }, }, }, })); beforeEach(() => { mock.recovery.messages = []; mock.recovery.error = null; mock.channel.archivedAt = null; mock.channels.data = [mock.channel]; mock.channels.isLoading = false; mock.channels.isError = false; mock.resolved.isLoading = false; mock.resolved.isError = false; mock.history.data = { messages: [], hasMore: false }; mock.history.isLoading = false; mock.history.isError = false; mock.history.error = null; mock.summary.data = { channels: [] }; mock.catchUp.error = null; mock.catchUp.isError = false; mock.catchUpQuery.mockClear(); }); const render = () => renderToStaticMarkup(); describe("rendered chat workspace", () => { it("exposes a retryable read failure without claiming it advanced", () => { const html = renderToStaticMarkup(); expect(html).toContain('role="alert"'); expect(html).toContain("Unread status has not advanced"); expect(html).toContain("Retry"); }); it("renders an ordinary unread dot and numeric mentions/direct activity", () => { mock.channels.data = [ mock.channel, { id: "dm", kind: "direct", name: null, archivedAt: null }, ]; mock.summary.data = { channels: [ { channelId: "channel", unreadCount: 5, mentionCount: 0 }, { channelId: "dm", unreadCount: 3, mentionCount: 0 }, ], }; const html = render(); expect(html).toContain('aria-label="Unread messages"'); expect(html).toContain('aria-label="3 mentions or direct unread messages"'); expect(html).not.toContain(">5"); }); it("separates loading, transient failure and unavailable channel", () => { mock.channels.isLoading = true; expect(render()).toContain("Loading channel"); mock.channels.isLoading = false; mock.channels.isError = true; expect(render()).toContain("Could not load channel"); expect(render()).not.toContain("Channel unavailable"); mock.channels.isError = false; mock.channels.data = []; expect(render()).toContain("Channel unavailable"); }); it("shows history loading rather than a misleading empty state", () => { mock.history.isLoading = true; expect(render()).toContain("Loading messages"); expect(render()).not.toContain("No messages yet"); }); it("overlays a recovered tombstone on stale history without adding unloaded rows", () => { const original = { id: "m", sequence: "1", revision: 1, body: "secret original", authorUserId: "user", createdAt: "2026-01-01", status: "active" }; mock.history.data.messages = [original]; mock.recovery.messages = [{ ...original, revision: 3, body: "", status: "deleted", deletedAt: "2026-01-02" }, { ...original, id: "unloaded", sequence: "2", body: "unloaded edit" }]; const html = render(); expect(html).not.toContain("secret original"); expect(html).not.toContain("unloaded edit"); expect(html).toContain("deleted"); }); it("hides history when mutation recovery discovers revoked access", () => { mock.recovery.error = { data: { code: "NOT_FOUND" } }; expect(render()).toContain("Channel unavailable or access removed"); }); it("hides revoked content rather than showing a transient retry", () => { mock.history.error = { data: { code: "NOT_FOUND" } }; mock.history.isError = true; const html = render(); expect(html).toContain("Channel unavailable or access removed"); expect(html).not.toContain("Could not load messages"); }); it("shows transient history failure with retry", () => { mock.history.isError = true; const html = render(); expect(html).toContain("Could not load messages"); expect(html).toContain("Retry"); }); it("keeps archived channels readable without a channel composer", () => { mock.channel.archivedAt = "2026-01-01"; const html = render(); expect(html).toContain('aria-label="Conversation: General"'); expect(html).not.toContain('aria-label="Send message"'); }); it("bounds initial catch-up to the oldest root predecessor when complete root history has no replies", () => { mock.history.data = { messages: [ { id: "a", sequence: "12", replyCount: 0, body: "First", authorUserId: "user", createdAt: "2026-01-01", }, { id: "b", sequence: "30", replyCount: 0, body: "Second", authorUserId: "user", createdAt: "2026-01-01", }, ], hasMore: false, }; render(); expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({ after: "11", }); }); it("does not skip replies before the initial root page", () => { mock.history.data = { messages: [ { id: "a", sequence: "30", replyCount: 1, body: "root", authorUserId: "user", createdAt: "2026-01-01", }, ], hasMore: false, }; render(); expect(mock.catchUpQuery.mock.calls.at(-1)?.[0]).toMatchObject({ after: "0", }); }); });