Files
gerbeur/src/test/pages/Index.test.tsx
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

421 lines
14 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { useLocation } from "react-router";
import { Index } from "../../pages/Index.tsx";
import {
FEED_TABS,
type FeedTab,
type FollowView,
} from "../../../shared/paths.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
makeSession,
page,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
/** Reads a feed's path as `App`'s routes do. */
function Scoped() {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? "");
const categorySlug = isTab ? undefined : segments[0];
const [feedTab, followView] = segments.slice(isTab ? 0 : 1);
return (
<Index
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={feedTab as FeedTab | undefined}
followView={followView as FollowView | undefined}
/>
);
}
function setup(
url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {},
) {
const user = userEvent.setup();
renderApp(<Scoped />, {
route: url,
path: "*",
session,
categories: [music],
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title"))
.map((a) => a.textContent);
describe("Index feeds", () => {
const older = makeDump({
id: "old",
title: "Old but loved",
voteCount: 30,
createdAt: new Date(Date.now() - 5 * HOUR),
});
const newer = makeDump({
id: "new",
title: "Brand new",
voteCount: 0,
createdAt: new Date(Date.now() - 60_000),
});
test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps", () => page([older, newer]));
const { user } = setup("/new");
await screen.findByText("Brand new");
expect(titles()).toEqual(["Brand new", "Old but loved"]);
await user.click(screen.getAllByRole("button", { name: "Hot" })[0]);
expect(currentPath()).toBe("/hot");
expect(titles()).toEqual(["Old but loved", "Brand new"]);
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
// One fetch: switching tabs re-sorts what's loaded.
expect(calls("GET /api/dumps")).toHaveLength(1);
});
test("the bare path is the viewer's default tab, with no redirect", async () => {
route("GET /api/dumps", () => page([older, newer]));
localStorage.setItem("default-feed-tab", "hot");
const { user } = setup("/");
await screen.findByText("Brand new");
expect(currentPath()).toBe("/");
expect(titles()).toEqual(["Old but loved", "Brand new"]);
await user.click(screen.getAllByRole("button", { name: "New" })[0]);
expect(currentPath()).toBe("/new");
// The default tab is the bare path again.
await user.click(screen.getAllByRole("button", { name: "Hot" })[0]);
expect(currentPath()).toBe("/");
});
test('a default tab stored as v3\'s "followed" is "follow" now', async () => {
route("GET /api/dumps", () => page([]));
localStorage.setItem("default-feed-tab", "followed");
setup("/", { session: makeSession() });
expect(
await screen.findByText("Follow some users to see their dumps here."),
).toBeInTheDocument();
expect(localStorage.getItem("default-feed-tab")).toBe("follow");
});
test("Journal lays the feed out as a mosaic", async () => {
route("GET /api/dumps", () => page([older, newer]));
setup("/journal");
await screen.findByText("Brand new");
expect(document.querySelector(".journal-grid")).not.toBeNull();
});
test("an empty feed and a failed one", async () => {
route("GET /api/dumps", () => page([]));
const first = setup("/new");
await screen.findByText("No dumps yet. Be the first!");
first.socket.drop();
});
test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps", () => fail("boom", 500));
setup("/new", { session: makeSession() });
expect(await screen.findByRole("alert")).toHaveTextContent(
"Failed to load",
);
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
});
test("sends the token so your own private dumps come back", async () => {
const session = makeSession();
route("GET /api/dumps", () => page([]));
setup("/new", { session });
await screen.findByText("No dumps yet. Be the first!");
expect(calls("GET /api/dumps")[0].headers.get("Authorization")).toBe(
`Bearer ${session.token}`,
);
});
test("scrolling to the end loads the next page", async () => {
route(
"GET /api/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([newer], true)
: page([older], false),
);
setup("/new");
await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) =>
o.elements.size > 0
)!;
act(() => observer.report(true));
await screen.findByText("Old but loved");
expect(calls("GET /api/dumps")[1].url.searchParams.get("page")).toBe("2");
});
test("live: a new dump goes on top; a deleted one goes away", async () => {
route("GET /api/dumps", () => page([older]));
const { socket } = setup("/new");
await screen.findByText("Old but loved");
socket.receive({
type: "dump_created",
dump: wire(
makeDump({ id: "live", title: "Just now", createdAt: new Date() }),
),
});
expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
});
test("live votes update the count", async () => {
route("GET /api/dumps", () => page([older]));
const { socket } = setup("/new");
await screen.findByText("Old but loved");
socket.receive({
type: "votes_update",
dumpId: "old",
voteCount: 31,
voterId: "x",
action: "cast",
});
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent(
"31",
);
});
test("guests can't vote", async () => {
route("GET /api/dumps", () => page([older]));
setup("/new");
await screen.findByText("Old but loved");
expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute(
"aria-disabled",
"true",
);
});
test("a reconnect backfills what was missed", async () => {
route("GET /api/dumps", () => page([older]), { once: true });
route("GET /api/dumps", () => page([newer, older]));
const { socket } = setup("/new");
await screen.findByText("Old but loved");
socket.drop();
await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome();
await screen.findByText("Brand new");
});
test("a category scopes the feed, and live arrivals to it", async () => {
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("category") === "music"
? [makeDump({ title: "A song", categoryIds: ["c-music"] })]
: [],
),
);
const { socket } = setup("/music/new");
await screen.findByText("A song");
expect(calls("GET /api/dumps")[0].url.searchParams.get("category")).toBe(
"music",
);
await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })),
});
socket.receive({
type: "dump_created",
dump: wire(
makeDump({
title: "Another song",
categoryIds: ["c-music"],
createdAt: new Date(),
}),
),
});
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument();
});
test("a dump edited out of the category leaves its feed", async () => {
const song = makeDump({
id: "s",
title: "A song",
categoryIds: ["c-music"],
});
route("GET /api/dumps", () => page([song]));
const { socket } = setup("/music/new");
await screen.findByText("A song");
socket.receive({
type: "dump_updated",
dump: wire({ ...song, categoryIds: [] }),
});
expect(screen.queryByText("A song")).not.toBeInTheDocument();
});
test("an unknown category is a 404", async () => {
route("GET /api/dumps", () => fail("nope", 404));
setup("/nonsense/new");
expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
});
test("a guest on the follow tab is sent to sign in, and back", () => {
route("GET /api/dumps", () => page([older]));
setup("/music/follow");
expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow");
});
test("the Android share sheet opens the new-dump form with the link", async () => {
route("GET /api/dumps", () => page([]));
route(
"GET /api/remote/preview",
() => ({ reached: false, richContent: null }),
);
route("GET /api/dumps", () => []);
setup("/new?share_url=https%3A%2F%2Fshared.test%2F", {
session: makeSession(),
});
const dialog = await screen.findByRole("dialog", { name: "New dump" });
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue(
"https://shared.test/",
);
});
});
describe("Followed feed", () => {
const followCalls = () =>
calls("GET /api/dumps").filter((r) => r.url.searchParams.has("follow"));
test("from people at /follow, from playlists at /follow/playlists", async () => {
const session = makeSession();
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("follow") === "users"
? [makeDump({ title: "By a friend" })]
: [],
),
);
const { user } = setup("/follow", { session });
await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(currentPath()).toBe("/follow/playlists");
expect(
screen.getByText("Follow some public playlists to see their dumps here."),
).toBeInTheDocument();
const [feed] = followCalls();
expect(feed.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
await user.click(screen.getByRole("button", { name: "From people" }));
expect(currentPath()).toBe("/follow");
});
test("a followed user's new dump arrives live, others' don't", async () => {
route("GET /api/me/follow", () => ({
followedUserIds: ["friend"],
followedPlaylistIds: [],
}));
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("follow") === "users"
? [
makeDump({
title: "By a friend",
userId: "friend",
createdAt: new Date(Date.now() - 60_000),
}),
]
: [],
),
);
const { socket } = setup("/follow", { session: makeSession() });
await screen.findByText("By a friend");
// The follows load beside the feed: their answer must be in.
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
await act(() => new Promise((r) => setTimeout(r, 0)));
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "Fresh", userId: "friend" })),
});
socket.receive({
type: "dump_created",
dump: wire(makeDump({ title: "A stranger's", userId: "stranger" })),
});
expect(screen.getByText("Fresh")).toBeInTheDocument();
expect(screen.queryByText("A stranger's")).not.toBeInTheDocument();
});
test("a followed playlist's new dumps bring its view up to date", async () => {
route("GET /api/me/follow", () => ({
followedUserIds: [],
followedPlaylistIds: ["mix"],
}));
route("GET /api/dumps", () => page([]), { once: true });
route("GET /api/dumps", () => page([]), { once: true });
route(
"GET /api/dumps",
(req) =>
page(
req.url.searchParams.get("follow") === "playlists"
? [makeDump({ title: "Added to the mix" })]
: [],
),
);
const { socket } = setup("/follow/playlists", { session: makeSession() });
await screen.findByText(
"Follow some public playlists to see their dumps here.",
);
await waitFor(() => expect(calls("GET /api/me/follow")).toHaveLength(1));
await act(() => new Promise((r) => setTimeout(r, 0)));
socket.receive({
type: "playlist_dumps_updated",
playlistId: "mix",
dumpIds: ["x"],
dumpCount: 1,
});
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
});
test("its view is in the path, a category's too", async () => {
route("GET /api/dumps", () => page([]));
setup("/music/follow/playlists", { session: makeSession() });
await screen.findByText(
"Follow some public playlists to see their dumps here.",
);
expect(
followCalls().map((r) => [
r.url.searchParams.get("follow"),
r.url.searchParams.get("category"),
]).sort(),
).toEqual([["playlists", "music"], ["users", "music"]]);
});
test("a server error reads as one, not as a lost connection", async () => {
route(
"GET /api/dumps",
(req) =>
req.url.searchParams.get("follow") === "users"
? fail("Internal error", 500)
: page([]),
);
setup("/follow", { session: makeSession() });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Internal error");
expect(alert).not.toHaveTextContent(/Could not connect/);
});
});