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 (
);
}
function setup(
url: string,
{ session = null as ReturnType | null } = {},
) {
const user = userEvent.setup();
renderApp(, {
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/);
});
});