import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../model.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const me = makeSession({ id: "me" });
const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
return { user, socket };
}
const n = {
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", 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("/users/fan");
expect(await link(/followed your playlist/)).toBe("/playlists/p1");
expect(await link(/^posted$/)).toBe("/dumps/d1");
expect(await link(/was added to/)).toBe("/dumps/d2");
expect(await link(/^upvoted$/)).toBe("/dumps/d3");
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
expect(screen.getByText("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getByText("Faves")).toBeInTheDocument();
});
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/notifications/read-all")).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/notifications/read-all", () => new TypeError("offline"));
open([n.follow]);
expect(await screen.findByText(/started following you/)).toBeInTheDocument();
await waitFor(() => expect(calls("POST /api/notifications/read-all")).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/notifications", () => fail("Database is locked", 500));
renderApp(, { session: me });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Database is locked");
expect(calls("POST /api/notifications/read-all")).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/notifications/read-all")).toHaveLength(2));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
);
});
test("load more", async () => {
route("GET /api/notifications", (req) =>
req.url.searchParams.get("page") === "1"
? page([n.follow], true)
: page([n.upvoted], false));
route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup();
renderApp(, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
});
});