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