All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
8.9 KiB
TypeScript
272 lines
8.9 KiB
TypeScript
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 "../../../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";
|
|
|
|
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(<Notifications />, { 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(<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/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("one that arrives while the list is loading isn't lost", async () => {
|
|
const list = deferred<unknown>();
|
|
route("GET /api/notifications", () => list.promise);
|
|
route("POST /api/notifications/read-all", () => ({}));
|
|
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
|
const socket = FakeWebSocket.last;
|
|
socket.welcome({ unreadNotificationCount: 1 });
|
|
await waitFor(() =>
|
|
expect(calls("GET /api/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),
|
|
});
|
|
// 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");
|
|
});
|
|
|
|
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(<Notifications />, { 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();
|
|
});
|
|
});
|