// @vitest-environment jsdom // // The notification center's order: what asks for the reader comes first, whichever source it is // from, then one time-ordered stream; read rows older than a week fold under "Earlier (n)". It was // two fixed sections, the server's above this browser's, so an admin's "access requests waiting" // sat under every server row — week-old read "Download ready" notices among them. // i18n is key-passthrough, so labels read as keys. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { api, type AppNotification } from "../../lib/api"; import type { Notification as ClientNotif } from "../../lib/notifications"; const h = vi.hoisted(() => ({ server: [] as unknown[], client: [] as unknown[], })); vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string, o?: { count?: number }) => o?.count === undefined ? key : `${key}:${o.count}`, }), })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => vi.fn() })); vi.mock("../../lib/notifications", async (importOriginal) => ({ ...(await importOriginal()), subscribe: () => () => {}, getNotifications: () => h.client, getUnreadCount: () => 0, markAllRead: () => {}, })); vi.mock("../../features/navigation/navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }), })); vi.mock("../files/filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) })); vi.mock("../channels/channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }), })); vi.mock("../feed/feedFiltersContext", () => ({ useFeedFilters: () => ({}), useFeedFiltersActions: () => ({ setFilters: vi.fn() }), })); vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ ...(await importOriginal()), PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, })); import NotificationsPanel from "./NotificationsPanel"; import { focusNavItem, restoreNavPosition } from "../../lib/listNav"; // The server list goes through the real live query, so an optimistic patch of its cache is seen. beforeEach(() => { vi.spyOn(api, "notifications").mockImplementation(async () => ({ items: h.server as AppNotification[], total: h.server.length, })); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); const H = 3600 * 1000; const D = 24 * H; const server = (id: number, ageMs: number, read: boolean) => ({ id, type: "download_done", title: `Server ${id}`, body: null, data: { title: `Ready ${id}` }, read, dismissed: false, created_at: new Date(Date.now() - ageMs).toISOString(), }) as unknown as AppNotification; const client = (id: number, ageMs: number, extra: Partial = {}) => ({ id, level: "success", message: `Activity ${id}.`, requiresInteraction: false, ts: Date.now() - ageMs, read: true, dismissed: true, transient: false, repeat: 1, ...extra, }) as unknown as ClientNotif; function mount() { const qc = new QueryClient(); const tree = () => ( {/* The page's scroller, as in the app: Back finds a row in another of the page's lists. */}
); const { rerender } = render(tree()); return { qc, rerender: () => rerender(tree()) }; } // Every test has a "Ready 1" on screen once the server list has landed. const loaded = () => screen.findByText("Ready 1"); // The rows of the list under a section's heading. const listUnder = (heading: string) => [...screen.getByText(heading).closest("section")!.querySelectorAll("[data-nav-index]")].map( (r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+/)?.[0], ); // Every row on screen, in document order, by its text. const rows = () => [...document.querySelectorAll("[data-nav-index]")].map( (r) => (r.textContent ?? "").match(/Ready \d+|Activity \d+|common\.accessRequestsTitle/)?.[0], ); describe("NotificationsPanel · order", () => { it("puts a waiting access-requests nudge first, above every server row", async () => { h.server = [server(1, 1 * H, false), server(2, 1 * D, true)]; h.client = [ client(9, 5 * 60 * 1000, { level: "warning", title: "common.accessRequestsTitle", message: "1 pending", meta: { kind: "access-requests" }, }), client(8, 2 * D), ]; mount(); await loaded(); expect(rows()).toEqual(["common.accessRequestsTitle", "Ready 1", "Ready 2", "Activity 8"]); expect(screen.getByText("inbox.sectionNeedsYou:2")).toBeTruthy(); expect(screen.getByText("inbox.sectionRecent")).toBeTruthy(); }); it("folds read rows older than a week under Earlier (n), closed until opened", async () => { h.server = [server(1, 1 * H, true), server(2, 10 * D, true)]; h.client = [client(3, 9 * D)]; mount(); await loaded(); expect(rows()).toEqual(["Ready 1"]); const fold = screen.getByRole("button", { name: "inbox.earlier:2" }); expect(fold.getAttribute("aria-expanded")).toBe("false"); // A section heading like "Needs you" and "Recent", its text the disclosure's button. expect(fold.closest("h2")).not.toBeNull(); fireEvent.click(fold); expect(fold.getAttribute("aria-expanded")).toBe("true"); expect(rows()).toEqual(["Ready 1", "Activity 3", "Ready 2"]); }); it("Back to a row that folded away while the page was gone opens Earlier on it", async () => { // Read during the visit it was opened from, the row kept its place in Needs you; on the next // visit it is read and over a week old, and folded under Earlier the restore found no list // holding it — the focus fell on the neighbour at its old place. h.server = [server(1, 8 * D, false), server(2, 9 * D, true)]; h.client = []; const cancel = restoreNavPosition({ list: 0, lists: 2, index: 0, name: "inbox.downloadDone.title: Ready 2", }); mount(); await loaded(); await waitFor(() => expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 2/), ); cancel(); expect( screen.getByRole("button", { name: "inbox.earlier:1" }).getAttribute("aria-expanded"), ).toBe("true"); }); it("does not open Earlier for a namesake there while the row Back left is still in sight", async () => { // A video downloaded twice, a scheduler run that failed the same way again: names repeat. The // extra list took the place match away, the name stood twice on the page, and the focus fell to // with Earlier open for nothing. const twin = { ...server(3, 9 * D, true), data: { title: "Ready 1" } } as AppNotification; h.server = [server(1, 1 * H, true), twin]; h.client = []; const cancel = restoreNavPosition({ list: 0, lists: 1, index: 0, name: "inbox.downloadDone.title: Ready 1", }); mount(); await waitFor(() => expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 1/), ); cancel(); expect( screen.getByRole("button", { name: "inbox.earlier:1" }).getAttribute("aria-expanded"), ).toBe("false"); }); it("opens Earlier on a row whose title ends in a space, as Back saved it trimmed", async () => { // A caption cut at the title's length often ends on a space. const spaced = { ...server(2, 9 * D, true), data: { title: "Ready 2 " } } as AppNotification; h.server = [server(1, 8 * D, false), spaced]; h.client = []; const cancel = restoreNavPosition({ list: 0, lists: 2, index: 0, name: "inbox.downloadDone.title: Ready 2", }); mount(); await loaded(); await waitFor(() => expect(document.activeElement?.closest("[data-nav-index]")?.textContent).toMatch(/Ready 2/), ); cancel(); }); it("numbers each list's rows from 0 for the keyboard walk", async () => { h.server = [server(1, 1 * H, false), server(2, 2 * H, true), server(3, 10 * D, true)]; h.client = [client(4, 3 * H)]; mount(); await loaded(); fireEvent.click(screen.getByRole("button", { name: "inbox.earlier:1" })); const lists = [...document.querySelectorAll("[data-nav-list]")].map((l) => [...l.querySelectorAll("[data-nav-index]")].map((r) => r.getAttribute("data-nav-index")), ); expect(lists).toEqual([["0"], ["0", "1"], ["0"]]); }); it("a row marked read keeps its place in Needs you for the visit, in the same list", async () => { vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never); h.server = [server(1, 1 * H, false), server(2, 2 * H, true)]; h.client = []; const { rerender } = mount(); await loaded(); const before = screen.getByText("Ready 1").closest("[data-nav-index]"); fireEvent.click(within(before as HTMLElement).getByRole("button", { name: "inbox.markRead" })); h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; // what the refetch after the write reads await waitFor(() => expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(), ); await act(async () => rerender()); expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"]); // The same element, not a remount in another list: the keyboard focus stays on it. expect(screen.getByText("Ready 1").closest("[data-nav-index]")).toBe(before); }); it("Mark all read empties Needs you, the client notices of the visit included", async () => { vi.spyOn(api, "markAllNotificationsRead").mockImplementation(async () => { h.server = [server(1, 1 * H, true)]; return { ok: true } as never; }); h.server = [server(1, 1 * H, false)]; h.client = [client(2, 2 * H, { read: false })]; const { rerender } = mount(); await loaded(); expect(listUnder("inbox.sectionNeedsYou:2")).toEqual(["Ready 1", "Activity 2"]); h.client = [client(2, 2 * H, { read: true })]; // what the store's markAllRead leaves fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" })); await act(async () => rerender()); expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull(); await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled()); expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1", "Activity 2"]); }); it("marking a row read from its button hands the focus to the row, not ", async () => { vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never); h.server = [server(1, 1 * H, false)]; h.client = []; mount(); await loaded(); const row = screen.getByText("Ready 1").closest("[data-nav-index]")!; const button = within(row).getByRole("button", { name: "inbox.markRead" }); button.focus(); h.server = [server(1, 1 * H, true)]; // what the refetch after the write reads fireEvent.click(button); await waitFor(() => expect(screen.queryByRole("button", { name: "inbox.markRead" })).toBeNull(), ); expect(row.isConnected).toBe(true); expect(document.activeElement).toBe(row); }); it("a band of rows already read still offers Mark all read, which empties it", async () => { // Opening the center marked this browser's notices read: the band holds them, and nothing on // the page is unread any more. h.server = [server(1, 1 * H, true)]; h.client = [client(2, 2 * H, { read: false })]; const { rerender } = mount(); await loaded(); h.client = [client(2, 2 * H, { read: true })]; // what the mount's markAllRead leaves await act(async () => rerender()); expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Activity 2"]); const button = screen.getByRole("button", { name: "inbox.markAllRead" }); // Unavailable reads either way: natively while unfocused, aria-disabled while it holds the focus. expect(button.hasAttribute("disabled") || button.getAttribute("aria-disabled") === "true").toBe( false, ); fireEvent.click(button); await act(async () => rerender()); expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull(); }); it("a read that lands before Mark all read commits does not put the rows back", async () => { let commit!: () => void; vi.spyOn(api, "markAllNotificationsRead").mockImplementation( () => new Promise((resolve) => (commit = () => resolve({ ok: true } as never))), ); h.server = [server(1, 1 * H, false)]; h.client = []; const { qc } = mount(); await loaded(); fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" })); await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled()); // The poll (or another row's write) reads the list before the server has committed the write. void qc.invalidateQueries({ queryKey: ["notifications"] }); await waitFor(() => expect(qc.getQueryData<{ items: AppNotification[] }>(["notifications"])?.items[0]?.read).toBe( false, ), ); h.server = [server(1, 1 * H, true)]; await act(async () => commit()); await waitFor(() => expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull()); expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"]); }); it("the rows stay out of the band until the read after the write has landed", async () => { // The write settling is not the list being fresh: a refetch over the network takes a moment, // and until it lands the cache still holds what the read before the commit said. let commit!: () => void; vi.spyOn(api, "markAllNotificationsRead").mockImplementation( () => new Promise((resolve) => (commit = () => resolve({ ok: true } as never))), ); h.server = [server(1, 1 * H, false)]; h.client = []; const { qc } = mount(); await loaded(); fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" })); await waitFor(() => expect(api.markAllNotificationsRead).toHaveBeenCalled()); void qc.invalidateQueries({ queryKey: ["notifications"] }); await waitFor(() => expect(qc.getQueryData<{ items: AppNotification[] }>(["notifications"])?.items[0]?.read).toBe( false, ), ); let land!: () => void; vi.mocked(api.notifications).mockImplementation( () => new Promise((resolve) => { land = () => resolve({ items: [server(1, 1 * H, true)], total: 1 }); }), ); await act(async () => commit()); await act(() => new Promise((r) => setTimeout(r, 30))); expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull(); await act(async () => land()); await waitFor(() => expect(listUnder("inbox.sectionRecent")).toEqual(["Ready 1"])); expect(screen.queryByText(/inbox\.sectionNeedsYou/)).toBeNull(); }); it("does not offer Mark all read for a band that only holds what asks by its kind", async () => { // An access-requests nudge stays up for as long as requests wait: the gesture cannot empty // that band, and a button that does nothing when pressed is not offered. h.server = [server(1, 1 * H, true)]; h.client = [ client(9, 5 * 60 * 1000, { level: "warning", title: "common.accessRequestsTitle", message: "1 pending", meta: { kind: "access-requests" }, }), ]; mount(); await loaded(); expect(screen.getByText("inbox.sectionNeedsYou:1")).toBeTruthy(); const button = screen.getByRole("button", { name: "inbox.markAllRead" }); expect(button.hasAttribute("disabled") || button.getAttribute("aria-disabled") === "true").toBe( true, ); }); it("a refused Mark all read brings the rows back at once, not at the next poll", async () => { vi.spyOn(api, "markAllNotificationsRead").mockRejectedValue(new Error("offline")); h.server = [server(1, 1 * H, false)]; h.client = []; mount(); await loaded(); const reads = vi.mocked(api.notifications).mock.calls.length; fireEvent.click(screen.getByRole("button", { name: "inbox.markAllRead" })); await waitFor(() => expect(vi.mocked(api.notifications).mock.calls.length).toBeGreaterThan(reads), ); await waitFor(() => expect(listUnder("inbox.sectionNeedsYou:1")).toEqual(["Ready 1"])); }); it("says a row is unread while the keyboard is on it; its name for Back stays the same", async () => { // The unread dot is only drawn: a read row held in Needs you sounded the same as an unread one. vi.spyOn(api, "markNotificationRead").mockResolvedValue({ ok: true } as never); h.server = [server(1, 1 * H, false), server(2, 2 * H, true)]; h.client = []; mount(); await loaded(); const spoken = (text: string) => { const row = screen.getByText(text).closest("[data-nav-index]")!; act(() => focusNavItem(row.closest("section")!, Number(row.getAttribute("data-nav-index")))); return [ document.activeElement?.getAttribute("aria-label"), row.getAttribute("data-nav-name"), ]; }; expect(spoken("Ready 1")).toEqual(["inbox.unreadName", "inbox.downloadDone.title: Ready 1"]); expect(spoken("Ready 2")).toEqual([ "inbox.downloadDone.title: Ready 2", "inbox.downloadDone.title: Ready 2", ]); // Marked read from its button, the row takes the focus before it reads read: what it is called // follows once it does. const row = screen.getByText("Ready 1").closest("[data-nav-index]")!; const button = within(row).getByRole("button", { name: "inbox.markRead" }); button.focus(); h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; fireEvent.click(button); await waitFor(() => expect(within(row).queryByRole("button", { name: "inbox.markRead" })).toBeNull(), ); expect(document.activeElement).toBe(row); await waitFor(() => expect(row.getAttribute("aria-label")).toBe("inbox.downloadDone.title: Ready 1"), ); }); it("a row marked read reads read as it takes the focus, not when the write lands", async () => { // The button hands the focus to its row first: a screen reader announced "…, unread" right // after the reader marked it read. vi.spyOn(api, "markNotificationRead").mockImplementation(() => new Promise(() => {})); h.server = [server(1, 1 * H, false)]; h.client = []; mount(); await loaded(); const row = screen.getByText("Ready 1").closest("[data-nav-index]")!; const button = within(row).getByRole("button", { name: "inbox.markRead" }); button.focus(); // Within the press's own task (the label is read out when the task ends): no render later. fireEvent.click(button); await Promise.resolve(); expect(document.activeElement).toBe(row); expect(row.getAttribute("aria-label")).toBe("inbox.downloadDone.title: Ready 1"); }); it("a refused Mark read brings the row back unread", async () => { vi.spyOn(api, "markNotificationRead").mockRejectedValue(new Error("offline")); h.server = [server(1, 1 * H, false)]; h.client = []; mount(); await loaded(); const row = screen.getByText("Ready 1").closest("[data-nav-index]")!; fireEvent.click(within(row).getByRole("button", { name: "inbox.markRead" })); await waitFor(() => expect(within(row).queryByRole("button", { name: "inbox.markRead" })).not.toBeNull(), ); }); it("names each row by what it is about, so two of one kind tell apart", async () => { // A screen reader hears the name while the keyboard is on the row, and Back finds the row it // left by it: "Download ready" alone was every download's name. h.server = [server(1, 1 * H, true), server(2, 2 * H, true)]; h.client = [client(3, 3 * H, { title: "Something broke", message: "Unexpected error." })]; mount(); await loaded(); expect( [...document.querySelectorAll("[data-nav-index]")].map((r) => r.getAttribute("data-nav-name"), ), ).toEqual([ "inbox.downloadDone.title: Ready 1", "inbox.downloadDone.title: Ready 2", "Something broke: Unexpected error.", ]); }); });