421 lines
14 KiB
TypeScript
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/);
|
|
});
|
|
});
|