Submatter/apps/web/components/chat/chat-workspace.test.tsx

230 lines
7.5 KiB
TypeScript
Raw Permalink Normal View History

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(<ChatWorkspace workspace="acme" channelId="channel" />);
describe("rendered chat workspace", () => {
it("exposes a retryable read failure without claiming it advanced", () => {
const html = renderToStaticMarkup(<ReadFailureAlert onRetry={vi.fn()} />);
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</span>");
});
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",
});
});
});