230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
|
|
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",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|