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(); }); });