Files
gerbeur/src/test/components/chat/ChatModal.test.tsx
khannurien 134d1a0b97 v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:54:09 +00:00

512 lines
17 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatModal } from "../../../components/chat/ChatModal.tsx";
import type { ChatMessage } from "../../../../shared/chat.ts";
import { calls, fail, route } from "../../fetch.ts";
import { makeChatMessage, makeSession, wire } from "../../fixtures.ts";
import { renderApp } from "../../render.tsx";
import { FakeWebSocket } from "../../ws.ts";
const me = makeSession({ id: "me", username: "me" });
async function setup(
history: ChatMessage[] = [],
{
session = me,
welcome = {} as Record<string, unknown>,
focusId = undefined as string | undefined,
} = {},
) {
route("GET /api/chat", () => wire(history));
const onClose = vi.fn();
const user = userEvent.setup();
renderApp(<ChatModal onClose={onClose} focusId={focusId} />, { session });
const socket = FakeWebSocket.last;
socket.welcome(welcome);
await screen.findByRole("dialog", { name: "Chat" });
await waitFor(() =>
expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument()
);
const composer = screen.getByPlaceholderText("Type a message…");
return { user, socket, onClose, composer };
}
const sent = (socket: FakeWebSocket, type: string) =>
socket.messages.filter((m) => m.type === type);
describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000;
await setup([
makeChatMessage({
id: "1",
userId: "ann",
authorUsername: "ann",
body: "one",
createdAt: new Date(t0),
}),
makeChatMessage({
id: "2",
userId: "ann",
authorUsername: "ann",
body: "two",
createdAt: new Date(t0 + 1000),
}),
makeChatMessage({
id: "3",
userId: "bob",
authorUsername: "bob",
body: "three",
createdAt: new Date(t0 + 2000),
}),
]);
expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass(
"chat-message--grouped",
);
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
});
test("empty and failed states", async () => {
route("GET /api/chat", () => fail("down", 500), { once: true });
route("GET /api/chat", () => []);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await screen.findByText("Couldn't load the chat.");
await user.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByText("No messages yet. Say hello!");
});
test("a full page offers older messages", async () => {
const page = Array.from(
{ length: 50 },
(_, i) =>
makeChatMessage({
id: `m${i + 10}`,
body: `msg ${i + 10}`,
createdAt: new Date(Date.now() - (50 - i) * 1000),
}),
);
route(
"GET /api/chat",
(req) =>
req.url.searchParams.has("before")
? wire([
makeChatMessage({
id: "m0",
body: "the oldest",
createdAt: new Date(Date.now() - 999_000),
}),
])
: wire(page),
);
const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(
await screen.findByRole("button", { name: "Load older messages" }),
);
await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) =>
r.url.searchParams.has("before")
)!;
expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not
.toBeInTheDocument();
});
test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000);
await setup([
makeChatMessage({
id: "old",
body: "seen",
createdAt: new Date(Date.now() - 60_000),
}),
makeChatMessage({
id: "new1",
body: "unseen",
createdAt: new Date(Date.now() - 10_000),
}),
], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New");
expect(divider.nextElementSibling).toHaveTextContent("unseen");
});
test("a deep link opens on its message, flashed", async () => {
await setup([
makeChatMessage({ id: "m1", body: "hey @me" }),
makeChatMessage({ id: "m2", body: "later" }),
], { focusId: "m1" });
expect(screen.getByText(/hey/).closest(".chat-message")).toHaveClass(
"chat-message--flash",
);
expect(screen.getByText("later").closest(".chat-message")).not.toHaveClass(
"chat-message--flash",
);
});
test("reading at the bottom moves the read marker", async () => {
const { socket } = await setup([makeChatMessage({ body: "hi" })]);
await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1));
});
test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({
type: "chat_focus",
open: true,
});
});
});
describe("ChatModal: sending", () => {
test("Enter sends; the message shows as sending until its echo", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "hello there{Enter}");
expect(composer).toHaveValue("");
const [send] = sent(socket, "chat_send");
expect(send).toMatchObject({ body: "hello there" });
expect(screen.getByText("Sending…")).toBeInTheDocument();
socket.receive({
type: "chat_message",
nonce: send.nonce,
message: wire(
makeChatMessage({
id: "echo",
userId: "me",
authorUsername: "me",
body: "hello there",
createdAt: new Date(),
}),
),
});
expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument();
});
test("Shift+Enter is a new line; blank and over-long messages don't send", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two");
expect(composer).toHaveValue("line one\nline two");
await user.clear(composer);
await user.type(composer, " {Enter}");
expect(sent(socket, "chat_send")).toHaveLength(0);
await user.clear(composer);
await user.click(composer);
await user.paste("x".repeat(2001));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText("2001 / 2000")).toBeInTheDocument();
});
test("a failed send can be retried, taken back or dropped", async () => {
const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send");
socket.receive({
type: "error",
scope: "chat",
nonce: send.nonce,
message: "Slow down",
});
const failed = screen.getByText("Slow down").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send");
socket.receive({
type: "error",
scope: "chat",
nonce: retry.nonce,
message: "Still no",
});
await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops");
expect(screen.queryByText("Still no")).not.toBeInTheDocument();
});
test("typing is announced, and its end too", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "Date"],
shouldAdvanceTime: true,
});
try {
const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{
type: "chat_typing",
typing: true,
}]);
act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({
type: "chat_typing",
typing: false,
});
} finally {
vi.useRealTimers();
}
});
test("the draft and reply outlive the chat", async () => {
const first = await setup([
makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" }),
]);
await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought");
});
});
describe("ChatModal: replies, edits, deletes", () => {
const theirs = () =>
makeChatMessage({
id: "t1",
userId: "ann",
authorUsername: "ann",
body: "ask me **anything**",
createdAt: new Date(Date.now() - 20_000),
});
const mine = () =>
makeChatMessage({
id: "m1",
userId: "me",
authorUsername: "me",
body: "my words",
createdAt: new Date(Date.now() - 10_000),
});
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent(
"ask me anything",
);
await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({
body: "sure",
replyToId: "t1",
});
});
test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not
.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
});
test("moderators can delete anyone's", async () => {
await setup([theirs()], {
session: makeSession({ id: "mod", role: "moderator" }),
});
const t = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
});
test("Up in an empty composer edits your last message; Enter saves", async () => {
const { user, socket, composer } = await setup([theirs(), mine()]);
await user.click(composer);
await user.keyboard("{ArrowUp}");
const box = await screen.findByDisplayValue("my words");
await user.clear(box);
await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{
type: "chat_edit",
id: "m1",
body: "better words",
}]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
});
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not
.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Delete this message?",
);
await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete",
}),
);
expect(sent(socket, "chat_delete")).toEqual([{
type: "chat_delete",
id: "m1",
}]);
socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument();
});
test("a reply to a deleted message says so", async () => {
const { socket } = await setup([
theirs(),
makeChatMessage({
id: "r",
body: "a reply",
replyToId: "t1",
replyToAuthor: "ann",
replyToBody: "ask me",
}),
]);
socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button"))
.toBeDisabled();
});
});
describe("ChatModal: live", () => {
test("new messages arrive, and a mention of me stands out", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_message",
message: wire(
makeChatMessage({
id: "x",
body: "hey @me look",
createdAt: new Date(),
}),
),
});
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass(
"chat-message--mention",
);
});
test("@everyone stands out only when the server flagged it", async () => {
const { socket } = await setup();
for (
const [id, body, mentionsEveryone] of [
["e1", "@everyone typed by anyone", undefined],
["e2", "@everyone from a moderator", true],
] as const
) {
socket.receive({
type: "chat_message",
message: wire(
makeChatMessage({
id,
body,
createdAt: new Date(),
mentionsEveryone,
}),
),
});
}
const row = (text: RegExp) =>
screen.getByText(text).closest(".chat-message");
expect(row(/typed by anyone/)).not.toHaveClass("chat-message--mention");
expect(row(/from a moderator/)).toHaveClass("chat-message--mention");
expect(screen.getByText("@everyone")).toHaveClass("md-everyone");
});
test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo"))
.toHaveTextContent("🎉🎉");
});
test("who's typing", async () => {
const { socket } = await setup();
socket.receive({
type: "chat_typing_update",
users: [{ userId: "ann", username: "ann", hasAvatar: false }],
});
expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [
{ userId: "ann", username: "ann", hasAvatar: false },
{ userId: "bob", username: "bob", hasAvatar: false },
],
});
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({
type: "chat_typing_update",
users: [{ userId: "me", username: "me", hasAvatar: false }],
});
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
});
test("a failed edit or delete is shown until dismissed", async () => {
const { user, socket } = await setup();
socket.receive({ type: "error", scope: "chat", message: "Not allowed" });
expect(screen.getByRole("alert")).toHaveTextContent("Not allowed");
await user.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByText("Not allowed")).not.toBeInTheDocument();
});
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({
id: "o",
body: "kept",
createdAt: new Date(Date.now() - 50_000),
});
const edited = makeChatMessage({
id: "e",
body: "before",
createdAt: new Date(Date.now() - 40_000),
});
const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop();
expect(screen.getByText("Disconnected — reconnecting…"))
.toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(
old.createdAt.toISOString(),
);
});
});