All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
7.0 KiB
TypeScript
129 lines
7.0 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 "../../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(<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("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();
|
|
});
|
|
});
|