v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,19 +13,15 @@ const categories: Category[] = [
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
route: "/~/hot",
|
||||
path: "/:x/:feedTab",
|
||||
});
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, { route: "/hot" });
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches scope, keeping the tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
renderWithContexts(<CategorySwitcher categorySlug="music" tab="new" />, {
|
||||
categories,
|
||||
route: "/music/new",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
await user.click(trigger);
|
||||
@@ -38,24 +34,36 @@ describe("CategorySwitcher", () => {
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("All is the ~ scope; an unknown slug shows as itself", async () => {
|
||||
test("All is the root; the default tab stays a bare path; an unknown slug shows as itself", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
renderWithContexts(<CategorySwitcher categorySlug="gone" tab="hot" />, {
|
||||
categories,
|
||||
route: "/gone/hot",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "All" }));
|
||||
expect(currentPath()).toBe("/~/hot");
|
||||
expect(currentPath()).toBe("/hot");
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/music/hot");
|
||||
});
|
||||
|
||||
test("on the default tab, a category is its bare path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher tab="journal" />, {
|
||||
categories,
|
||||
route: "/",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.click(screen.getByRole("option", { name: "Cinema" }));
|
||||
expect(currentPath()).toBe("/cinema");
|
||||
});
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher />, {
|
||||
renderWithContexts(<CategorySwitcher tab="hot" />, {
|
||||
categories,
|
||||
route: "/~/hot",
|
||||
path: "/~/:feedTab",
|
||||
route: "/hot",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("CommentThread", () => {
|
||||
setup([makeComment({ id: "a" })]);
|
||||
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/d1#comment-a",
|
||||
"/#comment-a",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("DumpCard", () => {
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
"/@owner/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).toBeInTheDocument();
|
||||
@@ -92,7 +92,7 @@ describe("DumpCard", () => {
|
||||
expect(castVote).toHaveBeenCalledWith("d3", 3);
|
||||
expect(currentPath()).toBe("/");
|
||||
await userEvent.click(screen.getByText("1 minute ago"));
|
||||
expect(currentPath()).toBe("/dumps/d3");
|
||||
expect(currentPath()).toBe("/@owner/dumps/d3");
|
||||
});
|
||||
|
||||
test("file dumps get a file preview; bare URLs a placeholder", () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("FilePreview", () => {
|
||||
renderWithContexts(<FilePreview dump={d} />, { session });
|
||||
expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute(
|
||||
"src",
|
||||
`http://localhost:8000/api/files/f1?v=${
|
||||
`http://localhost:8000/api/dumps/f1/file?v=${
|
||||
dumpFileVersion(d)
|
||||
}&token=${session.mediaToken}`,
|
||||
);
|
||||
@@ -60,7 +60,7 @@ describe("FilePreview", () => {
|
||||
mimeType: "audio/mpeg",
|
||||
title: "Dump f1",
|
||||
subtitle: "thing",
|
||||
dumpHref: "/dumps/f1",
|
||||
dumpHref: "/@owner/dumps/f1",
|
||||
artworkUrl: undefined,
|
||||
}));
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe("FilePreview", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toBe(dumpThumbnailUrl(d));
|
||||
expect(play.mock.calls[0][0].artworkUrl).toMatch(
|
||||
/^http:\/\/localhost:8000\/api\/thumbnails\/f1\?v=2048-\d+$/,
|
||||
/^http:\/\/localhost:8000\/api\/dumps\/f1\/thumbnail\?v=2048-\d+$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("FilePreview", () => {
|
||||
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
|
||||
route("GET /api/dumps/:id/file", () => new TypeError("no audio in jsdom"));
|
||||
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
|
||||
player: { play },
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("JournalCard", () => {
|
||||
);
|
||||
expect(currentPath()).toBe("/");
|
||||
await user.click(cards[1].querySelector(".journal-card-image")!);
|
||||
expect(currentPath()).toBe("/dumps/p");
|
||||
expect(currentPath()).toBe("/@owner/dumps/p");
|
||||
});
|
||||
|
||||
test("a video file shows its generated still", () => {
|
||||
|
||||
@@ -69,12 +69,12 @@ describe("RichContentCard", () => {
|
||||
renderWithContexts(
|
||||
<RichContentCard
|
||||
richContent={yt}
|
||||
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
|
||||
thumbnailOverrideUrl="http://localhost:8000/api/dumps/d/thumbnail"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/thumbnails/d",
|
||||
"http://localhost:8000/api/dumps/d/thumbnail",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function setup(
|
||||
function previewRoutes(
|
||||
{ title = "Page title", reached = true, dupes = [] as unknown[] } = {},
|
||||
) {
|
||||
route("GET /api/preview", (req) => ({
|
||||
route("GET /api/remote/preview", (req) => ({
|
||||
reached,
|
||||
richContent: reached
|
||||
? {
|
||||
@@ -49,7 +49,7 @@ function previewRoutes(
|
||||
}
|
||||
: { type: "link", url: req.url.searchParams.get("url") },
|
||||
}));
|
||||
route("GET /api/dumps/by-url", () => dupes);
|
||||
route("GET /api/dumps", () => dupes);
|
||||
}
|
||||
|
||||
const nextBtn = () => screen.getByRole("button", { name: "Next →" });
|
||||
@@ -74,7 +74,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
const title = await screen.findByLabelText("Title");
|
||||
await waitFor(() => expect(title).toHaveValue("Page title"));
|
||||
expect(title).toBeDisabled();
|
||||
const [req] = calls("GET /api/preview");
|
||||
const [req] = calls("GET /api/remote/preview");
|
||||
expect(req.url.searchParams.get("url")).toBe("https://example.com/a");
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
id: "old",
|
||||
slug: "old-one",
|
||||
title: "Old one",
|
||||
username: "ann",
|
||||
ownerUsername: "ann",
|
||||
createdAt: new Date(Date.now() - 3600_000).toISOString(),
|
||||
voteCount: 1,
|
||||
commentCount: 0,
|
||||
@@ -103,7 +103,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
{
|
||||
id: "older",
|
||||
title: "Older",
|
||||
username: "bob",
|
||||
ownerUsername: "bob",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
@@ -117,7 +117,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
"and 1 earlier dump",
|
||||
);
|
||||
await user.click(screen.getByRole("link", { name: "Old one" }));
|
||||
expect(currentPath()).toBe("/dumps/old-one");
|
||||
expect(currentPath()).toBe("/@ann/dumps/old-one");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
route("POST /api/dumps", () => wire(created));
|
||||
const mix = makePlaylist({ id: "p1", title: "Mix" });
|
||||
route(
|
||||
"GET /api/playlists/by-dump/:id/memberships",
|
||||
"GET /api/me/playlists",
|
||||
() => [{ playlist: wire(mix), hasDump: false }],
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
@@ -179,7 +179,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
previewRoutes();
|
||||
route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
|
||||
route(
|
||||
"GET /api/playlists/by-dump/:id/memberships",
|
||||
"GET /api/me/playlists",
|
||||
() => [{
|
||||
playlist: wire(makePlaylist({ title: "Mix" })),
|
||||
hasDump: false,
|
||||
@@ -198,7 +198,7 @@ describe("DumpCreateModal: a link", () => {
|
||||
test("a title typed by the poster is sent; a suggested one isn't", async () => {
|
||||
previewRoutes();
|
||||
route("POST /api/dumps", () => wire(makeDump()));
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
route("GET /api/me/playlists", () => []);
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/");
|
||||
await waitFor(() =>
|
||||
@@ -365,7 +365,7 @@ describe("DumpCreateModal: a file", () => {
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR);
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
route("GET /api/me/playlists", () => []);
|
||||
const { user } = setup();
|
||||
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
|
||||
dataTransfer: { files: [song()] },
|
||||
|
||||
@@ -21,16 +21,16 @@ describe("Markdown", () => {
|
||||
test("@mentions link to the profile, in-app", async () => {
|
||||
md("hi @ann and bob@mail.com");
|
||||
const link = screen.getByRole("link", { name: "@ann" });
|
||||
expect(link).toHaveAttribute("href", "/users/ann");
|
||||
expect(link).toHaveAttribute("href", "/@ann");
|
||||
expect(link).not.toHaveAttribute("target");
|
||||
// GFM autolinks the address itself; it isn't a mention of "mail".
|
||||
expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:bob@mail.com",
|
||||
);
|
||||
expect(document.querySelector('a[href="/users/mail"]')).toBeNull();
|
||||
expect(document.querySelector('a[href="/@mail"]')).toBeNull();
|
||||
await userEvent.click(link);
|
||||
expect(currentPath()).toBe("/users/ann");
|
||||
expect(currentPath()).toBe("/@ann");
|
||||
});
|
||||
|
||||
test("mentions in code and inside URLs are left alone", () => {
|
||||
@@ -44,7 +44,7 @@ describe("Markdown", () => {
|
||||
|
||||
test("external links open in a new tab; internal and same-origin ones don't", () => {
|
||||
md(
|
||||
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
|
||||
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/dumps/1/file)`,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute(
|
||||
"target",
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("mentions", () => {
|
||||
];
|
||||
|
||||
test("@ opens a listbox; Enter picks and adds a space", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "hi @al");
|
||||
const list = await screen.findByRole("listbox");
|
||||
@@ -140,7 +140,7 @@ describe("mentions", () => {
|
||||
});
|
||||
|
||||
test("clicking a suggestion picks it", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "@ali");
|
||||
await user.click(await screen.findByRole("option", { name: /alice/ }));
|
||||
@@ -148,7 +148,7 @@ describe("mentions", () => {
|
||||
});
|
||||
|
||||
test("Escape closes the list, and only a second Escape cancels", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const onCancel = vi.fn();
|
||||
const { user, box } = setup({ onCancel });
|
||||
await user.type(box, "@al");
|
||||
@@ -161,7 +161,7 @@ describe("mentions", () => {
|
||||
});
|
||||
|
||||
test("Enter with the list open picks rather than submitting", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const onKeyDown = vi.fn();
|
||||
const { user, box } = setup({ onKeyDown });
|
||||
await user.type(box, "@al");
|
||||
@@ -174,7 +174,7 @@ describe("mentions", () => {
|
||||
});
|
||||
|
||||
test("leaving the field closes the list", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { user, box } = setup();
|
||||
await user.type(box, "@al");
|
||||
await screen.findByRole("listbox");
|
||||
|
||||
@@ -12,28 +12,32 @@ const categories: Category[] = [
|
||||
];
|
||||
|
||||
describe("FeedTabBar", () => {
|
||||
test("guests don't get Followed; tabs navigate within the scope", async () => {
|
||||
test("guests don't get Followed; tabs navigate within the scope, the default one bare", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar />, {
|
||||
renderWithContexts(<FeedTabBar categorySlug="music" tab="hot" />, {
|
||||
route: "/music/hot",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
categories,
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Followed" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(currentPath()).toBe("/music/new");
|
||||
// Journal is the default tab.
|
||||
await user.click(screen.getByRole("button", { name: "Journal" }));
|
||||
expect(currentPath()).toBe("/music/journal");
|
||||
expect(currentPath()).toBe("/music");
|
||||
});
|
||||
|
||||
test("signed in, Followed is there", () => {
|
||||
renderWithContexts(<FeedTabBar />, {
|
||||
test("signed in, Followed is there", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar tab="follow" />, {
|
||||
session: makeSession(),
|
||||
route: "/~/followed",
|
||||
path: "/~/:feedTab",
|
||||
route: "/follow",
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Hot" }));
|
||||
expect(currentPath()).toBe("/hot");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("AppHeader", () => {
|
||||
});
|
||||
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
"/@ann",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
|
||||
@@ -19,12 +19,18 @@ describe("UserMenu", () => {
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
"/@ann",
|
||||
);
|
||||
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann/playlists",
|
||||
"/@ann/playlists",
|
||||
);
|
||||
expect(screen.getByRole("menuitem", { name: "Settings" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings",
|
||||
);
|
||||
// The site's tools are for those who hold category:manage.
|
||||
expect(screen.queryByRole("menuitem", { name: "Manage" })).toBeNull();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await user.click(trigger);
|
||||
@@ -32,7 +38,7 @@ describe("UserMenu", () => {
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("menuitem", { name: "@ann" }));
|
||||
expect(currentPath()).toBe("/users/ann");
|
||||
expect(currentPath()).toBe("/@ann");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("GlobalPlayer", () => {
|
||||
act(() =>
|
||||
player.play({
|
||||
kind: "file",
|
||||
fileUrl: "http://localhost:8000/api/files/x",
|
||||
fileUrl: "http://localhost:8000/api/dumps/x/file",
|
||||
mimeType: "video/mp4",
|
||||
title: "Clip",
|
||||
})
|
||||
@@ -181,7 +181,7 @@ describe("GlobalPlayer", () => {
|
||||
// Force a re-resolve that never lands.
|
||||
const { route, deferred } = await import("../../fetch.ts");
|
||||
const pending = deferred();
|
||||
route("GET /api/bandcamp/tracks", () => pending.promise);
|
||||
route("GET /api/remote/bandcamp", () => pending.promise);
|
||||
act(() => player.onError());
|
||||
expect(screen.getByText("Refreshing stream…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -25,17 +25,17 @@ describe("PlaylistCard", () => {
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/playlists/mix",
|
||||
"/@ann/playlists/mix",
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
"/@ann",
|
||||
);
|
||||
expect(screen.getByText("1 dump")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tunes")).toBeInTheDocument();
|
||||
await user.click(container.querySelector(".playlist-card-inner")!);
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
expect(currentPath()).toBe("/@ann/playlists/mix");
|
||||
});
|
||||
|
||||
test("the owner doesn't see their own name, or an unread dot", () => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ProfileSettingsTab } from "../../../components/profile/ProfileSettingsTab.tsx";
|
||||
import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts";
|
||||
import { calls, route } from "../../fetch.ts";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
import { renderApp } from "../../render.tsx";
|
||||
|
||||
vi.mock("../../../config/instance.ts", () => ({
|
||||
DEFAULT_STYLE: "smooth",
|
||||
EMAIL_ENABLED: true,
|
||||
}));
|
||||
|
||||
describe("ProfileSettingsTab", () => {
|
||||
test("the digest switches save to the account", async () => {
|
||||
route("GET /api/users/me/settings", () => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
digestMonthly: false,
|
||||
}));
|
||||
route(
|
||||
"PATCH /api/users/me/settings",
|
||||
(req) => ({ ...DEFAULT_SETTINGS, ...(req.body as object) }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderApp(<ProfileSettingsTab />, { session: makeSession() });
|
||||
|
||||
const row = (label: string) =>
|
||||
screen.getByText(label).closest(".profile-appearance-row") as HTMLElement;
|
||||
const weeklyOff = await waitFor(() => {
|
||||
const button = within(row("Weekly digest")).getByRole("button", {
|
||||
name: "Off",
|
||||
});
|
||||
expect(button).toBeEnabled();
|
||||
return button;
|
||||
});
|
||||
expect(
|
||||
within(row("Monthly recap")).getByRole("button", { name: "Off" }),
|
||||
).toHaveClass("active");
|
||||
expect(screen.queryByText(/doesn't send email/)).toBeNull();
|
||||
|
||||
await user.click(weeklyOff);
|
||||
await waitFor(() =>
|
||||
expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({
|
||||
digestWeekly: false,
|
||||
})
|
||||
);
|
||||
expect(weeklyOff).toHaveClass("active");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ describe("Avatar", () => {
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/u1?v=7",
|
||||
"http://localhost:8000/api/users/u1/avatar?v=7",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("Thumbnail", () => {
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
|
||||
.toContain(
|
||||
"/api/proxy-image?url=",
|
||||
"/api/remote/image?url=",
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "art" }));
|
||||
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
|
||||
@@ -30,7 +30,7 @@ describe("Thumbnail", () => {
|
||||
test("our own images skip the proxy retry", () => {
|
||||
renderWithContexts(
|
||||
<Thumbnail
|
||||
src="http://localhost:8000/api/thumbnails/d"
|
||||
src="http://localhost:8000/api/dumps/d/thumbnail"
|
||||
alt="t"
|
||||
placeholder={placeholder}
|
||||
/>,
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("VoteButton", () => {
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ }))
|
||||
.toHaveAttribute(
|
||||
"href",
|
||||
"/users/fan",
|
||||
"/@fan",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("AuthProvider", () => {
|
||||
test("starts from the stored session", () => {
|
||||
const session = makeSession({ username: "ann" });
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user?.username).toBe("ann");
|
||||
});
|
||||
@@ -23,11 +23,11 @@ describe("AuthProvider", () => {
|
||||
test("a token the server rejects is dropped", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => new Response(null, { status: 401 }));
|
||||
route("GET /api/me", () => new Response(null, { status: 401 }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user).toBeNull());
|
||||
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
|
||||
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
|
||||
expect(calls("GET /api/me")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("AuthProvider", () => {
|
||||
const session = makeSession({ role: "user" });
|
||||
storeSession(session);
|
||||
route(
|
||||
"GET /api/users/me",
|
||||
"GET /api/me",
|
||||
() => wire({ ...session.user, role: "moderator" }),
|
||||
);
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
@@ -48,29 +48,29 @@ describe("AuthProvider", () => {
|
||||
|
||||
test("offline doesn't sign anyone out", async () => {
|
||||
storeSession(makeSession());
|
||||
route("GET /api/users/me", () => new TypeError("offline"));
|
||||
route("GET /api/me", () => new TypeError("offline"));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
await waitFor(() => expect(calls("GET /api/me")).toHaveLength(1));
|
||||
expect(result.current.user).not.toBeNull();
|
||||
});
|
||||
|
||||
test("revalidates when the tab regains focus", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
await waitFor(() => expect(calls("GET /api/me")).toHaveLength(1));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
|
||||
await waitFor(() => expect(calls("GET /api/me")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("a session without a media token fetches one", async () => {
|
||||
const session = { ...makeSession(), mediaToken: undefined };
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
route("GET /api/me/media-token", () => ({ mediaToken: "m-1" }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
|
||||
@@ -79,7 +79,7 @@ describe("AuthProvider", () => {
|
||||
});
|
||||
|
||||
test("follows sign-in and sign-out from other tabs", () => {
|
||||
route("GET /api/users/me", () => ok(null));
|
||||
route("GET /api/me", () => ok(null));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
@@ -109,8 +109,8 @@ describe("AuthProvider", () => {
|
||||
const first = makeSession();
|
||||
storeSession(first);
|
||||
const pending = deferred();
|
||||
route("GET /api/users/me", () => pending.promise, { once: true });
|
||||
route("GET /api/users/me", () => wire(first.user));
|
||||
route("GET /api/me", () => pending.promise, { once: true });
|
||||
route("GET /api/me", () => wire(first.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
const second = { ...first, token: makeToken() };
|
||||
act(() => result.current.login(second));
|
||||
@@ -124,8 +124,8 @@ describe("AuthProvider", () => {
|
||||
test("reads the settings from the server into the session", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/settings", () => ({
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
route("GET /api/me/settings", () => ({
|
||||
style: "nyt",
|
||||
colorScheme: null,
|
||||
digestWeekly: false,
|
||||
|
||||
@@ -21,7 +21,7 @@ const embed = (n: number): PlayerItem => ({
|
||||
});
|
||||
const file = (n: number): PlayerItem => ({
|
||||
kind: "file",
|
||||
fileUrl: `http://localhost:8000/api/files/f${n}?token=secret`,
|
||||
fileUrl: `http://localhost:8000/api/dumps/f/file${n}?token=secret`,
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
const stream = (i: number, over: Partial<PlayerItem> = {}) =>
|
||||
@@ -291,7 +291,7 @@ describe("persistence", () => {
|
||||
|
||||
describe("expiring streams", () => {
|
||||
test("a stream error re-resolves and resumes on the same track", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1, 2]));
|
||||
route("GET /api/remote/bandcamp", () => albumResponse([0, 1, 2]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1), stream(2)], 1));
|
||||
act(() => p().onPlayStateChange(true));
|
||||
@@ -305,7 +305,7 @@ describe("expiring streams", () => {
|
||||
});
|
||||
|
||||
test("a track gone from the release restarts from the first one", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 2]));
|
||||
route("GET /api/remote/bandcamp", () => albumResponse([0, 2]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1)], 1));
|
||||
act(() => p().onPlayStateChange(true));
|
||||
@@ -317,7 +317,7 @@ describe("expiring streams", () => {
|
||||
});
|
||||
|
||||
test("a resolve that fails falls back to the embed", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => fail("down", 502));
|
||||
route("GET /api/remote/bandcamp", () => fail("down", 502));
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0)));
|
||||
act(() => p().onError());
|
||||
@@ -329,7 +329,7 @@ describe("expiring streams", () => {
|
||||
});
|
||||
|
||||
test("no embed to fall back to: the player stops", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => fail("down", 502));
|
||||
route("GET /api/remote/bandcamp", () => fail("down", 502));
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0, { embedUrl: undefined })));
|
||||
act(() => p().onError());
|
||||
@@ -337,20 +337,20 @@ describe("expiring streams", () => {
|
||||
});
|
||||
|
||||
test("a track that dies again right after a re-resolve is skipped", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
|
||||
route("GET /api/remote/bandcamp", () => albumResponse([0, 1]));
|
||||
const { p } = setup();
|
||||
act(() => p().playQueue([stream(0), stream(1)], 0));
|
||||
act(() => p().onError());
|
||||
await waitFor(() => expect(p().resolving).toBe(false));
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
|
||||
expect(calls("GET /api/remote/bandcamp")).toHaveLength(1);
|
||||
act(() => p().onError());
|
||||
expect(p().queueIndex).toBe(1);
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1);
|
||||
expect(calls("GET /api/remote/bandcamp")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a resolve that lands after the user moved on is dropped", async () => {
|
||||
const pending = deferred();
|
||||
route("GET /api/bandcamp/tracks", () => pending.promise);
|
||||
route("GET /api/remote/bandcamp", () => pending.promise);
|
||||
const { p } = setup();
|
||||
act(() => p().play(stream(0)));
|
||||
act(() => p().onError());
|
||||
@@ -370,13 +370,13 @@ describe("expiring streams", () => {
|
||||
const { p } = setup();
|
||||
act(() => p().play(embed(1)));
|
||||
act(() => p().onError());
|
||||
expect(calls("GET /api/bandcamp/tracks")).toHaveLength(0);
|
||||
expect(calls("GET /api/remote/bandcamp")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a stale restored stream is refreshed up front, without autoplay", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(10 * STREAM_STALE_MS);
|
||||
route("GET /api/bandcamp/tracks", () => albumResponse([0, 1]));
|
||||
route("GET /api/remote/bandcamp", () => albumResponse([0, 1]));
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({
|
||||
|
||||
@@ -12,8 +12,8 @@ describe("FollowProvider", () => {
|
||||
) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => status);
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
route("GET /api/me/follow", () => status);
|
||||
return renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
@@ -40,14 +40,14 @@ describe("FollowProvider", () => {
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
expect(calls("GET /api/follows/status")).toHaveLength(0);
|
||||
expect(calls("GET /api/me/follow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("follow and unfollow are optimistic and roll back on failure", async () => {
|
||||
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
|
||||
route("POST /api/follows/users/:id", () => ({}));
|
||||
route("PUT /api/me/follow/users/:id", () => ({}));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.followUser("u2");
|
||||
@@ -55,24 +55,24 @@ describe("FollowProvider", () => {
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("DELETE /api/follows/users/:id", () => fail("no", 500));
|
||||
route("DELETE /api/me/follow/users/:id", () => fail("no", 500));
|
||||
await act(async () => {
|
||||
ok = await result.current.unfollowUser("u2");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
|
||||
route("PUT /api/me/follow/playlists/:id", () => new TypeError("offline"));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => ({}));
|
||||
route("PUT /api/me/follow/playlists/:id", () => ({}));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
route("DELETE /api/follows/playlists/:id", () => ({}));
|
||||
route("DELETE /api/me/follow/playlists/:id", () => ({}));
|
||||
await act(() => result.current.unfollowPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
|
||||
"/api/follows/playlists/p2",
|
||||
expect(calls("DELETE /api/me/follow/playlists/:id")[0].path).toBe(
|
||||
"/api/me/follow/playlists/p2",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ describe("FollowProvider", () => {
|
||||
try {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => fail("down", 500));
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
route("GET /api/me/follow", () => fail("down", 500));
|
||||
const { result } = renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
@@ -92,7 +92,7 @@ describe("FollowProvider", () => {
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
route("GET /api/follows/status", () => ({
|
||||
route("GET /api/me/follow", () => ({
|
||||
followedUserIds: ["u1"],
|
||||
followedPlaylistIds: [],
|
||||
}));
|
||||
|
||||
@@ -57,10 +57,10 @@ describe("ThemeProvider", () => {
|
||||
const signIn = (settings: typeof DEFAULT_SETTINGS) => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/settings", () => settings);
|
||||
route("GET /api/me", () => wire(session.user));
|
||||
route("GET /api/me/settings", () => settings);
|
||||
route(
|
||||
"PATCH /api/users/me/settings",
|
||||
"PATCH /api/me/settings",
|
||||
(req) => ({ ...settings, ...(req.body as object) }),
|
||||
);
|
||||
};
|
||||
@@ -73,11 +73,11 @@ describe("ThemeProvider", () => {
|
||||
expect(result.current.colorScheme).toBe("dark");
|
||||
expect(root.getAttribute("data-style")).toBe("kanagawa");
|
||||
expect(localStorage.getItem("style")).toBe("kanagawa");
|
||||
expect(calls("PATCH /api/users/me/settings")).toHaveLength(0);
|
||||
expect(calls("PATCH /api/me/settings")).toHaveLength(0);
|
||||
|
||||
act(() => result.current.setStyle("melange"));
|
||||
await waitFor(() =>
|
||||
expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({
|
||||
expect(calls("PATCH /api/me/settings")[0]?.body).toEqual({
|
||||
style: "melange",
|
||||
})
|
||||
);
|
||||
@@ -91,9 +91,9 @@ describe("ThemeProvider", () => {
|
||||
signIn({ ...DEFAULT_SETTINGS });
|
||||
const { result } = renderHook(useTheme, { wrapper: authWrapper });
|
||||
await waitFor(() =>
|
||||
expect(calls("PATCH /api/users/me/settings")).toHaveLength(1)
|
||||
expect(calls("PATCH /api/me/settings")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({
|
||||
expect(calls("PATCH /api/me/settings")[0].body).toEqual({
|
||||
style: "brutalist",
|
||||
});
|
||||
expect(result.current.style).toBe("brutalist");
|
||||
|
||||
@@ -61,7 +61,9 @@ export function makeDump(over: Partial<Dump> = {}): Dump {
|
||||
id,
|
||||
kind: "url",
|
||||
title: `Dump ${id}`,
|
||||
slug: id,
|
||||
userId: "user-owner",
|
||||
ownerUsername: "owner",
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
url: `https://example.com/${id}`,
|
||||
voteCount: 0,
|
||||
@@ -76,7 +78,9 @@ export function makePlaylist(over: Partial<Playlist> = {}): Playlist {
|
||||
return {
|
||||
id,
|
||||
userId: "user-owner",
|
||||
ownerUsername: "owner",
|
||||
title: `Playlist ${id}`,
|
||||
slug: id,
|
||||
isPublic: true,
|
||||
createdAt: new Date("2026-02-01T00:00:00Z"),
|
||||
dumpCount: 0,
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("useSettings", () => {
|
||||
|
||||
test("a change shows at once, and the server's answer is kept", async () => {
|
||||
const reply = deferred();
|
||||
route("PATCH /api/users/me/settings", () => reply.promise);
|
||||
route("PATCH /api/me/settings", () => reply.promise);
|
||||
const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } };
|
||||
const { result } = renderHook(useSettings, {
|
||||
wrapper: wrapperFor(session),
|
||||
@@ -43,7 +43,7 @@ describe("useSettings", () => {
|
||||
saving = result.current.updateSettings({ digestWeekly: false });
|
||||
});
|
||||
expect(result.current.settings?.digestWeekly).toBe(false);
|
||||
expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({
|
||||
expect(calls("PATCH /api/me/settings")[0].body).toEqual({
|
||||
digestWeekly: false,
|
||||
});
|
||||
await act(async () => {
|
||||
@@ -56,7 +56,7 @@ describe("useSettings", () => {
|
||||
});
|
||||
|
||||
test("a refusal puts the previous settings back and throws", async () => {
|
||||
route("PATCH /api/users/me/settings", () => fail("nope", 500));
|
||||
route("PATCH /api/me/settings", () => fail("nope", 500));
|
||||
const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } };
|
||||
const { result } = renderHook(useSettings, {
|
||||
wrapper: wrapperFor(session),
|
||||
|
||||
@@ -6,7 +6,11 @@ import { isDumpVisited } from "../../../utils/shared/visited.ts";
|
||||
|
||||
describe("useDumpCardLink", () => {
|
||||
function Card() {
|
||||
const link = useDumpCardLink({ id: "d1", slug: "hello" });
|
||||
const link = useDumpCardLink({
|
||||
id: "d1",
|
||||
slug: "hello",
|
||||
ownerUsername: "ann",
|
||||
});
|
||||
return (
|
||||
<div data-testid="card" {...link.cardProps}>
|
||||
<a href="/elsewhere">inner link</a>
|
||||
@@ -27,7 +31,7 @@ describe("useDumpCardLink", () => {
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Card />} />
|
||||
<Route path="/dumps/:id" element={<p>dump page</p>} />
|
||||
<Route path="/@ann/dumps/:slug" element={<p>dump page</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -44,7 +48,11 @@ describe("useDumpCardLink", () => {
|
||||
test("ctrl/cmd/shift-click opens a new tab", () => {
|
||||
const { open } = setup();
|
||||
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
|
||||
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"/@ann/dumps/hello",
|
||||
"_blank",
|
||||
"noopener",
|
||||
);
|
||||
});
|
||||
|
||||
test("a middle click opens a new tab, except over a link", () => {
|
||||
|
||||
@@ -3,16 +3,20 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
|
||||
import { useUserDumpFeed } from "../../../hooks/dump/useUserDumpFeed.ts";
|
||||
import {
|
||||
type UserDumpList,
|
||||
useUserDumpFeed,
|
||||
} from "../../../hooks/dump/useUserDumpFeed.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeDump, makeSession, makeUser, page, wire } from "../../fixtures.ts";
|
||||
import { FakeIntersectionObserver } from "../../setup.ts";
|
||||
import { makeDump, makeSession, page } from "../../fixtures.ts";
|
||||
import type { Session } from "../../../utils/auth/storage.ts";
|
||||
|
||||
describe("useUserDumpFeed", () => {
|
||||
const ann = makeUser({ username: "ann" });
|
||||
function setup(
|
||||
opts: { session?: ReturnType<typeof makeSession> | null } = {},
|
||||
opts: {
|
||||
session?: ReturnType<typeof makeSession> | null;
|
||||
list?: UserDumpList;
|
||||
} = {},
|
||||
) {
|
||||
const session = opts.session ?? null;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
@@ -26,43 +30,48 @@ describe("useUserDumpFeed", () => {
|
||||
);
|
||||
}
|
||||
return renderHook(
|
||||
({ username }: { username?: string }) =>
|
||||
useUserDumpFeed(username, "dumps", "cache-key"),
|
||||
{
|
||||
initialProps: { username: "ann" as string | undefined },
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
({ userId }: { userId: string }) =>
|
||||
useUserDumpFeed(userId, opts.list ?? "dumps", { isOwner: false }),
|
||||
{ initialProps: { userId: "ann" }, wrapper: Wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
test("loads the profile and the first page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
const loaded = (state: ReturnType<typeof useUserDumpFeed>["state"]) =>
|
||||
state.status === "loaded" ? state.data : null;
|
||||
|
||||
test("loads the first page by the user's id, signed in", async () => {
|
||||
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true));
|
||||
const session = makeSession();
|
||||
const { result } = setup({ session });
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.profileUser.username).toBe("ann");
|
||||
expect(s.dumps.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.dumps.hasMore).toBe(true);
|
||||
const list = loaded(result.current.state)!;
|
||||
expect(list.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(list.hasMore).toBe(true);
|
||||
const [req] = calls("GET /api/users/:u/dumps");
|
||||
expect(req.path).toBe("/api/users/ann/dumps");
|
||||
expect(req.url.searchParams.get("page")).toBe("1");
|
||||
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
});
|
||||
|
||||
test("an unknown user is an error", async () => {
|
||||
route("GET /api/users/:u", () => fail("User not found", 404));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("error"));
|
||||
test("the upvoted list reads its own endpoint", async () => {
|
||||
route("GET /api/users/:u/upvoted", () => page([makeDump({ id: "v" })]));
|
||||
const { result } = setup({ list: "upvoted" });
|
||||
await waitFor(() =>
|
||||
expect(loaded(result.current.state)?.items.map((d) => d.id)).toEqual([
|
||||
"v",
|
||||
])
|
||||
);
|
||||
expect(result.current.cardClassName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("an offline load says so", async () => {
|
||||
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
test("a refused or offline load says so", async () => {
|
||||
route("GET /api/users/:u/dumps", () => fail("Nope", 500));
|
||||
const refused = setup();
|
||||
await waitFor(() =>
|
||||
expect(refused.result.current.state).toMatchObject({ status: "error" })
|
||||
);
|
||||
refused.unmount();
|
||||
route("GET /api/users/:u/dumps", () => new TypeError("Failed to fetch"));
|
||||
const { result } = setup();
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toMatchObject({
|
||||
@@ -72,8 +81,7 @@ describe("useUserDumpFeed", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the sentinel loads the next page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
test("loadMore appends the next page", async () => {
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
(req) =>
|
||||
@@ -83,53 +91,38 @@ describe("useUserDumpFeed", () => {
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.report(true));
|
||||
await waitFor(() => {
|
||||
const s = result.current.state;
|
||||
expect(s.status === "loaded" && s.dumps.items.map((d) => d.id)).toEqual([
|
||||
act(() => result.current.loadMore());
|
||||
await waitFor(() =>
|
||||
expect(loaded(result.current.state)?.items.map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.dumps.hasMore).toBe(false);
|
||||
expect(s.dumps.page).toBe(2);
|
||||
});
|
||||
|
||||
test("changing user resets to loading", async () => {
|
||||
route(
|
||||
"GET /api/users/:u",
|
||||
(req) => wire(makeUser({ username: req.params.u })),
|
||||
])
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result, rerender } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
rerender({ username: "bob" });
|
||||
expect(result.current.state.status).toBe("loading");
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
expect(loaded(result.current.state)).toMatchObject({
|
||||
hasMore: false,
|
||||
page: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("setItems edits the loaded list", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
test("another user starts over, and setItems edits the list", async () => {
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
|
||||
(req) =>
|
||||
page([
|
||||
makeDump({ id: `${req.params.u}-1` }),
|
||||
makeDump({ id: `${req.params.u}-2` }),
|
||||
]),
|
||||
);
|
||||
const { result } = setup();
|
||||
const { result, rerender } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
rerender({ userId: "bob" });
|
||||
expect(result.current.state.status).toBe("loading");
|
||||
await waitFor(() =>
|
||||
expect(loaded(result.current.state)?.items[0].id).toBe("bob-1")
|
||||
);
|
||||
act(() => result.current.setItems((prev) => prev.slice(1)));
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.dumps.items.map((d) => d.id)).toEqual(["b"]);
|
||||
expect(loaded(result.current.state)?.items.map((d) => d.id)).toEqual([
|
||||
"bob-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("useMentionAutocomplete", () => {
|
||||
|
||||
test("looks up the @query after a pause and opens with results", async () => {
|
||||
route(
|
||||
"GET /api/users/search",
|
||||
"GET /api/users",
|
||||
(req) =>
|
||||
users.filter((u) =>
|
||||
u.username.startsWith(req.url.searchParams.get("q")!)
|
||||
@@ -51,42 +51,42 @@ describe("useMentionAutocomplete", () => {
|
||||
await type("hi @al");
|
||||
expect(hook.result.current.mentionOpen).toBe(true);
|
||||
expect(hook.result.current.mentionResults).toHaveLength(3);
|
||||
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe(
|
||||
expect(calls("GET /api/users")[0].url.searchParams.get("q")).toBe(
|
||||
"al",
|
||||
);
|
||||
});
|
||||
|
||||
test("debounces keystrokes into one lookup", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.syncMention("@a", 2, true));
|
||||
act(() => hook.result.current.syncMention("@al", 3, true));
|
||||
act(() => hook.result.current.syncMention("@ali", 4, true));
|
||||
await act(() => vi.advanceTimersByTimeAsync(200));
|
||||
expect(calls("GET /api/users/search")).toHaveLength(1);
|
||||
expect(calls("GET /api/users")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("no results keeps it closed", async () => {
|
||||
route("GET /api/users/search", () => []);
|
||||
route("GET /api/users", () => []);
|
||||
const { hook, type } = setup();
|
||||
await type("@zz");
|
||||
expect(hook.result.current.mentionOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("an email address or a link label isn't a mention", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type } = setup();
|
||||
await type("me@al");
|
||||
await type("[@al");
|
||||
await type("`@al");
|
||||
expect(calls("GET /api/users/search")).toHaveLength(0);
|
||||
expect(calls("GET /api/users")).toHaveLength(0);
|
||||
expect(hook.result.current.mentionOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("a stale lookup can't open over a newer query", async () => {
|
||||
const slow = deferred();
|
||||
route("GET /api/users/search", () => slow.promise, { once: true });
|
||||
route("GET /api/users/search", () => []);
|
||||
route("GET /api/users", () => slow.promise, { once: true });
|
||||
route("GET /api/users", () => []);
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
await type("@alz");
|
||||
@@ -97,7 +97,7 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("arrows wrap, Enter picks, Tab picks", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type, replace } = setup();
|
||||
await type("hey @al");
|
||||
const down = key("ArrowDown");
|
||||
@@ -129,7 +129,7 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("Shift+Enter is left to the editor", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
let handled = true;
|
||||
@@ -142,7 +142,7 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("Escape closes just the list and stops there", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
const esc = key("Escape");
|
||||
@@ -161,7 +161,7 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("moving the caret off the mention closes it", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type } = setup();
|
||||
await type("@al and more");
|
||||
// typed at position 3
|
||||
@@ -172,7 +172,7 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("a caret move within the same query keeps it open", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
act(() => hook.result.current.syncMention("@al", 3, false));
|
||||
@@ -180,18 +180,18 @@ describe("useMentionAutocomplete", () => {
|
||||
});
|
||||
|
||||
test("a failed lookup is quiet", async () => {
|
||||
route("GET /api/users/search", () => new TypeError("offline"));
|
||||
route("GET /api/users", () => new TypeError("offline"));
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
expect(hook.result.current.mentionOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("unmounting cancels a pending lookup", async () => {
|
||||
route("GET /api/users/search", () => users);
|
||||
route("GET /api/users", () => users);
|
||||
const { hook } = setup();
|
||||
act(() => hook.result.current.syncMention("@al", 3, true));
|
||||
hook.unmount();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(calls("GET /api/users/search")).toHaveLength(0);
|
||||
expect(calls("GET /api/users")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("usePlayRichContent", () => {
|
||||
});
|
||||
|
||||
test("native Bandcamp plays a resolved queue", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => ({
|
||||
route("GET /api/remote/bandcamp", () => ({
|
||||
sourceUrl: bc.url,
|
||||
itemType: "album",
|
||||
resolvedAt: 1,
|
||||
@@ -96,7 +96,7 @@ describe("usePlayRichContent", () => {
|
||||
|
||||
test("native Bandcamp that can't resolve falls back to the embed", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
route("GET /api/bandcamp/tracks", () => fail("x", 500));
|
||||
route("GET /api/remote/bandcamp", () => fail("x", 500));
|
||||
const { hook, play, playQueue } = await load("native");
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("useYouTubePlaylist", () => {
|
||||
test("titles from the server, order and position from the player", async () => {
|
||||
// The API script loads once per document; later tests find YT ready.
|
||||
const player = installYT();
|
||||
route("GET /api/youtube/playlist", () => ({
|
||||
route("GET /api/remote/youtube", () => ({
|
||||
tracks: [
|
||||
{ videoId: "a", title: "A", duration: 10 },
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
@@ -94,7 +94,7 @@ describe("useYouTubePlaylist", () => {
|
||||
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
"GET /api/remote/youtube",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
await setup("https://www.youtube.com/embed/x?list=PL2");
|
||||
@@ -114,14 +114,14 @@ describe("useYouTubePlaylist", () => {
|
||||
test("no list in the URL: no rows, no requests", async () => {
|
||||
const { result } = await setup("https://www.youtube.com/embed/x");
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(calls("GET /api/youtube/playlist")).toHaveLength(0);
|
||||
expect(calls("GET /api/remote/youtube")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a new embed drops the old list; unmount destroys the player", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const player = installYT();
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
"GET /api/remote/youtube",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
const { result, rerender, unmount } = await setup(
|
||||
@@ -129,7 +129,7 @@ describe("useYouTubePlaylist", () => {
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(1));
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
route("GET /api/youtube/playlist", () => fail("x", 500));
|
||||
route("GET /api/remote/youtube", () => fail("x", 500));
|
||||
rerender({ url: "https://www.youtube.com/embed/y?list=PL4" });
|
||||
expect(result.current.rows).toEqual([]);
|
||||
expect(result.current.index).toBe(-1);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { MemoryRouter, useLocation } from "react-router";
|
||||
import { useTabParam } from "../../../hooks/shared/useTabParam.ts";
|
||||
|
||||
describe("useTabParam", () => {
|
||||
const TABS = ["dumps", "playlists", "votes"] as const;
|
||||
function setup(path: string, state?: unknown) {
|
||||
const TABS = ["overview", "playlists", "upvoted"] as const;
|
||||
function setup(path: string, base = "/@ann", state?: unknown) {
|
||||
let loc!: ReturnType<typeof useLocation>;
|
||||
const hook = renderHook(() => {
|
||||
loc = useLocation();
|
||||
return useTabParam(TABS, "dumps");
|
||||
return useTabParam(base, TABS, "overview");
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter
|
||||
@@ -26,36 +26,33 @@ describe("useTabParam", () => {
|
||||
return { hook, loc: () => loc };
|
||||
}
|
||||
|
||||
test("reads the tab after /~/", () => {
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
||||
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
||||
test("reads the segment after the base, the default on the bare path", () => {
|
||||
expect(setup("/@ann/playlists").hook.result.current[0]).toBe("playlists");
|
||||
expect(setup("/@ann/playlists/").hook.result.current[0]).toBe("playlists");
|
||||
// A segment after the tab's is the tab's own.
|
||||
expect(setup("/@ann/upvoted/more").hook.result.current[0]).toBe("upvoted");
|
||||
expect(setup("/@ann").hook.result.current[0]).toBe("overview");
|
||||
expect(setup("/@ann/").hook.result.current[0]).toBe("overview");
|
||||
});
|
||||
|
||||
test("the default tab collapses to the base path", () => {
|
||||
const { hook, loc } = setup("/users/ann/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/users/ann");
|
||||
test("an unknown segment, or the default's own name, is null", () => {
|
||||
expect(setup("/@ann/bogus").hook.result.current[0]).toBeNull();
|
||||
expect(setup("/@ann/overview").hook.result.current[0]).toBeNull();
|
||||
});
|
||||
|
||||
test("the default tab is the bare base", () => {
|
||||
const { hook, loc } = setup("/@ann/upvoted");
|
||||
act(() => hook.result.current[1]("overview"));
|
||||
expect(loc().pathname).toBe("/@ann");
|
||||
act(() => hook.result.current[1]("playlists"));
|
||||
expect(loc().pathname).toBe("/users/ann/~/playlists");
|
||||
});
|
||||
|
||||
test("at the root the default stays explicit", () => {
|
||||
const { hook, loc } = setup("/~/votes");
|
||||
act(() => hook.result.current[1]("dumps"));
|
||||
expect(loc().pathname).toBe("/~/dumps");
|
||||
expect(loc().pathname).toBe("/@ann/playlists");
|
||||
});
|
||||
|
||||
test("keeps the query string and the entry's state", () => {
|
||||
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
|
||||
act(() => hook.result.current[1]("votes"));
|
||||
expect(loc().pathname).toBe("/search/~/votes");
|
||||
expect(loc().search).toBe("?q=cats");
|
||||
expect(loc().state).toEqual({ back: "/x" });
|
||||
const { hook, loc } = setup("/search?q=x", "/search", { searchExit: 3 });
|
||||
act(() => hook.result.current[1]("upvoted"));
|
||||
expect(loc().pathname).toBe("/search/upvoted");
|
||||
expect(loc().search).toBe("?q=x");
|
||||
expect(loc().state).toEqual({ searchExit: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,9 +32,9 @@ function dumpRoutes(
|
||||
categoryIds: ["c1"],
|
||||
}),
|
||||
) {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
route("GET /api/users/:u/dumps/:slug", () => wire(dump));
|
||||
route(
|
||||
"GET /api/users/by-id/:id",
|
||||
"GET /api/users/:id",
|
||||
() => wire(makeUser({ id: "owner", username: "owner" })),
|
||||
);
|
||||
route("GET /api/dumps/:id/comments", () => []);
|
||||
@@ -45,16 +45,16 @@ function dumpRoutes(
|
||||
|
||||
function openDump(
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
url = "/dumps/a-find",
|
||||
url = "/@owner/dumps/a-find",
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Dump />, {
|
||||
route: url,
|
||||
path: "/dumps/:selectedDump",
|
||||
path: "/:handle/dumps/:slug",
|
||||
session,
|
||||
categories: [music],
|
||||
extraRoutes: (
|
||||
<Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />
|
||||
<Route path="/:handle/dumps/:slug/edit" element={<p>edit page</p>} />
|
||||
),
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
@@ -70,7 +70,7 @@ describe("Dump page", () => {
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
"/@owner",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
@@ -78,11 +78,26 @@ describe("Dump page", () => {
|
||||
);
|
||||
expect(screen.getByText("reasons").tagName).toBe("STRONG");
|
||||
await waitFor(() => expect(document.title).toMatch(/^A find · /));
|
||||
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
|
||||
expect(calls("GET /api/users/:u/dumps/:slug")[0].path).toBe(
|
||||
"/api/users/@owner/dumps/a-find",
|
||||
);
|
||||
// Its parts are read by its id.
|
||||
expect(calls("GET /api/dumps/:id/comments")[0].path).toBe(
|
||||
"/api/dumps/d1/comments",
|
||||
);
|
||||
});
|
||||
|
||||
test("a former slug or name: the URL takes the current one", async () => {
|
||||
dumpRoutes();
|
||||
openDump(null, "/@OWNER/dumps/old-find?x=1");
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
await waitFor(() => expect(currentPath()).toBe("/@owner/dumps/a-find?x=1"));
|
||||
// The new path names the dump on screen, which isn't fetched again.
|
||||
expect(calls("GET /api/users/:u/dumps/:slug")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a missing dump is an error page", async () => {
|
||||
route("GET /api/dumps/:id", () => fail("Dump not found", 404));
|
||||
route("GET /api/users/:u/dumps/:slug", () => fail("Dump not found", 404));
|
||||
route("GET /api/dumps/:id/comments", () => []);
|
||||
route("GET /api/dumps/:id/related", () => []);
|
||||
route("GET /api/dumps/:id/playlists", () => []);
|
||||
@@ -94,7 +109,7 @@ describe("Dump page", () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner can rename it in place, following the new slug", async () => {
|
||||
test("the owner can retitle it in place, which keeps its link", async () => {
|
||||
dumpRoutes();
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
@@ -102,7 +117,7 @@ describe("Dump page", () => {
|
||||
wire(
|
||||
makeDump({
|
||||
id: "d1",
|
||||
slug: "a-better-find",
|
||||
slug: "a-find",
|
||||
title: "A better find",
|
||||
userId: "owner",
|
||||
}),
|
||||
@@ -115,7 +130,7 @@ describe("Dump page", () => {
|
||||
await user.type(input, "A better find{Enter}");
|
||||
expect(await screen.findByRole("heading", { name: "A better find" }))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
|
||||
expect(currentPath()).toBe("/@owner/dumps/a-find");
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
||||
title: "A better find",
|
||||
});
|
||||
@@ -145,7 +160,7 @@ describe("Dump page", () => {
|
||||
dumpRoutes();
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("link", { name: "Edit" }));
|
||||
expect(currentPath()).toBe("/dumps/a-find/edit");
|
||||
expect(currentPath()).toBe("/@owner/dumps/a-find/edit");
|
||||
});
|
||||
|
||||
test("the title can be edited from the keyboard", async () => {
|
||||
@@ -224,7 +239,7 @@ describe("Dump page", () => {
|
||||
|
||||
test("adding to a playlist", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => [
|
||||
route("GET /api/me/playlists", () => [
|
||||
{
|
||||
playlist: wire(makePlaylist({ id: "p", title: "Mine" })),
|
||||
hasDump: true,
|
||||
@@ -244,7 +259,7 @@ describe("Dump page", () => {
|
||||
|
||||
test("a new playlist can be made from there, with the dump in it", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
route("GET /api/me/playlists", () => []);
|
||||
route(
|
||||
"POST /api/playlists",
|
||||
(req) =>
|
||||
@@ -276,7 +291,7 @@ describe("Dump page", () => {
|
||||
() => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]),
|
||||
);
|
||||
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||
openDump(null, "/dumps/a-find#comment-c9");
|
||||
openDump(null, "/@owner/dumps/a-find#comment-c9");
|
||||
await screen.findByText("linked");
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById("comment-c9")).toHaveClass(
|
||||
@@ -289,20 +304,20 @@ describe("Dump page", () => {
|
||||
|
||||
describe("DumpEdit page", () => {
|
||||
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
route("GET /api/users/:u/dumps/:slug", () => wire(dump));
|
||||
const user = userEvent.setup();
|
||||
renderApp(
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: `/dumps/${dump.slug ?? dump.id}/edit`,
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
route: `/@owner/dumps/${dump.slug}/edit`,
|
||||
path: "/:handle/dumps/:slug/edit",
|
||||
session,
|
||||
categories: [music],
|
||||
extraRoutes: (
|
||||
<>
|
||||
<Route path="/dumps/:selectedDump" element={<p>dump page</p>} />
|
||||
<Route path="/:handle/dumps/:slug" element={<p>dump page</p>} />
|
||||
<Route path="/" element={<p>home</p>} />
|
||||
</>
|
||||
),
|
||||
@@ -352,16 +367,57 @@ describe("DumpEdit page", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await screen.findByText("dump page");
|
||||
expect(currentPath()).toBe("/dumps/new-title");
|
||||
expect(currentPath()).toBe("/@owner/dumps/new-title");
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
||||
url: "https://a.test/",
|
||||
title: "New title",
|
||||
// Proposed from the new title, in the link field.
|
||||
slug: "new-title",
|
||||
comment: "old why",
|
||||
isPrivate: true,
|
||||
categoryIds: ["c1"],
|
||||
});
|
||||
});
|
||||
|
||||
test("the link follows the title until typed in, and is sent only when changed", async () => {
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" })));
|
||||
const user = openEdit(urlDump());
|
||||
const title = await screen.findByLabelText("Title");
|
||||
const link = screen.getByRole("textbox", { name: "Link" });
|
||||
expect(link).toHaveValue("a-find");
|
||||
expect(screen.getByText("/@owner/dumps/")).toBeInTheDocument();
|
||||
await user.type(title, " again");
|
||||
expect(link).toHaveValue("a-find-again");
|
||||
// Back to a title that slugifies as the saved one: the saved slug.
|
||||
await user.clear(title);
|
||||
await user.type(title, "A Find!");
|
||||
expect(link).toHaveValue("a-find");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await screen.findByText("dump page");
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).not.toHaveProperty("slug");
|
||||
});
|
||||
|
||||
test("a malformed link isn't sent, and a refused one says why", async () => {
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
() => fail('The slug "taken" is already taken', 409),
|
||||
);
|
||||
const user = openEdit(urlDump());
|
||||
const link = await screen.findByRole("textbox", { name: "Link" });
|
||||
await user.clear(link);
|
||||
await user.type(link, "Not OK");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText(/Lowercase letters, numbers/))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
||||
await user.clear(link);
|
||||
await user.type(link, "taken");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"is already taken",
|
||||
);
|
||||
});
|
||||
|
||||
test("clearing the note removes it", async () => {
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" })));
|
||||
const user = openEdit(urlDump());
|
||||
@@ -391,7 +447,7 @@ describe("DumpEdit page", () => {
|
||||
|
||||
test("refreshing the metadata", async () => {
|
||||
route(
|
||||
"POST /api/dumps/:id/refresh-metadata",
|
||||
"POST /api/dumps/:id/metadata",
|
||||
() =>
|
||||
wire({
|
||||
...urlDump(),
|
||||
@@ -561,8 +617,8 @@ describe("DumpEdit page", () => {
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: "/dumps/x/edit",
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
route: "/@owner/dumps/x/edit",
|
||||
path: "/:handle/dumps/:slug/edit",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { useParams } from "react-router";
|
||||
import { useLocation } from "react-router";
|
||||
import { Index } from "../../pages/Index.tsx";
|
||||
import { FEED_TABS, type FeedTab } from "../../../shared/paths.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
@@ -18,10 +19,19 @@ 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 { scope } = useParams();
|
||||
const categorySlug = scope === "~" ? undefined : scope;
|
||||
return <Index key={scope} categorySlug={categorySlug} />;
|
||||
const { pathname } = useLocation();
|
||||
const [first, second] = pathname.split("/").filter(Boolean);
|
||||
const isTab = (FEED_TABS as readonly string[]).includes(first ?? "");
|
||||
const categorySlug = first && !isTab ? first : undefined;
|
||||
return (
|
||||
<Index
|
||||
key={categorySlug ?? ""}
|
||||
categorySlug={categorySlug}
|
||||
feedTab={(isTab ? first : second) as FeedTab | undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(
|
||||
@@ -31,7 +41,7 @@ function setup(
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Scoped />, {
|
||||
route: url,
|
||||
path: "/:scope/:feedTab",
|
||||
path: "*",
|
||||
session,
|
||||
categories: [music],
|
||||
});
|
||||
@@ -59,35 +69,59 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
test("New sorts by date, Hot by score", async () => {
|
||||
route("GET /api/dumps/", () => page([older, newer]));
|
||||
const { user } = setup("/~/new");
|
||||
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(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);
|
||||
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/me/feed", () => 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");
|
||||
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");
|
||||
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() });
|
||||
route("GET /api/dumps", () => fail("boom", 500));
|
||||
setup("/new", { session: makeSession() });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Failed to load",
|
||||
);
|
||||
@@ -96,35 +130,35 @@ describe("Index feeds", () => {
|
||||
|
||||
test("sends the token so your own private dumps come back", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/dumps/", () => page([]));
|
||||
setup("/~/new", { session });
|
||||
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(
|
||||
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/",
|
||||
"GET /api/dumps",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([newer], true)
|
||||
: page([older], false),
|
||||
);
|
||||
setup("/~/new");
|
||||
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");
|
||||
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");
|
||||
route("GET /api/dumps", () => page([older]));
|
||||
const { socket } = setup("/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
@@ -138,8 +172,8 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
test("live votes update the count", async () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
const { socket } = setup("/~/new");
|
||||
route("GET /api/dumps", () => page([older]));
|
||||
const { socket } = setup("/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
@@ -154,8 +188,8 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
test("guests can't vote", async () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
setup("/~/new");
|
||||
route("GET /api/dumps", () => page([older]));
|
||||
setup("/new");
|
||||
await screen.findByText("Old but loved");
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
@@ -164,9 +198,9 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
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)));
|
||||
@@ -176,12 +210,17 @@ describe("Index feeds", () => {
|
||||
|
||||
test("a category scopes the feed, and live arrivals to it", async () => {
|
||||
route(
|
||||
"GET /api/categories/:slug/dumps",
|
||||
() => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]),
|
||||
"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/categories/:slug/dumps")[0].params.slug).toBe(
|
||||
expect(calls("GET /api/dumps")[0].url.searchParams.get("category")).toBe(
|
||||
"music",
|
||||
);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Music · /));
|
||||
@@ -204,23 +243,26 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
test("an unknown category is a 404", async () => {
|
||||
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
|
||||
route("GET /api/dumps", () => fail("nope", 404));
|
||||
setup("/nonsense/new");
|
||||
expect(await screen.findByRole("heading", { name: "404" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
setup("/~/followed");
|
||||
test("a guest on the follow tab gets the default feed, not a blank page", async () => {
|
||||
route("GET /api/dumps", () => page([older]));
|
||||
setup("/follow");
|
||||
await screen.findByText("Old but loved");
|
||||
});
|
||||
|
||||
test("the Android share sheet opens the new-dump form with the link", async () => {
|
||||
route("GET /api/dumps/", () => page([]));
|
||||
route("GET /api/preview", () => ({ reached: false, richContent: null }));
|
||||
route("GET /api/dumps/by-url", () => []);
|
||||
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", {
|
||||
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" });
|
||||
@@ -234,34 +276,45 @@ describe("Followed feed", () => {
|
||||
test("from people and from playlists", async () => {
|
||||
const session = makeSession();
|
||||
route(
|
||||
"GET /api/follows/feed/users",
|
||||
() => page([makeDump({ title: "By a friend" })]),
|
||||
"GET /api/me/feed",
|
||||
(req) =>
|
||||
page(
|
||||
req.url.searchParams.get("from") === "users"
|
||||
? [makeDump({ title: "By a friend" })]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
const { user } = setup("/~/followed", { session });
|
||||
const { user } = setup("/follow", { session });
|
||||
await screen.findByText("By a friend");
|
||||
await user.click(screen.getByRole("button", { name: "From playlists" }));
|
||||
expect(
|
||||
screen.getByText("Follow some public playlists to see their dumps here."),
|
||||
).toBeInTheDocument();
|
||||
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization"))
|
||||
.toBe(`Bearer ${session.token}`);
|
||||
const [feed] = calls("GET /api/me/feed");
|
||||
expect(feed.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
});
|
||||
|
||||
test("a category narrows it", async () => {
|
||||
route("GET /api/follows/feed/users", () => page([]));
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
setup("/music/followed", { session: makeSession() });
|
||||
route("GET /api/me/feed", () => page([]));
|
||||
setup("/music/follow", { session: makeSession() });
|
||||
await screen.findByText("Follow some users to see their dumps here.");
|
||||
expect(
|
||||
calls("GET /api/follows/feed/users")[0].url.searchParams.get("category"),
|
||||
).toBe("music");
|
||||
calls("GET /api/me/feed").map((r) => [
|
||||
r.url.searchParams.get("from"),
|
||||
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/follows/feed/users", () => fail("Internal error", 500));
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
setup("/~/followed", { session: makeSession() });
|
||||
route(
|
||||
"GET /api/me/feed",
|
||||
(req) =>
|
||||
req.url.searchParams.get("from") === "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/);
|
||||
|
||||
86
src/test/pages/Manage.test.tsx
Normal file
86
src/test/pages/Manage.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Manage } from "../../pages/Manage.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeCategory, makeSession, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
|
||||
function open(session = admin) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Manage />, { route: "/manage", path: "/manage", session });
|
||||
return user;
|
||||
}
|
||||
|
||||
describe("Manage", () => {
|
||||
test("an admin manages categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
let list = [music];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
route("POST /api/categories", (req) => {
|
||||
const c = makeCategory({ id: "c2", ...(req.body as object) });
|
||||
list = [...list, c];
|
||||
return wire(c);
|
||||
});
|
||||
route("DELETE /api/categories/:id", () => {
|
||||
list = list.filter((c) => c.id !== "c1");
|
||||
return null;
|
||||
});
|
||||
const user = open();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"cinema",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("button", { name: "Cinema" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
name: "Cinema",
|
||||
slug: "cinema",
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete category",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Music" })).not
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused category is reported, and Escape closes the editor", async () => {
|
||||
route("GET /api/categories", () => []);
|
||||
route("POST /api/categories", () => fail("Slug already taken", 409));
|
||||
const user = open();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"x{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Slug already taken",
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Add category" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("without category:manage, the page isn't found", async () => {
|
||||
open(makeSession({ id: "mod", role: "moderator" }));
|
||||
expect(await screen.findByText("This page does not exist."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,13 @@ const DAY = 86_400_000;
|
||||
|
||||
function open(items: Notification[], { hasMore = false } = {}) {
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
"GET /api/me/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page(items, hasMore)
|
||||
: page([]),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
route("POST /api/me/notifications/read", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
@@ -40,6 +40,8 @@ const n = {
|
||||
followerId: "f",
|
||||
followerUsername: "fan",
|
||||
playlistId: "p1",
|
||||
playlistSlug: "p1",
|
||||
playlistOwnerUsername: "bob",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
@@ -49,6 +51,8 @@ const n = {
|
||||
dumperId: "a",
|
||||
dumperUsername: "ann",
|
||||
dumpId: "d1",
|
||||
dumpSlug: "d1",
|
||||
dumpOwnerUsername: "bob",
|
||||
dumpTitle: "Song",
|
||||
},
|
||||
}),
|
||||
@@ -56,8 +60,12 @@ const n = {
|
||||
type: "playlist_dump_added",
|
||||
data: {
|
||||
dumpId: "d2",
|
||||
dumpSlug: "d2",
|
||||
dumpOwnerUsername: "bob",
|
||||
dumpTitle: "Tune",
|
||||
playlistId: "p",
|
||||
playlistSlug: "p",
|
||||
playlistOwnerUsername: "bob",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
@@ -67,6 +75,8 @@ const n = {
|
||||
voterId: "v",
|
||||
voterUsername: "bob",
|
||||
dumpId: "d3",
|
||||
dumpSlug: "d3",
|
||||
dumpOwnerUsername: "bob",
|
||||
dumpTitle: "Pic",
|
||||
},
|
||||
}),
|
||||
@@ -77,6 +87,8 @@ const n = {
|
||||
commenterUsername: "cy",
|
||||
commentId: "c1",
|
||||
dumpId: "d4",
|
||||
dumpSlug: "d4",
|
||||
dumpOwnerUsername: "bob",
|
||||
dumpTitle: "Post",
|
||||
},
|
||||
}),
|
||||
@@ -87,6 +99,8 @@ const n = {
|
||||
likerUsername: "lu",
|
||||
commentId: "c2",
|
||||
dumpId: "d5",
|
||||
dumpSlug: "d5",
|
||||
dumpOwnerUsername: "bob",
|
||||
dumpTitle: "Thing",
|
||||
},
|
||||
}),
|
||||
@@ -99,6 +113,8 @@ const n = {
|
||||
contextId: "c3",
|
||||
contextTitle: "",
|
||||
dumpId: "d6",
|
||||
dumpSlug: "d6",
|
||||
dumpOwnerUsername: "bob",
|
||||
},
|
||||
}),
|
||||
mentionChat: makeNotification({
|
||||
@@ -118,6 +134,8 @@ const n = {
|
||||
mentionerUsername: "mo",
|
||||
contextType: "playlist",
|
||||
contextId: "p9",
|
||||
playlistSlug: "faves",
|
||||
playlistOwnerUsername: "bob",
|
||||
contextTitle: "Faves",
|
||||
},
|
||||
}),
|
||||
@@ -128,26 +146,45 @@ describe("Notifications", () => {
|
||||
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");
|
||||
expect(await link(/started following you/)).toBe("/@fan");
|
||||
expect(await link(/followed your playlist/)).toBe("/@bob/playlists/p1");
|
||||
expect(await link(/^posted$/)).toBe("/@bob/dumps/d1");
|
||||
expect(await link(/was added to/)).toBe("/@bob/dumps/d2");
|
||||
expect(await link(/^upvoted$/)).toBe("/@bob/dumps/d3");
|
||||
expect(await link(/commented on/)).toBe("/@bob/dumps/d4#comment-c1");
|
||||
expect(await link(/liked your comment on/)).toBe(
|
||||
"/@bob/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",
|
||||
"/@bob/dumps/d6#comment-c3",
|
||||
"/notifications?chat=open",
|
||||
"/playlists/p9",
|
||||
"/@bob/playlists/faves",
|
||||
]);
|
||||
expect(screen.getByText("a comment")).toBeInTheDocument();
|
||||
expect(screen.getByText("the chat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Faves")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("one about something since deleted reads, but links nowhere", async () => {
|
||||
open([
|
||||
makeNotification({
|
||||
type: "dump_upvoted",
|
||||
data: {
|
||||
voterId: "v",
|
||||
voterUsername: "vic",
|
||||
dumpId: "gone",
|
||||
dumpTitle: "Gone dump",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const item = (await screen.findByText(/^upvoted$/)).closest("li")!;
|
||||
expect(item.querySelector("a.notification-item-link")).toBeNull();
|
||||
expect(item).toHaveTextContent("Gone dump");
|
||||
});
|
||||
|
||||
test("grouped by day", async () => {
|
||||
open([
|
||||
makeNotification({ createdAt: new Date() }),
|
||||
@@ -164,7 +201,7 @@ describe("Notifications", () => {
|
||||
open([n.follow, { ...n.upvoted, read: true }]);
|
||||
await screen.findByText(/started following you/);
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
@@ -178,24 +215,24 @@ describe("Notifications", () => {
|
||||
});
|
||||
|
||||
test("a list that loaded stays shown if marking it read fails", async () => {
|
||||
route("POST /api/notifications/read-all", () => new TypeError("offline"));
|
||||
route("POST /api/me/notifications/read", () => 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(calls("POST /api/me/notifications/read")).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));
|
||||
route("GET /api/me/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);
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("empty", async () => {
|
||||
@@ -215,7 +252,7 @@ describe("Notifications", () => {
|
||||
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)
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
@@ -225,13 +262,13 @@ describe("Notifications", () => {
|
||||
|
||||
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", () => ({}));
|
||||
route("GET /api/me/notifications", () => list.promise);
|
||||
route("POST /api/me/notifications/read", () => ({}));
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({ unreadNotificationCount: 1 });
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/notifications")).toHaveLength(1)
|
||||
expect(calls("GET /api/me/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.
|
||||
@@ -252,21 +289,21 @@ describe("Notifications", () => {
|
||||
expect(items[0]).toHaveClass("notification-item--unread");
|
||||
// The read-all after the load covered them: none is sent again.
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1);
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("load more", async () => {
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
"GET /api/me/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([n.follow], true)
|
||||
: page([n.upvoted], false),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
route("POST /api/me/notifications/read", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me });
|
||||
await user.click(await screen.findByRole("button", { name: "Load more" }));
|
||||
|
||||
@@ -30,6 +30,7 @@ function open(
|
||||
session = owner as ReturnType<typeof makeSession> | null,
|
||||
playlist = {} as Partial<Playlist>,
|
||||
items = dumps() as Dump[],
|
||||
url = "/@owner/playlists/mix",
|
||||
} = {},
|
||||
) {
|
||||
const pl = makePlaylist({
|
||||
@@ -41,11 +42,14 @@ function open(
|
||||
description: "Good **tunes**",
|
||||
...playlist,
|
||||
});
|
||||
route("GET /api/playlists/:id", () => wire({ ...pl, dumps: items }));
|
||||
route(
|
||||
"GET /api/users/:u/playlists/:slug",
|
||||
() => wire({ ...pl, dumps: items }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderApp(<PlaylistDetail />, {
|
||||
route: "/playlists/mix",
|
||||
path: "/playlists/:playlistId",
|
||||
route: url,
|
||||
path: "/:handle/playlists/:slug",
|
||||
session,
|
||||
extraRoutes: <Route path="/" element={<p>home</p>} />,
|
||||
});
|
||||
@@ -67,7 +71,10 @@ describe("PlaylistDetail", () => {
|
||||
expect(screen.getByText("tunes").tagName).toBe("STRONG");
|
||||
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
"/@owner",
|
||||
);
|
||||
expect(calls("GET /api/users/:u/playlists/:slug")[0].path).toBe(
|
||||
"/api/users/@owner/playlists/mix",
|
||||
);
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
|
||||
@@ -76,10 +83,13 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("not found", async () => {
|
||||
route("GET /api/playlists/:id", () => fail("Playlist not found", 404));
|
||||
route(
|
||||
"GET /api/users/:u/playlists/:slug",
|
||||
() => fail("Playlist not found", 404),
|
||||
);
|
||||
renderApp(<PlaylistDetail />, {
|
||||
route: "/playlists/x",
|
||||
path: "/playlists/:playlistId",
|
||||
route: "/@owner/playlists/x",
|
||||
path: "/:handle/playlists/:slug",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Playlist not found",
|
||||
@@ -100,7 +110,7 @@ describe("PlaylistDetail", () => {
|
||||
await screen.findByRole("heading", { name: "Mix" });
|
||||
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/playlists/p1/image?v=5000",
|
||||
"http://localhost:8000/api/playlists/p1/cover?v=5000",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,17 +139,17 @@ describe("PlaylistDetail", () => {
|
||||
description: "Better tunes",
|
||||
isPublic: true,
|
||||
});
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||
});
|
||||
|
||||
test("a new title moves to the new slug", async () => {
|
||||
test("a new title proposes a new link, which saving moves to", async () => {
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "remix",
|
||||
slug: (req.body as { slug: string }).slug,
|
||||
title: "Remix",
|
||||
userId: "owner",
|
||||
}),
|
||||
@@ -149,9 +159,30 @@ describe("PlaylistDetail", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const title = screen.getByPlaceholderText("Playlist title");
|
||||
await user.clear(title);
|
||||
await user.type(title, "Remix");
|
||||
await user.type(title, "Re-mix!");
|
||||
const link = screen.getByRole("textbox", { name: "Link" });
|
||||
expect(link).toHaveValue("re-mix");
|
||||
expect(screen.getByText("/@owner/playlists/")).toBeInTheDocument();
|
||||
await user.clear(link);
|
||||
await user.type(link, "remix");
|
||||
// Typed in, the link no longer follows the title.
|
||||
await user.type(title, "!");
|
||||
expect(link).toHaveValue("remix");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(currentPath()).toBe("/playlists/remix"));
|
||||
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/remix"));
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toMatchObject({
|
||||
title: "Re-mix!!",
|
||||
slug: "remix",
|
||||
});
|
||||
// The new path names the playlist on screen, which isn't fetched again.
|
||||
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a former slug or name: the URL takes the current one", async () => {
|
||||
open({ url: "/@OWNER/playlists/old-mix" });
|
||||
await screen.findByRole("heading", { name: "Mix" });
|
||||
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
|
||||
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a new cover shows once saved; a refused one says why", async () => {
|
||||
@@ -168,11 +199,11 @@ describe("PlaylistDetail", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), {
|
||||
route("PUT /api/playlists/:id/cover", () => fail("Not an image", 400), {
|
||||
once: true,
|
||||
});
|
||||
route(
|
||||
"POST /api/playlists/:id/image",
|
||||
"PUT /api/playlists/:id/cover",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
@@ -200,7 +231,7 @@ describe("PlaylistDetail", () => {
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/playlists/p1/image?v=9000",
|
||||
"http://localhost:8000/api/playlists/p1/cover?v=9000",
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -355,7 +386,7 @@ describe("PlaylistDetail", () => {
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||
});
|
||||
|
||||
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("ResetPassword", () => {
|
||||
expect(await screen.findByText("Passwords do not match"))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Set new password" }));
|
||||
expect(calls("POST /api/users/reset-password")).toHaveLength(0);
|
||||
expect(calls("PUT /api/password-resets/:token")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sets the password, then offers login", async () => {
|
||||
route("POST /api/users/reset-password", () => ({}));
|
||||
route("PUT /api/password-resets/:token", () => ({}));
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("New password"), "longenough");
|
||||
await user.type(
|
||||
@@ -49,8 +49,9 @@ describe("ResetPassword", () => {
|
||||
"longenough{Enter}",
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Password updated" });
|
||||
expect(calls("POST /api/users/reset-password")[0].body).toEqual({
|
||||
token: "rst",
|
||||
const [reset] = calls("PUT /api/password-resets/:token");
|
||||
expect(reset.params.token).toBe("rst");
|
||||
expect(reset.body).toEqual({
|
||||
newPassword: "longenough",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Go to login" }));
|
||||
@@ -59,7 +60,7 @@ describe("ResetPassword", () => {
|
||||
|
||||
test("an expired token is reported", async () => {
|
||||
route(
|
||||
"POST /api/users/reset-password",
|
||||
"PUT /api/password-resets/:token",
|
||||
() => fail("Reset link expired", 400),
|
||||
);
|
||||
const user = setup();
|
||||
|
||||
@@ -61,10 +61,10 @@ describe("Search", () => {
|
||||
expect(req.url.searchParams.get("q")).toBe("cat");
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
await user.click(screen.getByRole("button", { name: "Users (1)" }));
|
||||
expect(currentPath()).toBe("/search/~/users?q=cat");
|
||||
expect(currentPath()).toBe("/search/users?q=cat");
|
||||
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/catlover",
|
||||
"/@catlover",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
|
||||
expect(screen.getByText("Cats")).toBeInTheDocument();
|
||||
|
||||
206
src/test/pages/Settings.test.tsx
Normal file
206
src/test/pages/Settings.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Settings } from "../../pages/Settings.tsx";
|
||||
import { DEFAULT_SETTINGS } from "../../../shared/settings.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeSession, makeToken, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
|
||||
|
||||
const instance = vi.hoisted(() => ({ emailEnabled: false }));
|
||||
vi.mock("../../config/instance.ts", () => ({
|
||||
DEFAULT_STYLE: "smooth",
|
||||
get EMAIL_ENABLED() {
|
||||
return instance.emailEnabled;
|
||||
},
|
||||
}));
|
||||
|
||||
const annSession = makeSession({
|
||||
id: "ann",
|
||||
username: "ann",
|
||||
email: "ann@x.test",
|
||||
});
|
||||
|
||||
function open(session = annSession) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Settings />, { route: "/settings", path: "/settings", session });
|
||||
return user;
|
||||
}
|
||||
|
||||
const row = (label: string) =>
|
||||
screen.getByText(label).closest(".profile-appearance-row") as HTMLElement;
|
||||
|
||||
describe("Settings", () => {
|
||||
afterEach(() => {
|
||||
instance.emailEnabled = false;
|
||||
document.documentElement.removeAttribute("data-style");
|
||||
document.documentElement.removeAttribute("data-color-scheme");
|
||||
});
|
||||
|
||||
test("theme, colour scheme, default tab and sounds; no digests without SMTP", async () => {
|
||||
const user = open();
|
||||
expect(await screen.findByRole("heading", { name: "Settings" }))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Brutalist" }));
|
||||
expect(document.documentElement.getAttribute("data-style")).toBe(
|
||||
"brutalist",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Dark" }));
|
||||
expect(document.documentElement.getAttribute("data-color-scheme")).toBe(
|
||||
"dark",
|
||||
);
|
||||
await user.click(
|
||||
within(row("Default tab")).getByRole("button", { name: "Followed" }),
|
||||
);
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("follow");
|
||||
await user.click(
|
||||
within(row("Sounds")).getByRole("button", { name: "Off" }),
|
||||
);
|
||||
expect(localStorage.getItem("sounds")).toBe("off");
|
||||
expect(within(row("Weekly digest")).getByRole("button", { name: "On" }))
|
||||
.toBeDisabled();
|
||||
expect(screen.getByText(/doesn't send email/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the digest switches save to the account", async () => {
|
||||
instance.emailEnabled = true;
|
||||
route("GET /api/me/settings", () => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
digestMonthly: false,
|
||||
}));
|
||||
route(
|
||||
"PATCH /api/me/settings",
|
||||
(req) => ({ ...DEFAULT_SETTINGS, ...(req.body as object) }),
|
||||
);
|
||||
const user = open();
|
||||
const weeklyOff = await waitFor(() => {
|
||||
const button = within(row("Weekly digest")).getByRole("button", {
|
||||
name: "Off",
|
||||
});
|
||||
expect(button).toBeEnabled();
|
||||
return button;
|
||||
});
|
||||
expect(
|
||||
within(row("Monthly recap")).getByRole("button", { name: "Off" }),
|
||||
).toHaveClass("active");
|
||||
expect(screen.queryByText(/doesn't send email/)).toBeNull();
|
||||
|
||||
await user.click(weeklyOff);
|
||||
await waitFor(() =>
|
||||
expect(calls("PATCH /api/me/settings")[0]?.body).toEqual({
|
||||
digestWeekly: false,
|
||||
})
|
||||
);
|
||||
expect(weeklyOff).toHaveClass("active");
|
||||
});
|
||||
|
||||
test("a new username goes to the account and the session", async () => {
|
||||
route(
|
||||
"PATCH /api/me",
|
||||
(req) => wire({ ...annSession.user, ...(req.body as object) }),
|
||||
);
|
||||
const user = open();
|
||||
const name = await screen.findByRole("textbox", { name: "Username" });
|
||||
expect(screen.getByText(/once every 30 days/)).toBeInTheDocument();
|
||||
const rename = screen.getByRole("button", { name: "Rename" });
|
||||
expect(rename).toBeDisabled();
|
||||
await user.clear(name);
|
||||
await user.type(name, "annie");
|
||||
await user.click(rename);
|
||||
await waitFor(() =>
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
|
||||
.toBe("annie")
|
||||
);
|
||||
expect(calls("PATCH /api/me")[0].body).toEqual({ username: "annie" });
|
||||
expect(screen.getByRole("textbox", { name: "Username" })).toHaveValue(
|
||||
"annie",
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused username says why, and a malformed one isn't sent", async () => {
|
||||
route(
|
||||
"PATCH /api/me",
|
||||
() => fail("A username can only be changed once every 30 days", 429),
|
||||
);
|
||||
const user = open();
|
||||
const name = await screen.findByRole("textbox", { name: "Username" });
|
||||
await user.type(name, " x{Enter}");
|
||||
expect(await screen.findByText(/Letters, numbers and underscores/))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("PATCH /api/me")).toHaveLength(0);
|
||||
await user.clear(name);
|
||||
await user.type(name, "annie{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"once every 30 days",
|
||||
);
|
||||
});
|
||||
|
||||
test("change password keeps you signed in with the fresh tokens", async () => {
|
||||
const freshToken = makeToken({ sub: "ann" });
|
||||
route(
|
||||
"PUT /api/me/password",
|
||||
() => ({ token: freshToken, mediaToken: "fresh-media" }),
|
||||
);
|
||||
const user = open();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Current password"),
|
||||
"oldpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"different",
|
||||
);
|
||||
expect(await within(dialog).findByText("Passwords do not match"))
|
||||
.toBeInTheDocument();
|
||||
await user.clear(within(dialog).getByLabelText("Confirm new password"));
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "Change password" }),
|
||||
);
|
||||
expect(await within(dialog).findByText("Password changed successfully."))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
freshToken,
|
||||
);
|
||||
expect(calls("PUT /api/me/password")[0].body).toEqual({
|
||||
currentPassword: "oldpassword",
|
||||
newPassword: "newpassword",
|
||||
});
|
||||
});
|
||||
|
||||
test("a wrong current password is reported", async () => {
|
||||
route(
|
||||
"PUT /api/me/password",
|
||||
() => fail("Current password is incorrect", 400),
|
||||
);
|
||||
const user = open();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(within(dialog).getByLabelText("Current password"), "wrong");
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword{Enter}",
|
||||
);
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"Current password is incorrect",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,12 +22,12 @@ describe("Unsubscribe", () => {
|
||||
userId: "u",
|
||||
cadence: "monthly",
|
||||
});
|
||||
route("POST /api/users/unsubscribe", () => ({ cadence: "monthly" }));
|
||||
route("POST /api/unsubscribe", () => ({ cadence: "monthly" }));
|
||||
const user = setup(token);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Turn off the monthly recap?" }),
|
||||
).toBeInTheDocument();
|
||||
expect(calls("POST /api/users/unsubscribe")).toHaveLength(0);
|
||||
expect(calls("POST /api/unsubscribe")).toHaveLength(0);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Turn off monthly recaps" }),
|
||||
@@ -35,14 +35,14 @@ describe("Unsubscribe", () => {
|
||||
expect(await screen.findByRole("heading", { name: "Unsubscribed" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
calls("POST /api/users/unsubscribe")[0].url.searchParams.get("token"),
|
||||
calls("POST /api/unsubscribe")[0].url.searchParams.get("token"),
|
||||
)
|
||||
.toBe(token);
|
||||
});
|
||||
|
||||
test("a refusal shows why", async () => {
|
||||
route(
|
||||
"POST /api/users/unsubscribe",
|
||||
"POST /api/unsubscribe",
|
||||
() => fail("Invalid unsubscribe link", 400),
|
||||
);
|
||||
const user = setup(
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { UserDumps } from "../../pages/UserDumps.tsx";
|
||||
import { fail, route } from "../fetch.ts";
|
||||
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
const ann = makeUser({ id: "ann", username: "ann" });
|
||||
const annSession = makeSession({ id: "ann", username: "ann" });
|
||||
|
||||
function openDumps(session: ReturnType<typeof makeSession> | null = null) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserDumps />, {
|
||||
route: "/users/ann/dumps",
|
||||
path: "/users/:username/dumps",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
describe("UserDumps", () => {
|
||||
test("lists a user's dumps under a header back to the profile", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ title: "Hers", userId: "ann" })]),
|
||||
);
|
||||
openDumps();
|
||||
expect(await screen.findByText("Hers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /New dump/ })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
route("GET /api/users/:u", () => fail("User not found", 404));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
openDumps();
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← Back to profile" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("on your own page: a new-dump button, and your dumps going private stay", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
const mine = makeDump({ id: "m", title: "Mine", userId: "ann" });
|
||||
route("GET /api/users/:u/dumps", () => page([mine]));
|
||||
const { user, socket } = openDumps(annSession);
|
||||
await screen.findByText("Mine");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...mine, isPrivate: true }),
|
||||
});
|
||||
expect(screen.getByText("Mine")).toBeInTheDocument();
|
||||
route("GET /api/preview", () => ({ reached: false, richContent: null }));
|
||||
await user.click(screen.getByRole("button", { name: /New dump/ }));
|
||||
expect(screen.getByRole("dialog", { name: "New dump" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
const list = ["x", "y", "z"].map((id) =>
|
||||
makeDump({ id, title: `T-${id}`, userId: "ann" })
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page(list));
|
||||
const { socket } = openDumps();
|
||||
await screen.findByText("T-z");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: true }),
|
||||
});
|
||||
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: false }),
|
||||
});
|
||||
const titles = Array.from(document.querySelectorAll(".dump-card-title"))
|
||||
.map((a) => a.textContent);
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
});
|
||||
@@ -23,13 +23,13 @@ describe("UserLogin", () => {
|
||||
|
||||
test("logs in, stores the session and goes home", async () => {
|
||||
const session = makeSession({ username: "ann" });
|
||||
route("POST /api/users/login", () => wire(session));
|
||||
route("POST /api/sessions", () => wire(session));
|
||||
const user = setup();
|
||||
expect(document.title).toMatch(/^Log in · /);
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
|
||||
await screen.findByText("home page");
|
||||
expect(calls("POST /api/users/login")[0].body).toEqual({
|
||||
expect(calls("POST /api/sessions")[0].body).toEqual({
|
||||
username: "ann",
|
||||
password: "hunter22",
|
||||
});
|
||||
@@ -45,7 +45,7 @@ describe("UserLogin", () => {
|
||||
".auth-card button[type=submit]",
|
||||
)!,
|
||||
);
|
||||
expect(calls("POST /api/users/login")).toHaveLength(0);
|
||||
expect(calls("POST /api/sessions")).toHaveLength(0);
|
||||
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
@@ -53,7 +53,7 @@ describe("UserLogin", () => {
|
||||
});
|
||||
|
||||
test("shows the server's refusal", async () => {
|
||||
route("POST /api/users/login", () => fail("Invalid credentials", 401));
|
||||
route("POST /api/sessions", () => fail("Invalid credentials", 401));
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
|
||||
@@ -64,7 +64,7 @@ describe("UserLogin", () => {
|
||||
});
|
||||
|
||||
test("says when the server can't be reached", async () => {
|
||||
route("POST /api/users/login", () => new TypeError("Failed to fetch"));
|
||||
route("POST /api/sessions", () => new TypeError("Failed to fetch"));
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
|
||||
@@ -75,7 +75,7 @@ describe("UserLogin", () => {
|
||||
|
||||
test("the button says so while logging in", async () => {
|
||||
const pending = deferred();
|
||||
route("POST /api/users/login", () => pending.promise);
|
||||
route("POST /api/sessions", () => pending.promise);
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
|
||||
@@ -90,7 +90,7 @@ describe("UserLogin", () => {
|
||||
});
|
||||
|
||||
test("password reset request", async () => {
|
||||
route("POST /api/users/request-password-reset", () => ({}));
|
||||
route("POST /api/password-resets", () => ({}));
|
||||
const user = setup();
|
||||
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
|
||||
await user.type(
|
||||
@@ -98,14 +98,14 @@ describe("UserLogin", () => {
|
||||
" a@b.c {Enter}",
|
||||
);
|
||||
await screen.findByText(/you'll receive a reset link/);
|
||||
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({
|
||||
expect(calls("POST /api/password-resets")[0].body).toEqual({
|
||||
email: "a@b.c",
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed reset request is reported, not claimed as sent", async () => {
|
||||
route(
|
||||
"POST /api/users/request-password-reset",
|
||||
"POST /api/password-resets",
|
||||
() => fail("Internal error", 500),
|
||||
);
|
||||
const user = setup();
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
page,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
const ann = makeUser({ id: "ann", username: "ann" });
|
||||
const annSession = makeSession({ id: "ann", username: "ann" });
|
||||
|
||||
function open(session: ReturnType<typeof makeSession> | null = null) {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route(
|
||||
"GET /api/users/:u/playlists",
|
||||
() =>
|
||||
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true),
|
||||
);
|
||||
route(
|
||||
"GET /api/users/:u/followed-playlists",
|
||||
() =>
|
||||
page([
|
||||
makePlaylist({
|
||||
id: "f1",
|
||||
title: "Someone's mix",
|
||||
userId: "bob",
|
||||
ownerUsername: "bob",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserPlaylists />, {
|
||||
route: "/users/ann/playlists",
|
||||
path: "/users/:username/playlists",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
describe("UserPlaylists", () => {
|
||||
test("created and followed playlists", async () => {
|
||||
open();
|
||||
expect(await screen.findByText("Her mix")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Created (1+)" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Followed (1)" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Someone's mix")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner creates one and it goes on top", async () => {
|
||||
route(
|
||||
"POST /api/playlists",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "new",
|
||||
title: (req.body as { title: string }).title,
|
||||
userId: "ann",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: /New playlist/ }));
|
||||
const dialog = screen.getByRole("dialog", { name: "New playlist" });
|
||||
await user.type(within(dialog).getByPlaceholderText("Title"), "Road trip");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Private" }));
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||
expect(await screen.findByText("Road trip")).toBeInTheDocument();
|
||||
expect(calls("POST /api/playlists")[0].body).toEqual({
|
||||
title: "Road trip",
|
||||
isPublic: false,
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner deletes one, after confirming", async () => {
|
||||
route(
|
||||
"DELETE /api/playlists/:id",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("No playlists yet.");
|
||||
});
|
||||
|
||||
test("a refused delete keeps the playlist and says why", async () => {
|
||||
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||
expect(screen.getByText("Her mix")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("live: her new public playlist appears, one gone private leaves", async () => {
|
||||
const { socket } = open();
|
||||
await screen.findByText("Her mix");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
title: "Her mix",
|
||||
userId: "ann",
|
||||
isPublic: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
page,
|
||||
wire,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
const ann = makeUser({
|
||||
id: "ann",
|
||||
@@ -34,23 +34,21 @@ function open(
|
||||
{
|
||||
session = null as ReturnType<typeof makeSession> | null,
|
||||
user = ann as PublicUser,
|
||||
url = "/users/ann",
|
||||
url = "/@ann",
|
||||
dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })],
|
||||
votes = [makeDump({ id: "v1", title: "She liked this" })],
|
||||
playlists = [makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })],
|
||||
categories = [] as ReturnType<typeof makeCategory>[],
|
||||
} = {},
|
||||
) {
|
||||
route("GET /api/users/:u", () => wire(user));
|
||||
route("GET /api/users/:u/dumps", () => page(dumps));
|
||||
route("GET /api/users/:u/votes", () => page(votes));
|
||||
route(
|
||||
"GET /api/users/:u/playlists",
|
||||
() => page([makePlaylist({ title: "Her mix", userId: "ann" })]),
|
||||
);
|
||||
route("GET /api/users/:u/upvoted", () => page(votes));
|
||||
route("GET /api/users/:u/playlists", () => page(playlists));
|
||||
const u = userEvent.setup();
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: url,
|
||||
path: "/users/:username/*",
|
||||
path: "/:handle/*",
|
||||
session,
|
||||
categories,
|
||||
});
|
||||
@@ -58,32 +56,39 @@ function open(
|
||||
}
|
||||
|
||||
describe("UserPublicProfile", () => {
|
||||
test("header, description, and the dumps tab", async () => {
|
||||
test("header, description, and the overview", async () => {
|
||||
open();
|
||||
expect(await screen.findByRole("heading", { name: "ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/root",
|
||||
"/@root",
|
||||
);
|
||||
expect(screen.getByText("dump").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
expect(screen.getByText("She liked this")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Her dump")).toBeInTheDocument();
|
||||
expect(await screen.findByText("She liked this")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps (1)" }))
|
||||
.toBeInTheDocument();
|
||||
const [dumpsAll, votesAll] = screen.getAllByRole("link", {
|
||||
name: "View all →",
|
||||
});
|
||||
expect(dumpsAll).toHaveAttribute("href", "/@ann/dumps");
|
||||
expect(votesAll).toHaveAttribute("href", "/@ann/upvoted");
|
||||
await waitFor(() => expect(document.title).toMatch(/^ann · /));
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
expect(screen.queryByRole("link", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
// The lists are read by the user's id, which a rename doesn't change.
|
||||
expect(calls("GET /api/users/:u/dumps")[0].path).toBe(
|
||||
"/api/users/ann/dumps",
|
||||
);
|
||||
expect(calls("GET /api/users/:u")[0].path).toBe("/api/users/@ann");
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
route("GET /api/users/:u", () => fail("User not found", 404));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
route("GET /api/users/:u/votes", () => page([]));
|
||||
route("GET /api/users/:u/playlists", () => page([]));
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: "/users/nobody",
|
||||
path: "/users/:username/*",
|
||||
route: "/@nobody",
|
||||
path: "/:handle/*",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"User not found",
|
||||
@@ -95,50 +100,166 @@ describe("UserPublicProfile", () => {
|
||||
expect(await screen.findByText("Moderator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tabs are in the URL", async () => {
|
||||
test("tabs are plain segments, the overview the bare path", async () => {
|
||||
const { user } = open();
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
await user.click(screen.getByRole("button", { name: "Playlists" }));
|
||||
expect(currentPath()).toBe("/users/ann/~/playlists");
|
||||
expect(screen.getByText("Her mix")).toBeInTheDocument();
|
||||
const tabs = document.querySelector(".profile-tabs") as HTMLElement;
|
||||
await user.click(within(tabs).getByRole("button", { name: "Playlists" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists");
|
||||
expect(await screen.findByText("Her mix")).toBeInTheDocument();
|
||||
await user.click(within(tabs).getByRole("button", { name: "Overview" }));
|
||||
expect(currentPath()).toBe("/@ann");
|
||||
});
|
||||
|
||||
test("followed: people and playlists, with more people on demand", async () => {
|
||||
test("a tab or view the profile hasn't is not found", async () => {
|
||||
for (const url of ["/@ann/nope", "/@ann/follow/users", "/@ann/dumps/x"]) {
|
||||
const { unmount } = renderApp(<UserPublicProfile />, {
|
||||
route: url,
|
||||
path: "/:handle/*",
|
||||
});
|
||||
expect(await screen.findByText("This page does not exist."))
|
||||
.toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test("a former name or another case: the URL takes the current one", async () => {
|
||||
open({ url: "/@ANN_OLD/playlists?x=1" });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
await waitFor(() => expect(currentPath()).toBe("/@ann/playlists?x=1"));
|
||||
expect(await screen.findByText("Her mix")).toBeInTheDocument();
|
||||
// The URL's change names the user on screen, who isn't fetched again.
|
||||
expect(calls("GET /api/users/:u")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the dumps tab lists them all; a visitor loses one gone private, and gets it back", async () => {
|
||||
const list = ["x", "y", "z"].map((id) =>
|
||||
makeDump({ id, title: `T-${id}`, userId: "ann" })
|
||||
);
|
||||
const { socket } = open({ url: "/@ann/dumps", dumps: list });
|
||||
await screen.findByText("T-z");
|
||||
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /New dump/ })).not
|
||||
.toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: true }),
|
||||
});
|
||||
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: false }),
|
||||
});
|
||||
const titles = Array.from(document.querySelectorAll(".dump-card-title"))
|
||||
.map((a) => a.textContent);
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
|
||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||
open({ url: "/@ann/dumps" });
|
||||
// After open's, so that it wins.
|
||||
route(
|
||||
"GET /api/users/:u/followed-users",
|
||||
"GET /api/users/:u/dumps",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeDump({ title: "First", userId: "ann" })], true)
|
||||
: page([makeDump({ title: "Second", userId: "ann" })]),
|
||||
);
|
||||
await screen.findByText("First");
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
|
||||
expect(await screen.findByText("Second")).toBeInTheDocument();
|
||||
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the upvoted tab follows their votes", async () => {
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
|
||||
);
|
||||
const { socket } = open({ url: "/@ann/upvoted" });
|
||||
await screen.findByText("She liked this");
|
||||
expect(screen.getByRole("heading", { name: "Upvoted" }))
|
||||
.toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v3",
|
||||
voteCount: 1,
|
||||
voterId: "ann",
|
||||
action: "cast",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
|
||||
"Fresh like",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("the playlists tab holds hers only, kept live", async () => {
|
||||
const { socket } = open({ url: "/@ann/playlists" });
|
||||
await screen.findByText("Her mix");
|
||||
expect(calls("GET /api/users/:u/follow/playlists")).toHaveLength(0);
|
||||
expect(screen.queryByRole("button", { name: "Delete playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
title: "Her mix",
|
||||
userId: "ann",
|
||||
isPublic: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("follow: users at the bare tab, playlists one segment further", async () => {
|
||||
route(
|
||||
"GET /api/users/:u/follow/users",
|
||||
() => page([makeUser({ username: "bob" })]),
|
||||
);
|
||||
route(
|
||||
"GET /api/users/:u/follow/playlists",
|
||||
() => page([makePlaylist({ title: "Their mix" })]),
|
||||
);
|
||||
const { user } = open({ url: "/@ann/follow" });
|
||||
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/@bob",
|
||||
);
|
||||
expect(calls("GET /api/users/:u/follow/playlists")).toHaveLength(0);
|
||||
const views = document.querySelector(".followed-sub-nav") as HTMLElement;
|
||||
await user.click(within(views).getByRole("button", { name: "Playlists" }));
|
||||
expect(currentPath()).toBe("/@ann/follow/playlists");
|
||||
expect(await screen.findByText("Their mix")).toBeInTheDocument();
|
||||
await user.click(within(views).getByRole("button", { name: "Users" }));
|
||||
expect(currentPath()).toBe("/@ann/follow");
|
||||
});
|
||||
|
||||
test("follow: more users as the list's end scrolls into view", async () => {
|
||||
route(
|
||||
"GET /api/users/:u/follow/users",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "bob" })], true)
|
||||
: page([makeUser({ username: "cy" })]),
|
||||
);
|
||||
route(
|
||||
"GET /api/users/:u/followed-playlists",
|
||||
() => page([makePlaylist({ title: "Their mix" })]),
|
||||
);
|
||||
const { user } = open({ url: "/users/ann/~/followed" });
|
||||
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/bob",
|
||||
);
|
||||
expect(screen.getByText("Their mix")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
open({ url: "/@ann/follow" });
|
||||
await screen.findByRole("link", { name: /@bob/ });
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
|
||||
expect(await screen.findByRole("link", { name: /@cy/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("followed: leaving the tab while it loads still loads it", async () => {
|
||||
const users = deferred<unknown>();
|
||||
route("GET /api/users/:u/followed-users", () => users.promise);
|
||||
route("GET /api/users/:u/followed-playlists", () => page([]));
|
||||
const { user } = open({ url: "/users/ann/~/followed" });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
await user.click(screen.getByRole("button", { name: "Playlists" }));
|
||||
users.resolve(page([makeUser({ username: "bob" })]));
|
||||
await user.click(screen.getByRole("button", { name: "Followed" }));
|
||||
expect(await screen.findByRole("link", { name: /@bob/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("invitees as a tree", async () => {
|
||||
@@ -147,25 +268,25 @@ describe("UserPublicProfile", () => {
|
||||
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
|
||||
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
|
||||
]));
|
||||
open({ url: "/users/ann/~/invitees" });
|
||||
open({ url: "/@ann/invitees" });
|
||||
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest(
|
||||
"li",
|
||||
)!;
|
||||
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
|
||||
test("visitors can follow; a settings link is only on your own profile", async () => {
|
||||
open({ session: makeSession({ id: "bob" }) });
|
||||
expect(await screen.findByRole("button", { name: "Follow ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
expect(screen.queryByRole("link", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("your own profile", () => {
|
||||
test("edit the description; clearing it really clears it", async () => {
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
"PATCH /api/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
@@ -178,12 +299,12 @@ describe("UserPublicProfile", () => {
|
||||
await user.clear(box);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("Who am I?")).toBeInTheDocument();
|
||||
expect(calls("PATCH /api/users/me")[0].body).toEqual({ description: "" });
|
||||
expect(calls("PATCH /api/me")[0].body).toEqual({ description: "" });
|
||||
});
|
||||
|
||||
test("edit the email", async () => {
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
"PATCH /api/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
@@ -200,7 +321,7 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("a refused email says why", async () => {
|
||||
route("PATCH /api/users/me", () => fail("Email already in use", 409));
|
||||
route("PATCH /api/me", () => fail("Email already in use", 409));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
@@ -213,7 +334,7 @@ describe("UserPublicProfile", () => {
|
||||
|
||||
test("upload an avatar", async () => {
|
||||
route(
|
||||
"POST /api/avatars/me",
|
||||
"PUT /api/me/avatar",
|
||||
() =>
|
||||
wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }),
|
||||
);
|
||||
@@ -230,13 +351,13 @@ describe("UserPublicProfile", () => {
|
||||
expect(document.querySelector(".profile-avatar-wrapper img"))
|
||||
.toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/ann?v=7000",
|
||||
"http://localhost:8000/api/users/ann/avatar?v=7000",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test("a rejected avatar says why", async () => {
|
||||
route("POST /api/avatars/me", () => fail("Not an image", 400));
|
||||
route("PUT /api/me/avatar", () => fail("Not an image", 400));
|
||||
const { user } = open({ session: annSession });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
@@ -293,113 +414,22 @@ describe("UserPublicProfile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("settings: theme, colour scheme, default tab and sounds", async () => {
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
test("your settings, and the site's tools for an admin, are a link away", async () => {
|
||||
open({
|
||||
session: { ...annSession, user: { ...annSession.user, role: "admin" } },
|
||||
user: { ...ann, role: "admin" },
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Brutalist" }),
|
||||
);
|
||||
expect(document.documentElement.getAttribute("data-style")).toBe(
|
||||
"brutalist",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Dark" }));
|
||||
expect(document.documentElement.getAttribute("data-color-scheme")).toBe(
|
||||
"dark",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
||||
const sounds = screen.getByText("Sounds").closest(
|
||||
".profile-appearance-row",
|
||||
) as HTMLElement;
|
||||
await user.click(within(sounds).getByRole("button", { name: "Off" }));
|
||||
expect(localStorage.getItem("sounds")).toBe("off");
|
||||
// Without SMTP on the server, the digests can't be switched.
|
||||
const weekly = screen.getByText("Weekly digest").closest(
|
||||
".profile-appearance-row",
|
||||
) as HTMLElement;
|
||||
expect(within(weekly).getByRole("button", { name: "On" })).toBeDisabled();
|
||||
expect(screen.getByText(/doesn't send email/)).toBeInTheDocument();
|
||||
document.documentElement.removeAttribute("data-style");
|
||||
document.documentElement.removeAttribute("data-color-scheme");
|
||||
});
|
||||
|
||||
test("change password keeps you signed in with the fresh tokens", async () => {
|
||||
const freshToken = makeToken({ sub: "ann" });
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => ({ token: freshToken, mediaToken: "fresh-media" }),
|
||||
);
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Current password"),
|
||||
"oldpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"different",
|
||||
);
|
||||
expect(await within(dialog).findByText("Passwords do not match"))
|
||||
.toBeInTheDocument();
|
||||
await user.clear(within(dialog).getByLabelText("Confirm new password"));
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "Change password" }),
|
||||
);
|
||||
expect(await within(dialog).findByText("Password changed successfully."))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
freshToken,
|
||||
);
|
||||
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
|
||||
currentPassword: "oldpassword",
|
||||
newPassword: "newpassword",
|
||||
});
|
||||
});
|
||||
|
||||
test("a wrong current password is reported", async () => {
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => fail("Current password is incorrect", 400),
|
||||
);
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Current password"),
|
||||
"wrong",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword{Enter}",
|
||||
);
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"Current password is incorrect",
|
||||
expect(await screen.findByRole("link", { name: "Settings" }))
|
||||
.toHaveAttribute("href", "/settings");
|
||||
expect(screen.getByRole("link", { name: "Manage" })).toHaveAttribute(
|
||||
"href",
|
||||
"/manage",
|
||||
);
|
||||
// Their own profile has no manage tab: that's for others'.
|
||||
expect(
|
||||
within(document.querySelector(".profile-tabs") as HTMLElement)
|
||||
.queryByRole("button", { name: "Manage" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("log out", async () => {
|
||||
@@ -417,7 +447,7 @@ describe("UserPublicProfile", () => {
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
url: "/@ann/manage",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Moderator" }),
|
||||
@@ -442,7 +472,7 @@ describe("UserPublicProfile", () => {
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
url: "/@ann/manage",
|
||||
});
|
||||
await user.click(await screen.findByRole("button", { name: "Admin" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
@@ -450,90 +480,53 @@ describe("UserPublicProfile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("non-admins get no manage tab", async () => {
|
||||
test("an admin renames someone, and the URL follows", async () => {
|
||||
route(
|
||||
"PATCH /api/users/:id/username",
|
||||
(req) =>
|
||||
wire({
|
||||
...ann,
|
||||
username: (req.body as { username: string }).username,
|
||||
}),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/@ann/manage",
|
||||
});
|
||||
const name = await screen.findByRole("textbox", { name: "Username" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "annie{Enter}");
|
||||
await waitFor(() => expect(currentPath()).toBe("/@annie/manage"));
|
||||
expect(screen.getByRole("heading", { name: "annie" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("PATCH /api/users/:id/username")[0]).toMatchObject({
|
||||
path: "/api/users/ann/username",
|
||||
body: { username: "annie" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a refused rename says why", async () => {
|
||||
route(
|
||||
"PATCH /api/users/:id/username",
|
||||
() => fail("Username already exists", 409),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/@ann/manage",
|
||||
});
|
||||
const name = await screen.findByRole("textbox", { name: "Username" });
|
||||
await user.type(name, "x{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Username already exists",
|
||||
);
|
||||
});
|
||||
|
||||
test("non-admins get no manage tab, nor its page", async () => {
|
||||
open({
|
||||
session: makeSession({ id: "mod", role: "moderator" }),
|
||||
url: "/users/ann/~/manage",
|
||||
url: "/@ann/manage",
|
||||
});
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
expect(screen.queryByRole("button", { name: "Manage" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("on your own profile, an admin manages categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
let list = [music];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
route("POST /api/categories", (req) => {
|
||||
const c = makeCategory({ id: "c2", ...(req.body as object) });
|
||||
list = [...list, c];
|
||||
return wire(c);
|
||||
});
|
||||
route("DELETE /api/categories/:id", () => {
|
||||
list = list.filter((c) => c.id !== "c1");
|
||||
return null;
|
||||
});
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
const { user } = open({
|
||||
session: admin,
|
||||
url: "/users/ann/~/manage",
|
||||
user: { ...ann, role: "admin" },
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"cinema",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("button", { name: "Cinema" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
name: "Cinema",
|
||||
slug: "cinema",
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete category",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Music" })).not
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused category is reported, and Escape closes the editor", async () => {
|
||||
route("GET /api/categories", () => []);
|
||||
route("POST /api/categories", () => fail("Slug already taken", 409));
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
const { user } = open({
|
||||
session: admin,
|
||||
url: "/users/ann/~/manage",
|
||||
user: { ...ann, role: "admin" },
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"x{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Slug already taken",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Name" }),
|
||||
"{Escape}",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add category" }))
|
||||
expect(await screen.findByText("This page does not exist."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("UserRegister", () => {
|
||||
test("registers with a valid invite", async () => {
|
||||
route("GET /api/invites/:t", () => ({}));
|
||||
const session = makeSession({ username: "new_one" });
|
||||
route("POST /api/users/register", () => wire(session));
|
||||
route("POST /api/users", () => wire(session));
|
||||
const user = setup();
|
||||
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
|
||||
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
|
||||
@@ -45,7 +45,7 @@ describe("UserRegister", () => {
|
||||
"longenough{Enter}",
|
||||
);
|
||||
await screen.findByText("home page");
|
||||
expect(calls("POST /api/users/register")[0].body).toEqual({
|
||||
expect(calls("POST /api/users")[0].body).toEqual({
|
||||
username: "new_one",
|
||||
password: "longenough",
|
||||
inviteToken: "inv-1",
|
||||
@@ -69,13 +69,13 @@ describe("UserRegister", () => {
|
||||
expect(await screen.findByText(/letters, numbers, or underscores/))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("At least 8 characters")).toBeInTheDocument();
|
||||
expect(calls("POST /api/users/register")).toHaveLength(0);
|
||||
expect(calls("POST /api/users")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("shows a server refusal (username taken)", async () => {
|
||||
route("GET /api/invites/:t", () => ({}));
|
||||
route(
|
||||
"POST /api/users/register",
|
||||
"POST /api/users",
|
||||
() => fail("Username already taken", 409),
|
||||
);
|
||||
const user = setup();
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { UserUpvoted } from "../../pages/UserUpvoted.tsx";
|
||||
import { route } from "../fetch.ts";
|
||||
import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
const ann = makeUser({ id: "ann", username: "ann" });
|
||||
const annSession = makeSession({ id: "ann", username: "ann" });
|
||||
|
||||
describe("UserUpvoted", () => {
|
||||
const voted = () => [
|
||||
makeDump({ id: "v1", title: "Liked one" }),
|
||||
makeDump({ id: "v2", title: "Liked two" }),
|
||||
];
|
||||
|
||||
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/votes", () => page(voted()));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserUpvoted />, {
|
||||
route: "/users/ann/upvoted",
|
||||
path: "/users/:username/upvoted",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
test("lists what they upvoted", async () => {
|
||||
openVotes();
|
||||
expect(await screen.findByText("Liked one")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Upvoted" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("your own list shows even before the live connection says what you voted", async () => {
|
||||
openVotes(annSession);
|
||||
// The socket is never opened: no welcome, no myVotes.
|
||||
expect(await screen.findByText("Liked one")).toBeInTheDocument();
|
||||
expect(screen.getByText("Liked two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("unvoting on your own list fades the dump out", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { socket } = openVotes(annSession);
|
||||
await screen.findByText("Liked one");
|
||||
socket.welcome({ myVotes: ["v1", "v2"] });
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v1",
|
||||
voteCount: 0,
|
||||
voterId: "ann",
|
||||
action: "remove",
|
||||
});
|
||||
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
|
||||
"dump-card--fading",
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("their new upvote appears at the top", async () => {
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
|
||||
);
|
||||
const { socket } = openVotes();
|
||||
await screen.findByText("Liked one");
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v3",
|
||||
voteCount: 1,
|
||||
voterId: "ann",
|
||||
action: "cast",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
|
||||
"Fresh like",
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -209,19 +209,17 @@ export function defaultRoutes(
|
||||
() => categories.map((c) => JSON.parse(JSON.stringify(c))),
|
||||
fallback,
|
||||
);
|
||||
route("GET /api/follows/status", () => ({
|
||||
route("GET /api/me/follow", () => ({
|
||||
followedUserIds: [],
|
||||
followedPlaylistIds: [],
|
||||
}), fallback);
|
||||
// Standing, not fallbacks: a page test's own `GET /api/users/:u` would
|
||||
// otherwise answer for "me" and hand the session someone else's role.
|
||||
route(
|
||||
"GET /api/users/me",
|
||||
"GET /api/me",
|
||||
() => JSON.parse(JSON.stringify(session?.user ?? null)),
|
||||
);
|
||||
route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" }));
|
||||
route("GET /api/me/media-token", () => ({ mediaToken: "media-token" }));
|
||||
route(
|
||||
"GET /api/users/me/settings",
|
||||
"GET /api/me/settings",
|
||||
() => ({ ...DEFAULT_SETTINGS }),
|
||||
fallback,
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ const album = {
|
||||
|
||||
describe("resolveBandcampQueue", () => {
|
||||
test("turns the streamable tracks into a queue", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => album);
|
||||
route("GET /api/remote/bandcamp", () => album);
|
||||
const items = await resolveBandcampQueue(album.sourceUrl, {
|
||||
dumpHref: "/dumps/x",
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
@@ -63,21 +63,21 @@ describe("resolveBandcampQueue", () => {
|
||||
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
|
||||
});
|
||||
expect(items[1]).toMatchObject({ resolveIndex: 2 });
|
||||
const [req] = calls("GET /api/bandcamp/tracks");
|
||||
const [req] = calls("GET /api/remote/bandcamp");
|
||||
expect(req.url.searchParams.get("url")).toBe(album.sourceUrl);
|
||||
expect(req.url.searchParams.has("force")).toBe(false);
|
||||
});
|
||||
|
||||
test("force asks the server to skip its cache", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => album);
|
||||
route("GET /api/remote/bandcamp", () => album);
|
||||
await resolveBandcampQueue(album.sourceUrl, {}, { force: true });
|
||||
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
|
||||
expect(calls("GET /api/remote/bandcamp")[0].url.searchParams.get("force"))
|
||||
.toBe("1");
|
||||
});
|
||||
|
||||
test("falls back to the given artwork", async () => {
|
||||
route(
|
||||
"GET /api/bandcamp/tracks",
|
||||
"GET /api/remote/bandcamp",
|
||||
() => ({ ...album, artworkUrl: undefined }),
|
||||
);
|
||||
const [item] = await resolveBandcampQueue(album.sourceUrl, {
|
||||
@@ -87,13 +87,13 @@ describe("resolveBandcampQueue", () => {
|
||||
});
|
||||
|
||||
test("throws on an HTTP error, an error envelope, or nothing to play", async () => {
|
||||
route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true });
|
||||
route("GET /api/remote/bandcamp", () => fail("down", 502), { once: true });
|
||||
await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/);
|
||||
|
||||
route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true });
|
||||
route("GET /api/remote/bandcamp", () => fail("nope", 200), { once: true });
|
||||
await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/);
|
||||
|
||||
route("GET /api/bandcamp/tracks", () => ({
|
||||
route("GET /api/remote/bandcamp", () => ({
|
||||
...album,
|
||||
tracks: [album.tracks[1]],
|
||||
}));
|
||||
|
||||
@@ -63,12 +63,12 @@ describe("stream sources", () => {
|
||||
|
||||
test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => {
|
||||
route(
|
||||
"GET /api/bandcamp/tracks",
|
||||
"GET /api/remote/bandcamp",
|
||||
() => ({ ...album, artworkUrl: undefined }),
|
||||
);
|
||||
const items = await resolveStreamQueue(stream);
|
||||
expect(items[0].artworkUrl).toBe("https://old/art.png");
|
||||
expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force"))
|
||||
expect(calls("GET /api/remote/bandcamp")[0].url.searchParams.get("force"))
|
||||
.toBe("1");
|
||||
});
|
||||
|
||||
|
||||
@@ -14,20 +14,27 @@ describe("extractPeaks", () => {
|
||||
close = close;
|
||||
},
|
||||
);
|
||||
route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2])));
|
||||
route(
|
||||
"GET /api/dumps/:id/file",
|
||||
() => new Response(new Uint8Array([1, 2])),
|
||||
);
|
||||
|
||||
const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3);
|
||||
const peaks = await extractPeaks(
|
||||
"http://localhost:8000/api/dumps/w1/file",
|
||||
3,
|
||||
);
|
||||
expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]);
|
||||
expect(close).toHaveBeenCalled();
|
||||
|
||||
await extractPeaks("http://localhost:8000/api/files/w1", 3);
|
||||
await extractPeaks("http://localhost:8000/api/dumps/w1/file", 3);
|
||||
expect(decode).toHaveBeenCalledTimes(1);
|
||||
expect(calls("GET /api/files/:id")).toHaveLength(1);
|
||||
expect(calls("GET /api/dumps/:id/file")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a failed fetch throws", async () => {
|
||||
route("GET /api/files/:id", () => fail("gone", 404));
|
||||
await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects
|
||||
route("GET /api/dumps/:id/file", () => fail("gone", 404));
|
||||
await expect(extractPeaks("http://localhost:8000/api/dumps/w2/file", 3))
|
||||
.rejects
|
||||
.toThrow(/404/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("youtube", () => {
|
||||
|
||||
test("fetchPlaylistTracks", async () => {
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
"GET /api/remote/youtube",
|
||||
(req) =>
|
||||
req.url.searchParams.get("list") === "PL"
|
||||
? { tracks: [{ videoId: "a", title: "A", duration: 3 }] }
|
||||
@@ -37,7 +37,7 @@ describe("youtube", () => {
|
||||
{ videoId: "a", title: "A", duration: 3 },
|
||||
]);
|
||||
await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/);
|
||||
route("GET /api/youtube/playlist", () => fail("x", 500));
|
||||
route("GET /api/remote/youtube", () => fail("x", 500));
|
||||
await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ describe("deserializers", () => {
|
||||
id: "d",
|
||||
kind: "url",
|
||||
title: "t",
|
||||
slug: "t",
|
||||
userId: "u",
|
||||
ownerUsername: "ann",
|
||||
createdAt,
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
@@ -77,14 +79,18 @@ describe("deserializers", () => {
|
||||
const p = deserializePlaylistWithDumps({
|
||||
id: "p",
|
||||
userId: "u",
|
||||
ownerUsername: "ann",
|
||||
title: "t",
|
||||
slug: "t",
|
||||
isPublic: true,
|
||||
createdAt,
|
||||
dumps: [{
|
||||
id: "d",
|
||||
kind: "url",
|
||||
title: "t",
|
||||
slug: "t",
|
||||
userId: "u",
|
||||
ownerUsername: "ann",
|
||||
createdAt,
|
||||
voteCount: 0,
|
||||
commentCount: 0,
|
||||
@@ -94,7 +100,15 @@ describe("deserializers", () => {
|
||||
expect(p.dumps[0].createdAt).toBeInstanceOf(Date);
|
||||
|
||||
const mem = deserializePlaylistMembership({
|
||||
playlist: { id: "p", userId: "u", title: "t", isPublic: true, createdAt },
|
||||
playlist: {
|
||||
id: "p",
|
||||
userId: "u",
|
||||
ownerUsername: "ann",
|
||||
title: "t",
|
||||
slug: "t",
|
||||
isPublic: true,
|
||||
createdAt,
|
||||
},
|
||||
hasDump: true,
|
||||
});
|
||||
expect(mem.playlist.createdAt).toBeInstanceOf(Date);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { API_URL } from "../../../config/api.ts";
|
||||
describe("proxyImageUrl", () => {
|
||||
test("http images go through the proxy; https ones don't", () => {
|
||||
expect(proxyImageUrl("http://img.test/a.png")).toBe(
|
||||
`${API_URL}/api/proxy-image?url=${
|
||||
`${API_URL}/api/remote/image?url=${
|
||||
encodeURIComponent("http://img.test/a.png")
|
||||
}`,
|
||||
);
|
||||
@@ -18,11 +18,11 @@ describe("proxyImageUrl", () => {
|
||||
});
|
||||
test("force proxies https too", () => {
|
||||
expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain(
|
||||
"/api/proxy-image?url=",
|
||||
"/api/remote/image?url=",
|
||||
);
|
||||
});
|
||||
test("relative, local and non-http URLs are left alone", () => {
|
||||
expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x");
|
||||
expect(proxyImageUrl("/api/dumps/x/file")).toBe("/api/dumps/x/file");
|
||||
expect(proxyImageUrl("http://localhost:8000/a.png")).toBe(
|
||||
"http://localhost:8000/a.png",
|
||||
);
|
||||
@@ -38,7 +38,7 @@ describe("proxyImageUrl", () => {
|
||||
describe("canProxyImage", () => {
|
||||
test("third-party http(s) only", () => {
|
||||
expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true);
|
||||
expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false);
|
||||
expect(canProxyImage(`${API_URL}/api/dumps/x/file`)).toBe(false);
|
||||
expect(canProxyImage("/relative.png")).toBe(false);
|
||||
expect(canProxyImage("data:image/png;base64,AA")).toBe(false);
|
||||
expect(canProxyImage("http://[bad")).toBe(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
dumpFileUrl,
|
||||
dumpThumbnailUrl,
|
||||
dumpUrl,
|
||||
isAt,
|
||||
normalizeUrl,
|
||||
playlistImageUrl,
|
||||
playlistUrl,
|
||||
@@ -14,23 +15,25 @@ describe("urls", () => {
|
||||
const createdAt = new Date(1000);
|
||||
const updatedAt = new Date(2000);
|
||||
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt })).toBe(
|
||||
`${API_URL}/api/files/d1?v=42-1000`,
|
||||
`${API_URL}/api/dumps/d1/file?v=42-1000`,
|
||||
);
|
||||
// An edit (a replaced file among them) moves the version on.
|
||||
expect(dumpFileUrl({ id: "d1", fileSize: 42, createdAt, updatedAt }))
|
||||
.toBe(`${API_URL}/api/files/d1?v=42-2000`);
|
||||
.toBe(`${API_URL}/api/dumps/d1/file?v=42-2000`);
|
||||
expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe(
|
||||
`${API_URL}/api/files/d1?v=0-0&token=t%20o%2Bk`,
|
||||
`${API_URL}/api/dumps/d1/file?v=0-0&token=t%20o%2Bk`,
|
||||
);
|
||||
});
|
||||
test("dumpThumbnailUrl", () => {
|
||||
expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`);
|
||||
expect(dumpThumbnailUrl({ id: "d1" })).toBe(
|
||||
`${API_URL}/api/dumps/d1/thumbnail`,
|
||||
);
|
||||
expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe(
|
||||
`${API_URL}/api/thumbnails/d1?token=tk`,
|
||||
`${API_URL}/api/dumps/d1/thumbnail?token=tk`,
|
||||
);
|
||||
expect(
|
||||
dumpThumbnailUrl({ id: "d1", fileSize: 7, createdAt: new Date(5) }, "tk"),
|
||||
).toBe(`${API_URL}/api/thumbnails/d1?v=7-5&token=tk`);
|
||||
).toBe(`${API_URL}/api/dumps/d1/thumbnail?v=7-5&token=tk`);
|
||||
});
|
||||
test("normalizeUrl adds a scheme when there's none", () => {
|
||||
expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x");
|
||||
@@ -38,19 +41,28 @@ describe("urls", () => {
|
||||
expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B");
|
||||
expect(normalizeUrl(" ")).toBe("");
|
||||
});
|
||||
test("slug beats id in page URLs", () => {
|
||||
expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title");
|
||||
expect(dumpUrl({ id: "1" })).toBe("/dumps/1");
|
||||
expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix");
|
||||
expect(playlistUrl({ id: "2" })).toBe("/playlists/2");
|
||||
test("page URLs sit under the owner, by slug, never by id", () => {
|
||||
expect(dumpUrl({ ownerUsername: "bob", slug: "nice-title" })).toBe(
|
||||
"/@bob/dumps/nice-title",
|
||||
);
|
||||
expect(playlistUrl({ ownerUsername: "bob", slug: "mix" })).toBe(
|
||||
"/@bob/playlists/mix",
|
||||
);
|
||||
});
|
||||
test("isAt: the item a path names, the name in any case", () => {
|
||||
const item = { ownerUsername: "Bob", slug: "mix" };
|
||||
expect(isAt(item, "bob", "mix")).toBe(true);
|
||||
expect(isAt(item, "bob", "mix-2")).toBe(false);
|
||||
expect(isAt(item, "ann", "mix")).toBe(false);
|
||||
expect(isAt(null, "bob", "mix")).toBe(false);
|
||||
});
|
||||
test("playlistImageUrl carries the token for a private playlist only", () => {
|
||||
const updatedAt = new Date(9000);
|
||||
expect(playlistImageUrl({ id: "p", updatedAt, isPublic: true }, "tk"))
|
||||
.toBe(`${API_URL}/api/playlists/p/image?v=9000`);
|
||||
.toBe(`${API_URL}/api/playlists/p/cover?v=9000`);
|
||||
expect(playlistImageUrl({ id: "p", updatedAt, isPublic: false }, "tk"))
|
||||
.toBe(`${API_URL}/api/playlists/p/image?v=9000&token=tk`);
|
||||
.toBe(`${API_URL}/api/playlists/p/cover?v=9000&token=tk`);
|
||||
expect(playlistImageUrl({ id: "p", isPublic: false }, null))
|
||||
.toBe(`${API_URL}/api/playlists/p/image`);
|
||||
.toBe(`${API_URL}/api/playlists/p/cover`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user