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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user