v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,23 +22,40 @@ const owner = makeSession({ id: "owner", username: "owner" });
|
||||
const other = makeSession({ id: "other", username: "other" });
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
|
||||
function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) {
|
||||
function dumpRoutes(
|
||||
dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "a-find",
|
||||
title: "A find",
|
||||
userId: "owner",
|
||||
comment: "Because **reasons**",
|
||||
categoryIds: ["c1"],
|
||||
}),
|
||||
) {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })));
|
||||
route(
|
||||
"GET /api/users/by-id/:id",
|
||||
() => wire(makeUser({ id: "owner", username: "owner" })),
|
||||
);
|
||||
route("GET /api/dumps/:id/comments", () => []);
|
||||
route("GET /api/dumps/:id/related", () => []);
|
||||
route("GET /api/dumps/:id/playlists", () => []);
|
||||
return dump;
|
||||
}
|
||||
|
||||
function openDump(session: ReturnType<typeof makeSession> | null = null, url = "/dumps/a-find") {
|
||||
function openDump(
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
url = "/dumps/a-find",
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Dump />, {
|
||||
route: url,
|
||||
path: "/dumps/:selectedDump",
|
||||
session,
|
||||
categories: [music],
|
||||
extraRoutes: <Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />,
|
||||
extraRoutes: (
|
||||
<Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />
|
||||
),
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome();
|
||||
@@ -49,9 +66,16 @@ describe("Dump page", () => {
|
||||
test("shows the dump, its author, categories and note", async () => {
|
||||
dumpRoutes();
|
||||
openDump();
|
||||
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner");
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
|
||||
expect(await screen.findByRole("heading", { name: "A find" }))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
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");
|
||||
@@ -63,21 +87,38 @@ describe("Dump page", () => {
|
||||
route("GET /api/dumps/:id/related", () => []);
|
||||
route("GET /api/dumps/:id/playlists", () => []);
|
||||
openDump();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found");
|
||||
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Dump not found",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "← Back to all dumps" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner can rename it in place, following the new slug", async () => {
|
||||
dumpRoutes();
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" })));
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
() =>
|
||||
wire(
|
||||
makeDump({
|
||||
id: "d1",
|
||||
slug: "a-better-find",
|
||||
title: "A better find",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
||||
const input = screen.getByDisplayValue("A find");
|
||||
await user.clear(input);
|
||||
await user.type(input, "A better find{Enter}");
|
||||
expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "A better find" }))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" });
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
||||
title: "A better find",
|
||||
});
|
||||
});
|
||||
|
||||
test("a refused rename says why; Escape backs out", async () => {
|
||||
@@ -96,7 +137,8 @@ describe("Dump page", () => {
|
||||
const { user } = openDump(other);
|
||||
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
||||
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner gets an Edit link", async () => {
|
||||
@@ -116,33 +158,54 @@ describe("Dump page", () => {
|
||||
});
|
||||
|
||||
test("votes go through the socket", async () => {
|
||||
dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }));
|
||||
dumpRoutes(
|
||||
makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }),
|
||||
);
|
||||
const { user, socket } = openDump(other);
|
||||
await user.click(await screen.findByRole("button", { name: "Upvote" }));
|
||||
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
|
||||
expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3");
|
||||
expect(screen.getByRole("button", { name: "Remove vote" }))
|
||||
.toHaveTextContent("3");
|
||||
});
|
||||
|
||||
test("comments load, and live ones arrive", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/comments",
|
||||
() => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]),
|
||||
);
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByText("first");
|
||||
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) });
|
||||
socket.receive({
|
||||
type: "comment_created",
|
||||
comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })),
|
||||
});
|
||||
expect(screen.getByText("second")).toBeInTheDocument();
|
||||
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
|
||||
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
||||
// Another dump's comment isn't shown here.
|
||||
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) });
|
||||
socket.receive({
|
||||
type: "comment_created",
|
||||
comment: wire(
|
||||
makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" }),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("related dumps and the playlists it's in", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]));
|
||||
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/related",
|
||||
() => wire([makeDump({ title: "Links here" })]),
|
||||
);
|
||||
route(
|
||||
"GET /api/dumps/:id/playlists",
|
||||
() => wire([makePlaylist({ title: "Best of" })]),
|
||||
);
|
||||
openDump();
|
||||
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Related" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Links here")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Best of")).toBeInTheDocument();
|
||||
});
|
||||
@@ -151,16 +214,26 @@ describe("Dump page", () => {
|
||||
const d = dumpRoutes();
|
||||
const { socket } = openDump();
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) });
|
||||
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...d, title: "Retitled" }),
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Retitled" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adding to a playlist", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => [
|
||||
{ playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true },
|
||||
{
|
||||
playlist: wire(makePlaylist({ id: "p", title: "Mine" })),
|
||||
hasDump: true,
|
||||
},
|
||||
]);
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
||||
const row = await screen.findByRole("checkbox", { name: /Mine/ });
|
||||
@@ -172,24 +245,44 @@ 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("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title })));
|
||||
route(
|
||||
"POST /api/playlists",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "np",
|
||||
title: (req.body as { title: string }).title,
|
||||
}),
|
||||
),
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
||||
await user.click(await screen.findByRole("button", { name: "+ New playlist" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "+ New playlist" }),
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
|
||||
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
|
||||
expect(row).toHaveAttribute("aria-checked", "true");
|
||||
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1");
|
||||
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/np/dumps/d1",
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolls to a linked comment", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/comments",
|
||||
() => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]),
|
||||
);
|
||||
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||
openDump(null, "/dumps/a-find#comment-c9");
|
||||
await screen.findByText("linked");
|
||||
await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight"));
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById("comment-c9")).toHaveClass(
|
||||
"comment-node--highlight",
|
||||
)
|
||||
);
|
||||
expect(scroll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -199,7 +292,9 @@ describe("DumpEdit page", () => {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
const user = userEvent.setup();
|
||||
renderApp(
|
||||
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>,
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: `/dumps/${dump.slug ?? dump.id}/edit`,
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
@@ -226,11 +321,18 @@ describe("DumpEdit page", () => {
|
||||
url: "https://a.test/",
|
||||
comment: "old why",
|
||||
categoryIds: [],
|
||||
richContent: { type: "link", url: "https://a.test/", title: "Page's own title" },
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "https://a.test/",
|
||||
title: "Page's own title",
|
||||
},
|
||||
});
|
||||
|
||||
test("saves title, note, categories and visibility", async () => {
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })));
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "d1", slug: "new-title" })),
|
||||
);
|
||||
const user = openEdit(urlDump());
|
||||
const title = await screen.findByLabelText("Title");
|
||||
expect(document.title).toMatch(/^Editing A find/);
|
||||
@@ -264,7 +366,8 @@ describe("DumpEdit page", () => {
|
||||
const title = await screen.findByLabelText("Title");
|
||||
await user.click(screen.getByRole("button", { name: "Reset to default" }));
|
||||
expect(title).toHaveValue("Page's own title");
|
||||
expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Reset to default" }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
test("a refused save stays on the form", async () => {
|
||||
@@ -277,20 +380,51 @@ describe("DumpEdit page", () => {
|
||||
});
|
||||
|
||||
test("refreshing the metadata", async () => {
|
||||
route("POST /api/dumps/:id/refresh-metadata", () =>
|
||||
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } }));
|
||||
route(
|
||||
"POST /api/dumps/:id/refresh-metadata",
|
||||
() =>
|
||||
wire({
|
||||
...urlDump(),
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "https://a.test/",
|
||||
title: "Fresh title",
|
||||
description: "New blurb",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Refresh metadata" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Refresh metadata" }),
|
||||
);
|
||||
expect(await screen.findByText("New blurb")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("replacing a file sends a visibility change in the same request", async () => {
|
||||
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
|
||||
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }));
|
||||
const fileDump = makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
title: "Song",
|
||||
userId: "owner",
|
||||
fileName: "song.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
isPrivate: false,
|
||||
});
|
||||
route(
|
||||
"PUT /api/dumps/:id/file",
|
||||
() => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }),
|
||||
);
|
||||
const user = openEdit(fileDump);
|
||||
await screen.findByText("song.mp3");
|
||||
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'.fdz input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await screen.findByText("dump page");
|
||||
@@ -302,15 +436,31 @@ describe("DumpEdit page", () => {
|
||||
});
|
||||
|
||||
test("a failed file replacement stays on the form, with nothing half-saved", async () => {
|
||||
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
|
||||
const fileDump = makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
title: "Song",
|
||||
userId: "owner",
|
||||
fileName: "song.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
isPrivate: false,
|
||||
});
|
||||
route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)"));
|
||||
const user = openEdit(fileDump);
|
||||
await screen.findByText("song.mp3");
|
||||
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'.fdz input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("File too large (max 50 MB)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("File too large (max 50 MB)"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText("dump page")).not.toBeInTheDocument();
|
||||
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
|
||||
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
||||
@@ -319,36 +469,66 @@ describe("DumpEdit page", () => {
|
||||
test("deleting asks, then goes home", async () => {
|
||||
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Delete dump" }),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete dump",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("home");
|
||||
expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!))
|
||||
expect(
|
||||
JSON.parse(
|
||||
document.querySelector('[data-testid="location"]')!.getAttribute(
|
||||
"data-state",
|
||||
)!,
|
||||
),
|
||||
)
|
||||
.toEqual({ deletedDumpId: "d1" });
|
||||
});
|
||||
|
||||
test("a delete that can't reach the server says so", async () => {
|
||||
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Delete dump" }),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete dump",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
/Could not connect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a thumbnail that won't upload says so", async () => {
|
||||
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
|
||||
const user = openEdit(urlDump());
|
||||
await screen.findByLabelText("Title");
|
||||
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!;
|
||||
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }));
|
||||
const picker = document.querySelector<HTMLInputElement>(
|
||||
'.dump-edit-thumbnail-row input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
picker,
|
||||
new File([new Uint8Array(3)], "t.png", { type: "image/png" }),
|
||||
);
|
||||
expect(await screen.findByText("Not an image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("guests are sent to log in", () => {
|
||||
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, {
|
||||
route: "/dumps/x/edit",
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
});
|
||||
renderApp(
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: "/dumps/x/edit",
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useParams } from "react-router";
|
||||
import { Index } from "../../pages/Index.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makeSession,
|
||||
page,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
@@ -39,8 +45,18 @@ const titles = () =>
|
||||
.map((a) => a.textContent);
|
||||
|
||||
describe("Index feeds", () => {
|
||||
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
|
||||
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
|
||||
const older = makeDump({
|
||||
id: "old",
|
||||
title: "Old but loved",
|
||||
voteCount: 30,
|
||||
createdAt: new Date(Date.now() - 5 * HOUR),
|
||||
});
|
||||
const newer = makeDump({
|
||||
id: "new",
|
||||
title: "Brand new",
|
||||
voteCount: 0,
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
test("New sorts by date, Hot by score", async () => {
|
||||
route("GET /api/dumps/", () => page([older, newer]));
|
||||
@@ -72,7 +88,9 @@ describe("Index feeds", () => {
|
||||
test("a failed load shows an error and disables posting", async () => {
|
||||
route("GET /api/dumps/", () => fail("boom", 500));
|
||||
setup("/~/new", { session: makeSession() });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Failed to load",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -81,18 +99,25 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([]));
|
||||
setup("/~/new", { session });
|
||||
await screen.findByText("No dumps yet. Be the first!");
|
||||
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the end loads the next page", async () => {
|
||||
route("GET /api/dumps/", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([newer], true)
|
||||
: page([older], false));
|
||||
route(
|
||||
"GET /api/dumps/",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([newer], true)
|
||||
: page([older], false),
|
||||
);
|
||||
setup("/~/new");
|
||||
await screen.findByText("Brand new");
|
||||
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
|
||||
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");
|
||||
});
|
||||
@@ -101,7 +126,12 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
const { socket } = setup("/~/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(
|
||||
makeDump({ id: "live", title: "Just now", createdAt: new Date() }),
|
||||
),
|
||||
});
|
||||
expect(titles()[0]).toBe("Just now");
|
||||
socket.receive({ type: "dump_deleted", dumpId: "old" });
|
||||
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
|
||||
@@ -111,8 +141,16 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
const { socket } = setup("/~/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "old",
|
||||
voteCount: 31,
|
||||
voterId: "x",
|
||||
action: "cast",
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent(
|
||||
"31",
|
||||
);
|
||||
});
|
||||
|
||||
test("guests can't vote", async () => {
|
||||
@@ -134,13 +172,30 @@ 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"] })]));
|
||||
route(
|
||||
"GET /api/categories/:slug/dumps",
|
||||
() => page([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("music");
|
||||
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe(
|
||||
"music",
|
||||
);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Music · /));
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(
|
||||
makeDump({
|
||||
title: "Another song",
|
||||
categoryIds: ["c-music"],
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Another song")).toBeInTheDocument();
|
||||
});
|
||||
@@ -148,7 +203,8 @@ describe("Index feeds", () => {
|
||||
test("an unknown category is a 404", async () => {
|
||||
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
|
||||
setup("/nonsense/new");
|
||||
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "404" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
|
||||
@@ -161,22 +217,32 @@ describe("Index feeds", () => {
|
||||
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", { session: makeSession() });
|
||||
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", {
|
||||
session: makeSession(),
|
||||
});
|
||||
const dialog = await screen.findByRole("dialog", { name: "New dump" });
|
||||
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
|
||||
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue(
|
||||
"https://shared.test/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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" })]));
|
||||
route(
|
||||
"GET /api/follows/feed/users",
|
||||
() => page([makeDump({ title: "By a friend" })]),
|
||||
);
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
const { user } = setup("/~/followed", { 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}`);
|
||||
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}`);
|
||||
});
|
||||
|
||||
test("a category narrows it", async () => {
|
||||
@@ -184,7 +250,9 @@ describe("Followed feed", () => {
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
setup("/music/followed", { 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");
|
||||
expect(
|
||||
calls("GET /api/follows/feed/users")[0].url.searchParams.get("category"),
|
||||
).toBe("music");
|
||||
});
|
||||
|
||||
test("a server error reads as one, not as a lost connection", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Notifications } from "../../pages/Notifications.tsx";
|
||||
import type { Notification } from "../../model.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
@@ -12,33 +12,122 @@ const me = makeSession({ id: "me" });
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function open(items: Notification[], { hasMore = false } = {}) {
|
||||
route("GET /api/notifications", (req) =>
|
||||
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page(items, hasMore)
|
||||
: page([]),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
|
||||
socket.welcome({
|
||||
unreadNotificationCount: items.filter((n) => !n.read).length,
|
||||
});
|
||||
return { user, socket };
|
||||
}
|
||||
|
||||
const n = {
|
||||
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
|
||||
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
|
||||
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
|
||||
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
|
||||
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
|
||||
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
|
||||
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
|
||||
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
|
||||
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
|
||||
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }),
|
||||
follow: makeNotification({
|
||||
type: "user_followed",
|
||||
data: { followerId: "f", followerUsername: "fan" },
|
||||
}),
|
||||
playlistFollow: makeNotification({
|
||||
type: "playlist_followed",
|
||||
data: {
|
||||
followerId: "f",
|
||||
followerUsername: "fan",
|
||||
playlistId: "p1",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
posted: makeNotification({
|
||||
type: "user_dump_posted",
|
||||
data: {
|
||||
dumperId: "a",
|
||||
dumperUsername: "ann",
|
||||
dumpId: "d1",
|
||||
dumpTitle: "Song",
|
||||
},
|
||||
}),
|
||||
added: makeNotification({
|
||||
type: "playlist_dump_added",
|
||||
data: {
|
||||
dumpId: "d2",
|
||||
dumpTitle: "Tune",
|
||||
playlistId: "p",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
upvoted: makeNotification({
|
||||
type: "dump_upvoted",
|
||||
data: {
|
||||
voterId: "v",
|
||||
voterUsername: "bob",
|
||||
dumpId: "d3",
|
||||
dumpTitle: "Pic",
|
||||
},
|
||||
}),
|
||||
commented: makeNotification({
|
||||
type: "dump_commented",
|
||||
data: {
|
||||
commenterId: "c",
|
||||
commenterUsername: "cy",
|
||||
commentId: "c1",
|
||||
dumpId: "d4",
|
||||
dumpTitle: "Post",
|
||||
},
|
||||
}),
|
||||
liked: makeNotification({
|
||||
type: "comment_liked",
|
||||
data: {
|
||||
likerId: "l",
|
||||
likerUsername: "lu",
|
||||
commentId: "c2",
|
||||
dumpId: "d5",
|
||||
dumpTitle: "Thing",
|
||||
},
|
||||
}),
|
||||
mentionComment: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "comment",
|
||||
contextId: "c3",
|
||||
contextTitle: "",
|
||||
dumpId: "d6",
|
||||
},
|
||||
}),
|
||||
mentionChat: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "chat",
|
||||
contextId: "x",
|
||||
contextTitle: "",
|
||||
},
|
||||
}),
|
||||
mentionPlaylist: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "playlist",
|
||||
contextId: "p9",
|
||||
contextTitle: "Faves",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
describe("Notifications", () => {
|
||||
test("every kind reads right and links to its subject", async () => {
|
||||
open(Object.values(n));
|
||||
const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href");
|
||||
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");
|
||||
@@ -46,8 +135,14 @@ describe("Notifications", () => {
|
||||
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");
|
||||
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
|
||||
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
|
||||
const mentions = screen.getAllByText(/mentioned you in/).map((el) =>
|
||||
el.closest("a")!.getAttribute("href")
|
||||
);
|
||||
expect(mentions).toEqual([
|
||||
"/dumps/d6#comment-c3",
|
||||
"/notifications?chat=open",
|
||||
"/playlists/p9",
|
||||
]);
|
||||
expect(screen.getByText("a comment")).toBeInTheDocument();
|
||||
expect(screen.getByText("the chat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Faves")).toBeInTheDocument();
|
||||
@@ -59,28 +154,37 @@ describe("Notifications", () => {
|
||||
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
|
||||
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
|
||||
]);
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Today" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Earlier" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
|
||||
open([n.follow, { ...n.upvoted, read: true }]);
|
||||
await screen.findByText(/started following you/);
|
||||
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
|
||||
expect(screen.getByText(/started following you/).closest("li"))
|
||||
.toHaveClass("notification-item--unread");
|
||||
expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new");
|
||||
expect(document.querySelector(".notifications-unread-pill"))
|
||||
.toHaveTextContent("1 new");
|
||||
});
|
||||
|
||||
test("a list that loaded stays shown if marking it read fails", async () => {
|
||||
route("POST /api/notifications/read-all", () => 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(await screen.findByText(/started following you/))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/started following you/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -102,31 +206,45 @@ describe("Notifications", () => {
|
||||
test("a new one arrives at the top while the page is open", async () => {
|
||||
const { socket } = open([n.follow]);
|
||||
await screen.findByText(/started following you/);
|
||||
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.upvoted),
|
||||
});
|
||||
const items = document.querySelectorAll(".notification-item");
|
||||
expect(items[0]).toHaveTextContent("bob upvoted Pic");
|
||||
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));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("one that arrives while the list is loading isn't lost", async () => {
|
||||
let answer!: (r: Response) => void;
|
||||
route("GET /api/notifications", () => new Promise<Response>((r) => (answer = r)));
|
||||
const list = deferred<unknown>();
|
||||
route("GET /api/notifications", () => list.promise);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({ unreadNotificationCount: 1 });
|
||||
await waitFor(() => expect(calls("GET /api/notifications")).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/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.
|
||||
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.upvoted),
|
||||
});
|
||||
// And one the list does have, sent while it was on its way: shown once.
|
||||
socket.receive({ type: "notification_created", notification: wire(n.follow) });
|
||||
answer(page([n.follow]));
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.follow),
|
||||
});
|
||||
list.resolve(page([n.follow]));
|
||||
await screen.findByText(/started following you/);
|
||||
const items = document.querySelectorAll(".notification-item");
|
||||
expect(items).toHaveLength(2);
|
||||
@@ -135,15 +253,19 @@ describe("Notifications", () => {
|
||||
});
|
||||
|
||||
test("load more", async () => {
|
||||
route("GET /api/notifications", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([n.follow], true)
|
||||
: page([n.upvoted], false));
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([n.follow], true)
|
||||
: page([n.upvoted], false),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me });
|
||||
await user.click(await screen.findByRole("button", { name: "Load more" }));
|
||||
await screen.findByText(/^upvoted$/);
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route } from "react-router";
|
||||
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
|
||||
@@ -19,7 +25,11 @@ const dumps = () => [
|
||||
];
|
||||
|
||||
function open(
|
||||
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
|
||||
{
|
||||
session = owner as ReturnType<typeof makeSession> | null,
|
||||
playlist = {} as Partial<Playlist>,
|
||||
items = dumps() as Dump[],
|
||||
} = {},
|
||||
) {
|
||||
const pl = makePlaylist({
|
||||
id: "p1",
|
||||
@@ -44,30 +54,44 @@ function open(
|
||||
}
|
||||
|
||||
const titles = () =>
|
||||
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
|
||||
Array.from(document.querySelectorAll(".dump-card-title")).map((a) =>
|
||||
a.textContent
|
||||
);
|
||||
|
||||
describe("PlaylistDetail", () => {
|
||||
test("shows the playlist and its dumps in order", async () => {
|
||||
open({ session: null });
|
||||
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Mix" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("tunes").tagName).toBe("STRONG");
|
||||
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
|
||||
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
);
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
|
||||
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("not found", async () => {
|
||||
route("GET /api/playlists/:id", () => fail("nope", 404));
|
||||
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
|
||||
renderApp(<PlaylistDetail />, {
|
||||
route: "/playlists/x",
|
||||
path: "/playlists/:playlistId",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Playlist not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("a visitor can follow a public playlist, not edit it", async () => {
|
||||
open({ session: visitor });
|
||||
await screen.findByRole("heading", { name: "Mix" });
|
||||
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Follow playlist" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the cover is versioned on the last edit", async () => {
|
||||
@@ -80,8 +104,19 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("editing the description shows at once, without waiting for the socket", async () => {
|
||||
route("PUT /api/playlists/:id", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
description: "Better tunes",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const box = screen.getByPlaceholderText("Description (optional)");
|
||||
@@ -89,13 +124,26 @@ describe("PlaylistDetail", () => {
|
||||
await user.type(box, "Better tunes");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
||||
description: "Better tunes",
|
||||
isPublic: true,
|
||||
});
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
});
|
||||
|
||||
test("a new title moves to the new slug", async () => {
|
||||
route("PUT /api/playlists/:id", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "remix",
|
||||
title: "Remix",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const title = screen.getByPlaceholderText("Playlist title");
|
||||
@@ -107,14 +155,44 @@ describe("PlaylistDetail", () => {
|
||||
|
||||
test("a new cover shows once saved; a refused one says why", async () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
|
||||
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
|
||||
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
|
||||
route("POST /api/playlists/:id/image", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), {
|
||||
once: true,
|
||||
});
|
||||
route(
|
||||
"POST /api/playlists/:id/image",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
imageMime: "image/png",
|
||||
updatedAt: new Date(9000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
|
||||
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
|
||||
const picker = document.querySelector<HTMLInputElement>(
|
||||
'.img-picker input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
picker,
|
||||
new File([new Uint8Array(3)], "c.png", { type: "image/png" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -136,19 +214,36 @@ describe("PlaylistDetail", () => {
|
||||
test("removing a dump fades it out, with an undo", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
route("PUT /api/playlists/:pid/order", () => ({}));
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
|
||||
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
|
||||
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
".playlist-dump-item",
|
||||
) as HTMLElement;
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/mix/dumps/b",
|
||||
);
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Cancel removal" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["a", "b", "c"],
|
||||
});
|
||||
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
@@ -157,20 +252,31 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("a refused removal puts the dump back and says so", async () => {
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => fail("Forbidden", 403),
|
||||
);
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
|
||||
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
".playlist-dump-item",
|
||||
) as HTMLElement;
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"couldn't be removed",
|
||||
);
|
||||
expect(within(bravo).getByRole("button", { name: "Remove from playlist" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("dragging reorders, and a refused reorder reloads the real order", async () => {
|
||||
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
|
||||
open();
|
||||
await screen.findByText("Charlie");
|
||||
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
|
||||
const items = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
|
||||
for (const el of items()) {
|
||||
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
|
||||
}
|
||||
@@ -179,18 +285,33 @@ describe("PlaylistDetail", () => {
|
||||
fireEvent.dragOver(c, { clientY: 90 });
|
||||
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
|
||||
fireEvent.dragEnd(items()[2]);
|
||||
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
|
||||
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
|
||||
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
|
||||
await waitFor(() =>
|
||||
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["b", "c", "a"],
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/playlists/:id")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting the playlist asks, then goes home", async () => {
|
||||
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:id",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -199,22 +320,44 @@ describe("PlaylistDetail", () => {
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).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(currentPath()).toBe("/playlists/mix");
|
||||
});
|
||||
|
||||
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
|
||||
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "d", title: "Delta" })),
|
||||
);
|
||||
const { socket } = open({ session: visitor });
|
||||
await screen.findByText("Alpha");
|
||||
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"], dumpCount: 4 });
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "p1",
|
||||
dumpIds: ["d", "a", "b", "c"],
|
||||
dumpCount: 4,
|
||||
});
|
||||
expect(await screen.findByText("Delta")).toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
|
||||
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p1", title: "Renamed", userId: "owner" }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Renamed" }))
|
||||
.toBeInTheDocument();
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
|
||||
socket.receive({
|
||||
type: "playlist_deleted",
|
||||
playlistId: "p1",
|
||||
userId: "owner",
|
||||
});
|
||||
expect(await screen.findByText("home")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,39 @@ import { act, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Search } from "../../pages/Search.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
function results(
|
||||
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
|
||||
{
|
||||
dumps = [] as ReturnType<typeof makeDump>[],
|
||||
hasMore = false,
|
||||
users = [] as ReturnType<typeof makeUser>[],
|
||||
playlists = [] as ReturnType<typeof makePlaylist>[],
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
|
||||
dumps: {
|
||||
items: wire(dumps),
|
||||
total: dumps.length + (hasMore ? 1 : 0),
|
||||
hasMore,
|
||||
},
|
||||
users: wire(users),
|
||||
playlists: wire(playlists),
|
||||
};
|
||||
}
|
||||
|
||||
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
|
||||
function open(
|
||||
url: string,
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session });
|
||||
return user;
|
||||
@@ -44,7 +62,10 @@ describe("Search", () => {
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
await user.click(screen.getByRole("button", { name: "Users (1)" }));
|
||||
expect(currentPath()).toBe("/search/~/users?q=cat");
|
||||
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
|
||||
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/catlover",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
|
||||
expect(screen.getByText("Cats")).toBeInTheDocument();
|
||||
});
|
||||
@@ -52,7 +73,8 @@ describe("Search", () => {
|
||||
test("nothing found", async () => {
|
||||
route("GET /api/search", () => results());
|
||||
const user = open("/search?q=zzz");
|
||||
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No dumps match "zzz".'))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Users (0)" }));
|
||||
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
|
||||
});
|
||||
@@ -66,45 +88,73 @@ describe("Search", () => {
|
||||
});
|
||||
|
||||
test("more dumps on scroll; a failed page keeps what's shown", async () => {
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
|
||||
{ once: true },
|
||||
);
|
||||
route("GET /api/search", () => new TypeError("offline"), { once: true });
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "Second" })] }),
|
||||
);
|
||||
open("/search?q=x");
|
||||
await screen.findByText("First");
|
||||
const scroll = () => {
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) =>
|
||||
o.elements.size > 0
|
||||
)!;
|
||||
act(() => obs.report(true));
|
||||
};
|
||||
scroll();
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
// Scrolling away and back tries again at once.
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => obs.callback([{ isIntersecting: false } as IntersectionObserverEntry], obs as never));
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) =>
|
||||
o.elements.size > 0
|
||||
)!;
|
||||
act(() => obs.report(false));
|
||||
scroll();
|
||||
await screen.findByText("Second");
|
||||
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
|
||||
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe(
|
||||
"2",
|
||||
);
|
||||
});
|
||||
|
||||
test("a later page that keeps failing isn't asked for in a loop", async () => {
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
|
||||
{ once: true },
|
||||
);
|
||||
route("GET /api/search", () => fail("down", 500));
|
||||
// A short page: the sentinel is on screen and stays there.
|
||||
FakeIntersectionObserver.inView = true;
|
||||
open("/search?q=x");
|
||||
await screen.findByText("First");
|
||||
// In short steps: React holds what an act() schedules until it ends.
|
||||
for (let i = 0; i < 20; i++) await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
const pages = calls("GET /api/search").filter((c) => c.url.searchParams.get("page") !== "1");
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
}
|
||||
const pages = calls("GET /api/search").filter((c) =>
|
||||
c.url.searchParams.get("page") !== "1"
|
||||
);
|
||||
// Once at once, once more a second later; the next is 2s after that.
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a new query replaces the results", async () => {
|
||||
route("GET /api/search", (req) =>
|
||||
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
|
||||
route(
|
||||
"GET /api/search",
|
||||
(req) =>
|
||||
results({
|
||||
dumps: [
|
||||
makeDump({ title: `About ${req.url.searchParams.get("q")}` }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const user = open("/search?q=one");
|
||||
await screen.findByText("About one");
|
||||
const [box] = screen.getAllByRole("searchbox");
|
||||
|
||||
@@ -3,7 +3,13 @@ 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 {
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
page,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
@@ -12,12 +18,29 @@ 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" })]));
|
||||
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 });
|
||||
renderApp(<UserPlaylists />, {
|
||||
route: "/users/ann/playlists",
|
||||
path: "/users/:username/playlists",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
@@ -25,15 +48,27 @@ 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.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();
|
||||
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" })));
|
||||
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/ }));
|
||||
@@ -42,16 +77,26 @@ describe("UserPlaylists", () => {
|
||||
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(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 }));
|
||||
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 user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("No playlists yet.");
|
||||
});
|
||||
|
||||
@@ -60,7 +105,11 @@ describe("UserPlaylists", () => {
|
||||
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 user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||
expect(screen.getByText("Her mix")).toBeInTheDocument();
|
||||
});
|
||||
@@ -69,9 +118,24 @@ describe("UserPlaylists", () => {
|
||||
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" })) });
|
||||
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 })) });
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
title: "Her mix",
|
||||
userId: "ann",
|
||||
isPublic: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,17 @@ import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
|
||||
|
||||
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" });
|
||||
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" });
|
||||
const ann = makeUser({
|
||||
id: "ann",
|
||||
username: "ann",
|
||||
description: "I **dump** things",
|
||||
invitedByUsername: "root",
|
||||
});
|
||||
const annSession = makeSession({
|
||||
id: "ann",
|
||||
username: "ann",
|
||||
email: "ann@x.test",
|
||||
});
|
||||
|
||||
function open(
|
||||
{
|
||||
@@ -33,7 +42,10 @@ function open(
|
||||
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/playlists",
|
||||
() => page([makePlaylist({ title: "Her mix", userId: "ann" })]),
|
||||
);
|
||||
const u = userEvent.setup();
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: url,
|
||||
@@ -47,14 +59,20 @@ function open(
|
||||
describe("UserPublicProfile", () => {
|
||||
test("header, description, and the dumps tab", async () => {
|
||||
open();
|
||||
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root");
|
||||
expect(await screen.findByRole("heading", { name: "ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/root",
|
||||
);
|
||||
expect(screen.getByText("dump").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
expect(screen.getByText("She liked this")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps (1)" }))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() => expect(document.title).toMatch(/^ann · /));
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
@@ -62,8 +80,13 @@ describe("UserPublicProfile", () => {
|
||||
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/*" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("User not found");
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: "/users/nobody",
|
||||
path: "/users/:username/*",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"User not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("a role chip for moderators, and none for users", async () => {
|
||||
@@ -80,40 +103,61 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("followed: people and playlists, with more people on demand", async () => {
|
||||
route("GET /api/users/:u/followed-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" })]));
|
||||
route(
|
||||
"GET /api/users/:u/followed-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(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" }));
|
||||
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: /@cy/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("invitees as a tree", async () => {
|
||||
route("GET /api/users/:u/invitees", () => wire([
|
||||
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
|
||||
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
|
||||
]));
|
||||
route("GET /api/users/:u/invitees", () =>
|
||||
wire([
|
||||
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
|
||||
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
|
||||
]));
|
||||
open({ url: "/users/ann/~/invitees" });
|
||||
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!;
|
||||
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 () => {
|
||||
open({ session: makeSession({ id: "bob" }) });
|
||||
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "Follow ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("your own profile", () => {
|
||||
test("edit the description; clearing it really clears it", async () => {
|
||||
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement;
|
||||
const view = (await screen.findByText("dump")).closest(
|
||||
"[title='Edit description']",
|
||||
) as HTMLElement;
|
||||
view.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
const box = screen.getByPlaceholderText("Who am I?");
|
||||
@@ -124,35 +168,56 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("edit the email", async () => {
|
||||
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
const input = screen.getByDisplayValue("ann@x.test");
|
||||
await user.clear(input);
|
||||
await user.type(input, "new@x.test{Enter}");
|
||||
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test");
|
||||
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
||||
.toBe("new@x.test");
|
||||
});
|
||||
|
||||
test("a refused email says why", async () => {
|
||||
route("PATCH /api/users/me", () => fail("Email already in use", 409));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Email already in use",
|
||||
);
|
||||
});
|
||||
|
||||
test("upload an avatar", async () => {
|
||||
route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }));
|
||||
route(
|
||||
"POST /api/avatars/me",
|
||||
() =>
|
||||
wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
".avatar-change-overlay input",
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/ann?v=7000",
|
||||
)
|
||||
expect(document.querySelector(".profile-avatar-wrapper img"))
|
||||
.toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/ann?v=7000",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,21 +225,33 @@ describe("UserPublicProfile", () => {
|
||||
route("POST /api/avatars/me", () => fail("Not an image", 400));
|
||||
const { user } = open({ session: annSession });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
".avatar-change-overlay input",
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Not an image",
|
||||
);
|
||||
});
|
||||
|
||||
test("invite someone: a link to copy", async () => {
|
||||
route("POST /api/invites", () => ({ token: "tok123" }));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: "+ Invite someone" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "+ Invite someone" }),
|
||||
);
|
||||
const link = await screen.findByRole("textbox", { name: "Invite link" });
|
||||
expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
|
||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||
// user-event stands in for the clipboard.
|
||||
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`);
|
||||
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
|
||||
expect(await navigator.clipboard.readText()).toBe(
|
||||
`${location.origin}/register?token=tok123`,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copied!" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("your upvotes show before the live connection welcomes you", async () => {
|
||||
@@ -189,19 +266,34 @@ describe("UserPublicProfile", () => {
|
||||
const { socket } = open({ session: annSession });
|
||||
await screen.findByText("She liked this");
|
||||
socket.welcome({ myVotes: ["v1"] });
|
||||
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 });
|
||||
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading");
|
||||
socket.receive({
|
||||
type: "vote_ack",
|
||||
dumpId: "v1",
|
||||
action: "remove",
|
||||
voteCount: 0,
|
||||
});
|
||||
expect(screen.getByText("She liked this").closest(".dump-card"))
|
||||
.toHaveClass("dump-card--fading");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings: theme, colour scheme, default tab and sounds", async () => {
|
||||
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
|
||||
await user.click(await screen.findByRole("button", { name: "Brutalist" }));
|
||||
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist");
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
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");
|
||||
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");
|
||||
await user.click(screen.getByRole("button", { name: "Off" }));
|
||||
@@ -211,19 +303,45 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("change password keeps you signed in with the fresh tokens", async () => {
|
||||
route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" }));
|
||||
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
|
||||
await user.click(await screen.findByRole("button", { name: "Change password…" }));
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => ({ token: "fresh-token", 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.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("fresh-token");
|
||||
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(
|
||||
"fresh-token",
|
||||
);
|
||||
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
|
||||
currentPassword: "oldpassword",
|
||||
newPassword: "newpassword",
|
||||
@@ -231,14 +349,33 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
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…" }));
|
||||
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");
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
test("log out", async () => {
|
||||
@@ -250,25 +387,53 @@ describe("UserPublicProfile", () => {
|
||||
|
||||
describe("manage", () => {
|
||||
test("an admin can change someone's role", async () => {
|
||||
route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role }));
|
||||
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
|
||||
await user.click(await screen.findByRole("button", { name: "Moderator" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active"));
|
||||
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role");
|
||||
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator");
|
||||
route(
|
||||
"PATCH /api/users/:id/role",
|
||||
(req) => wire({ ...ann, role: (req.body as { role: string }).role }),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Moderator" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass(
|
||||
"active",
|
||||
)
|
||||
);
|
||||
expect(calls("PATCH /api/users/:id/role")[0].path).toBe(
|
||||
"/api/users/ann/role",
|
||||
);
|
||||
expect(document.querySelector(".profile-role-chip")).toHaveTextContent(
|
||||
"Moderator",
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused role change says why", async () => {
|
||||
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400));
|
||||
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
|
||||
route(
|
||||
"PATCH /api/users/:id/role",
|
||||
() => fail("Cannot demote the last admin", 400),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await user.click(await screen.findByRole("button", { name: "Admin" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Cannot demote the last admin",
|
||||
);
|
||||
});
|
||||
|
||||
test("non-admins get no manage tab", async () => {
|
||||
open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" });
|
||||
open({
|
||||
session: makeSession({ id: "mod", role: "moderator" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Manage" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -286,31 +451,66 @@ describe("UserPublicProfile", () => {
|
||||
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" }));
|
||||
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.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" });
|
||||
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());
|
||||
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" }));
|
||||
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" })).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,19 +13,30 @@ 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 });
|
||||
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" })]));
|
||||
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();
|
||||
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /New dump/ })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
@@ -33,7 +44,8 @@ describe("UserDumps", () => {
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
openDumps();
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← Back to profile" })).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 () => {
|
||||
@@ -43,43 +55,64 @@ describe("UserDumps", () => {
|
||||
const { user, socket } = openDumps(annSession);
|
||||
await screen.findByText("Mine");
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) });
|
||||
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();
|
||||
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" }));
|
||||
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 }) });
|
||||
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);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserUpvoted", () => {
|
||||
const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })];
|
||||
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 });
|
||||
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();
|
||||
expect(screen.getByRole("heading", { name: "Upvoted" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("your own list shows even before the live connection says what you voted", async () => {
|
||||
@@ -95,8 +128,16 @@ describe("UserUpvoted", () => {
|
||||
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");
|
||||
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 {
|
||||
@@ -105,13 +146,24 @@ describe("UserUpvoted", () => {
|
||||
});
|
||||
|
||||
test("their new upvote appears at the top", async () => {
|
||||
route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" })));
|
||||
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" });
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v3",
|
||||
voteCount: 1,
|
||||
voterId: "ann",
|
||||
action: "cast",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like")
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
|
||||
"Fresh like",
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,11 @@ const home = <Route path="/" element={<p>home page</p>} />;
|
||||
describe("UserLogin", () => {
|
||||
function setup() {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<UserLogin />, { route: "/login", path: "/login", extraRoutes: home });
|
||||
renderWithContexts(<UserLogin />, {
|
||||
route: "/login",
|
||||
path: "/login",
|
||||
extraRoutes: home,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -30,16 +34,27 @@ describe("UserLogin", () => {
|
||||
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({ username: "ann", password: "hunter22" });
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username).toBe("ann");
|
||||
expect(calls("POST /api/users/login")[0].body).toEqual({
|
||||
username: "ann",
|
||||
password: "hunter22",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username)
|
||||
.toBe("ann");
|
||||
});
|
||||
|
||||
test("both fields are required", async () => {
|
||||
const user = setup();
|
||||
// The header has a "Log in" button too; this is the form's.
|
||||
await user.click(document.querySelector<HTMLButtonElement>(".auth-card button[type=submit]")!);
|
||||
await user.click(
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
".auth-card button[type=submit]",
|
||||
)!,
|
||||
);
|
||||
expect(calls("POST /api/users/login")).toHaveLength(0);
|
||||
expect(screen.getByPlaceholderText("Username")).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("shows the server's refusal", async () => {
|
||||
@@ -58,7 +73,9 @@ describe("UserLogin", () => {
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
/Could not connect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the button says so while logging in", async () => {
|
||||
@@ -67,8 +84,13 @@ describe("UserLogin", () => {
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("Username"), "ann");
|
||||
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
|
||||
expect(await screen.findByRole("button", { name: "Logging in…" })).toBeDisabled();
|
||||
pending.resolve(new Response(JSON.stringify({ success: true, data: wire(makeSession()) })));
|
||||
expect(await screen.findByRole("button", { name: "Logging in…" }))
|
||||
.toBeDisabled();
|
||||
pending.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ success: true, data: wire(makeSession()) }),
|
||||
),
|
||||
);
|
||||
await screen.findByText("home page");
|
||||
});
|
||||
|
||||
@@ -76,18 +98,32 @@ describe("UserLogin", () => {
|
||||
route("POST /api/users/request-password-reset", () => ({}));
|
||||
const user = setup();
|
||||
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
|
||||
await user.type(screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Your email address"),
|
||||
" a@b.c {Enter}",
|
||||
);
|
||||
await screen.findByText(/you'll receive a reset link/);
|
||||
expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({ email: "a@b.c" });
|
||||
expect(calls("POST /api/users/request-password-reset")[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", () => fail("Internal error", 500));
|
||||
route(
|
||||
"POST /api/users/request-password-reset",
|
||||
() => fail("Internal error", 500),
|
||||
);
|
||||
const user = setup();
|
||||
await user.click(screen.getByRole("button", { name: "Forgot password?" }));
|
||||
await user.type(screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Request failed");
|
||||
expect(screen.queryByText(/you'll receive a reset link/)).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Your email address"),
|
||||
"a@b.c{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Request failed",
|
||||
);
|
||||
expect(screen.queryByText(/you'll receive a reset link/)).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +158,10 @@ describe("UserRegister", () => {
|
||||
const user = setup();
|
||||
await user.type(await screen.findByPlaceholderText("Username"), "new_one");
|
||||
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
|
||||
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Password (min. 8 characters)"),
|
||||
"longenough{Enter}",
|
||||
);
|
||||
await screen.findByText("home page");
|
||||
expect(calls("POST /api/users/register")[0].body).toEqual({
|
||||
username: "new_one",
|
||||
@@ -136,22 +175,37 @@ describe("UserRegister", () => {
|
||||
test("validates the username and password length", async () => {
|
||||
route("GET /api/invites/:t", () => ({}));
|
||||
const user = setup();
|
||||
await user.type(await screen.findByPlaceholderText("Username"), "bad name!");
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText("Username"),
|
||||
"bad name!",
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
|
||||
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "short{Enter}");
|
||||
expect(await screen.findByText(/letters, numbers, or underscores/)).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Password (min. 8 characters)"),
|
||||
"short{Enter}",
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
test("shows a server refusal (username taken)", async () => {
|
||||
route("GET /api/invites/:t", () => ({}));
|
||||
route("POST /api/users/register", () => fail("Username already taken", 409));
|
||||
route(
|
||||
"POST /api/users/register",
|
||||
() => fail("Username already taken", 409),
|
||||
);
|
||||
const user = setup();
|
||||
await user.type(await screen.findByPlaceholderText("Username"), "taken");
|
||||
await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe");
|
||||
await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Username already taken");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Password (min. 8 characters)"),
|
||||
"longenough{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Username already taken",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,15 +222,23 @@ describe("ResetPassword", () => {
|
||||
|
||||
test("no token: an invalid link", () => {
|
||||
setup("");
|
||||
expect(screen.getByRole("heading", { name: "Invalid link" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
|
||||
expect(screen.getByRole("heading", { name: "Invalid link" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute(
|
||||
"href",
|
||||
"/login",
|
||||
);
|
||||
});
|
||||
|
||||
test("the two passwords must match", async () => {
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("New password"), "longenough");
|
||||
await user.type(screen.getByPlaceholderText("Confirm new password"), "different");
|
||||
expect(await screen.findByText("Passwords do not match")).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Confirm new password"),
|
||||
"different",
|
||||
);
|
||||
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);
|
||||
});
|
||||
@@ -185,46 +247,75 @@ describe("ResetPassword", () => {
|
||||
route("POST /api/users/reset-password", () => ({}));
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("New password"), "longenough");
|
||||
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Confirm new password"),
|
||||
"longenough{Enter}",
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Password updated" });
|
||||
expect(calls("POST /api/users/reset-password")[0].body).toEqual({ token: "rst", newPassword: "longenough" });
|
||||
expect(calls("POST /api/users/reset-password")[0].body).toEqual({
|
||||
token: "rst",
|
||||
newPassword: "longenough",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Go to login" }));
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an expired token is reported", async () => {
|
||||
route("POST /api/users/reset-password", () => fail("Reset link expired", 400));
|
||||
route(
|
||||
"POST /api/users/reset-password",
|
||||
() => fail("Reset link expired", 400),
|
||||
);
|
||||
const user = setup();
|
||||
await user.type(screen.getByPlaceholderText("New password"), "longenough");
|
||||
await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Reset link expired");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Confirm new password"),
|
||||
"longenough{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Reset link expired",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route guards and 404", () => {
|
||||
test("guest-only pages send members home", () => {
|
||||
renderWithContexts(<RestrictedGuest><p>form</p></RestrictedGuest>, {
|
||||
session: makeSession(),
|
||||
route: "/login",
|
||||
path: "/login",
|
||||
extraRoutes: home,
|
||||
});
|
||||
renderWithContexts(
|
||||
<RestrictedGuest>
|
||||
<p>form</p>
|
||||
</RestrictedGuest>,
|
||||
{
|
||||
session: makeSession(),
|
||||
route: "/login",
|
||||
path: "/login",
|
||||
extraRoutes: home,
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("home page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("member-only pages send guests to login", () => {
|
||||
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
|
||||
route: "/notifications",
|
||||
path: "/notifications",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
});
|
||||
renderWithContexts(
|
||||
<RestrictedLoggedIn>
|
||||
<p>secret</p>
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: "/notifications",
|
||||
path: "/notifications",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("each guard lets the right people through", () => {
|
||||
renderWithContexts(<RestrictedLoggedIn><p>secret</p></RestrictedLoggedIn>, {
|
||||
session: makeSession(),
|
||||
});
|
||||
renderWithContexts(
|
||||
<RestrictedLoggedIn>
|
||||
<p>secret</p>
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
session: makeSession(),
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("secret")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -232,6 +323,9 @@ describe("route guards and 404", () => {
|
||||
renderWithContexts(<NotFound />, { route: "/nope" });
|
||||
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
|
||||
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute("href", "/");
|
||||
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
|
||||
"href",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user