Files
gerbeur/src/test/pages/Notifications.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

398 lines
13 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../../shared/notification.ts";
import { calls, deferred, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const me = makeSession({ id: "me" });
const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) {
route(
"GET /api/me/notifications",
(req) =>
req.url.searchParams.get("page") === "1"
? page(items, hasMore)
: page([]),
);
route("POST /api/me/notifications/read", () => ({}));
const user = userEvent.setup();
const view = renderApp(<Notifications />, {
session: me,
route: "/notifications",
});
const socket = FakeWebSocket.last;
socket.welcome({
unreadNotificationCount: items.filter((n) => !n.read).length,
});
return { user, socket, view };
}
/** The list as the page saves it on leaving, which a Back restores. */
const CACHE_KEY = "feed:notifications:me";
const n = {
follow: makeNotification({
type: "user_followed",
data: { followerId: "f", followerUsername: "fan" },
}),
playlistFollow: makeNotification({
type: "playlist_followed",
data: {
followerId: "f",
followerUsername: "fan",
playlistId: "p1",
playlistSlug: "p1",
playlistOwnerUsername: "bob",
playlistTitle: "Mix",
},
}),
posted: makeNotification({
type: "user_dump_posted",
data: {
dumperId: "a",
dumperUsername: "ann",
dumpId: "d1",
dumpSlug: "d1",
dumpOwnerUsername: "bob",
dumpTitle: "Song",
},
}),
added: makeNotification({
type: "playlist_dump_added",
data: {
dumpId: "d2",
dumpSlug: "d2",
dumpOwnerUsername: "bob",
dumpTitle: "Tune",
playlistId: "p",
playlistSlug: "p",
playlistOwnerUsername: "bob",
playlistTitle: "Mix",
},
}),
upvoted: makeNotification({
type: "dump_upvoted",
data: {
voterId: "v",
voterUsername: "bob",
dumpId: "d3",
dumpSlug: "d3",
dumpOwnerUsername: "bob",
dumpTitle: "Pic",
},
}),
commented: makeNotification({
type: "dump_commented",
data: {
commenterId: "c",
commenterUsername: "cy",
commentId: "c1",
dumpId: "d4",
dumpSlug: "d4",
dumpOwnerUsername: "bob",
dumpTitle: "Post",
},
}),
liked: makeNotification({
type: "comment_liked",
data: {
likerId: "l",
likerUsername: "lu",
commentId: "c2",
dumpId: "d5",
dumpSlug: "d5",
dumpOwnerUsername: "bob",
dumpTitle: "Thing",
},
}),
mentionComment: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "comment",
contextId: "c3",
contextTitle: "",
dumpId: "d6",
dumpSlug: "d6",
dumpOwnerUsername: "bob",
},
}),
mentionChat: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "chat",
contextId: "x",
contextTitle: "",
},
}),
mentionEveryone: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "chat",
contextId: "y",
contextTitle: "",
everyone: true,
},
}),
mentionPlaylist: makeNotification({
type: "user_mentioned",
data: {
mentionerId: "m",
mentionerUsername: "mo",
contextType: "playlist",
contextId: "p9",
playlistSlug: "faves",
playlistOwnerUsername: "bob",
contextTitle: "Faves",
},
}),
};
describe("Notifications", () => {
test("every kind reads right and links to its subject", async () => {
open(Object.values(n));
const link = async (text: RegExp) =>
(await screen.findByText(text)).closest("a")!.getAttribute("href");
expect(await link(/started following you/)).toBe("/@fan");
expect(await link(/followed your playlist/)).toBe("/@bob/playlists/p1");
expect(await link(/^posted$/)).toBe("/@bob/dumps/d1");
expect(await link(/was added to/)).toBe("/@bob/dumps/d2");
expect(await link(/^upvoted$/)).toBe("/@bob/dumps/d3");
expect(await link(/commented on/)).toBe("/@bob/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe(
"/@bob/dumps/d5#comment-c2",
);
const mentions = screen.getAllByText(/mentioned you in/).map((el) =>
el.closest("a")!.getAttribute("href")
);
expect(mentions).toEqual([
"/@bob/dumps/d6#comment-c3",
"/notifications?chat=x",
"/@bob/playlists/faves",
]);
expect(await link(/mentioned everyone in/)).toBe("/notifications?chat=y");
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getAllByText("the chat")).toHaveLength(2);
expect(screen.getByText("Faves")).toBeInTheDocument();
});
test("one about something since deleted reads, but links nowhere", async () => {
open([
makeNotification({
type: "dump_upvoted",
data: {
voterId: "v",
voterUsername: "vic",
dumpId: "gone",
dumpTitle: "Gone dump",
},
}),
]);
const item = (await screen.findByText(/^upvoted$/)).closest("li")!;
expect(item.querySelector("a.notification-item-link")).toBeNull();
expect(item).toHaveTextContent("Gone dump");
});
test("grouped by day", async () => {
open([
makeNotification({ createdAt: new Date() }),
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
]);
expect(await screen.findByRole("heading", { name: "Today" }))
.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Earlier" }))
.toBeInTheDocument();
});
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
expect(screen.getByText(/started following you/).closest("li"))
.toHaveClass("notification-item--unread");
expect(document.querySelector(".notifications-unread-pill"))
.toHaveTextContent("1 new");
});
test("a list that loaded stays shown if marking it read fails", async () => {
route("POST /api/me/notifications/read", () => new TypeError("offline"));
open([n.follow]);
expect(await screen.findByText(/started following you/))
.toBeInTheDocument();
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText(/started following you/)).toBeInTheDocument();
});
test("a failed load shows the server's reason", async () => {
route("GET /api/me/notifications", () => fail("Database is locked", 500));
renderApp(<Notifications />, { session: me });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Database is locked");
expect(calls("POST /api/me/notifications/read")).toHaveLength(0);
});
test("empty", async () => {
open([]);
expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument();
});
test("a new one arrives at the top while the page is open", async () => {
const { socket } = open([n.follow]);
await screen.findByText(/started following you/);
socket.receive({
type: "notification_created",
notification: wire(n.upvoted),
});
const items = document.querySelectorAll(".notification-item");
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
// Seen as it arrives: marked read, so the bell doesn't count it.
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(2)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
});
test("one missed while the socket was down shows after a reconnect, and is marked read", async () => {
const missed = { ...n.upvoted, createdAt: new Date() };
route("GET /api/me/notifications", () => page([n.follow]), { once: true });
const { socket } = open([missed, n.follow]);
await screen.findByText(/started following you/);
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome({ unreadNotificationCount: 1 });
const upvoted = await screen.findByText(/^upvoted$/);
expect(document.querySelector(".notification-item"))
.toContainElement(upvoted);
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(2)
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
});
test("one that arrives while the list is loading isn't lost", async () => {
const list = deferred<unknown>();
route("GET /api/me/notifications", () => list.promise);
route("POST /api/me/notifications/read", () => ({}));
renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: 1 });
await waitFor(() =>
expect(calls("GET /api/me/notifications")).toHaveLength(1)
);
// Created after the list was read, so the list won't have it — but the
// read-all after the load will mark it read.
socket.receive({
type: "notification_created",
notification: wire({ ...n.upvoted, createdAt: new Date() }),
});
// And one the list does have, sent while it was on its way: shown once.
socket.receive({
type: "notification_created",
notification: wire(n.follow),
});
list.resolve(page([n.follow]));
await screen.findByText(/started following you/);
const items = document.querySelectorAll(".notification-item");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread");
// The read-all after the load covered them: none is sent again.
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
);
await new Promise((r) => setTimeout(r, 50));
expect(calls("POST /api/me/notifications/read")).toHaveLength(1);
});
test("the next page loads as the end scrolls into view", async () => {
route(
"GET /api/me/notifications",
(req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false),
);
route("POST /api/me/notifications/read", () => ({}));
renderApp(<Notifications />, { session: me });
await screen.findByText(/started following you/);
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
expect(await screen.findByText(/^upvoted$/)).toBeInTheDocument();
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
});
test("leaving saves the list, with the ones marked read as read", async () => {
const { view } = open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument()
);
view.unmount();
const saved = JSON.parse(sessionStorage.getItem(CACHE_KEY)!);
expect(saved.items.map((x: Notification) => [x.id, x.read])).toEqual([
[n.follow.id, true],
[n.upvoted.id, true],
]);
});
test("going back restores the list and the scroll, with what came meanwhile on top", async () => {
sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({
items: [wire({ ...n.follow, read: true })],
page: 1,
hasMore: false,
scrollY: 420,
savedAt: Date.now(),
}),
);
const scrollTo = vi.spyOn(globalThis, "scrollTo");
const newer = { ...n.upvoted, createdAt: new Date(Date.now() + 1000) };
// A MemoryRouter's first entry is a POP, as after Back.
open([newer, { ...n.follow, read: true }]);
// Shown before the page is fetched again.
expect(screen.getByText(/started following you/)).toBeInTheDocument();
expect(scrollTo).toHaveBeenCalledWith(0, 420);
const upvoted = await screen.findByText(/^upvoted$/);
const items = document.querySelectorAll(".notification-item");
expect(items).toHaveLength(2);
expect(items[0]).toContainElement(upvoted);
expect(items[0]).toHaveClass("notification-item--unread");
expect(items[1]).not.toHaveClass("notification-item--unread");
await waitFor(() =>
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
);
expect(calls("GET /api/me/notifications")).toHaveLength(1);
});
});