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(, { 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(, { 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(); route("GET /api/me/notifications", () => list.promise); route("POST /api/me/notifications/read", () => ({})); renderApp(, { 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(, { 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); }); });