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

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:
khannurien
2026-09-27 16:05:29 +00:00
parent c8bf0d583c
commit 364c498a24
116 changed files with 5266 additions and 2503 deletions

View File

@@ -24,11 +24,7 @@ describe("usePlayRichContent", () => {
const playQueue = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<ctxMod.PlayerContext.Provider
value={{
...(ctxMod.PlayerContext as unknown as { _currentValue: object })._currentValue,
play,
playQueue,
} as never}
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
>
{children}
</ctxMod.PlayerContext.Provider>
@@ -90,7 +86,13 @@ describe("usePlayRichContent", () => {
sourceUrl: bc.url,
itemType: "album",
resolvedAt: 1,
tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }],
tracks: [{
index: 0,
title: "T",
streamUrl: "https://s/0",
streamable: true,
capped: false,
}],
}));
const { hook, playQueue, canPlay } = await load("native");
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
@@ -108,7 +110,9 @@ describe("usePlayRichContent", () => {
const { hook, play, playQueue } = await load("native");
await act(() => hook.result.current.playRichContent(bc));
expect(playQueue).not.toHaveBeenCalled();
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" }));
expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "embed" }),
);
});
test("an unknown mode reads as embed", async () => {
@@ -119,7 +123,9 @@ describe("usePlayRichContent", () => {
describe("useUserDumpFeed", () => {
const ann = makeUser({ username: "ann" });
function setup(opts: { session?: ReturnType<typeof makeSession> | null } = {}) {
function setup(
opts: { session?: ReturnType<typeof makeSession> | null } = {},
) {
const session = opts.session ?? null;
function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(session);
@@ -147,7 +153,10 @@ describe("useUserDumpFeed", () => {
const session = makeSession();
const { result } = setup({ session });
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true);
@@ -167,35 +176,51 @@ describe("useUserDumpFeed", () => {
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
route("GET /api/users/:u/dumps", () => page([]));
const { result } = setup();
await waitFor(() => expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
}));
await waitFor(() =>
expect(result.current.state).toMatchObject({
status: "error",
error: expect.stringMatching(/Could not connect/),
})
);
});
test("scrolling to the sentinel loads the next page", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", (req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false));
route(
"GET /api/users/:u/dumps",
(req) =>
req.url.searchParams.get("page") === "1"
? page([makeDump({ id: "a" })], true)
: page([makeDump({ id: "b" })], false),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
const sentinel = document.createElement("div");
act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel));
act(() => {
result.current.sentinelRef(sentinel);
});
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
act(() => obs.report(true));
await waitFor(() => {
const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]);
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
"a",
"b",
]);
});
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.hasMore).toBe(false);
expect(s.page).toBe(2);
});
test("changing user resets to loading", async () => {
route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u })));
route(
"GET /api/users/:u",
(req) => wire(makeUser({ username: req.params.u })),
);
route("GET /api/users/:u/dumps", () => page([]));
const { result, rerender } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
@@ -206,17 +231,26 @@ describe("useUserDumpFeed", () => {
test("setItems edits the loaded list", async () => {
route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]));
route(
"GET /api/users/:u/dumps",
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
);
const { result } = setup();
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setItems((prev) => prev.slice(1)));
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
const s = result.current.state as Extract<
typeof result.current.state,
{ status: "loaded" }
>;
expect(s.items.map((d) => d.id)).toEqual(["b"]);
});
});
describe("useYouTubePlaylist", () => {
type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void };
type Events = {
onReady?: () => void;
onStateChange?: (e: { data: number }) => void;
};
function installYT(ids = ["a", "b", "c"]) {
const player = {
events: {} as Events,
@@ -231,23 +265,33 @@ describe("useYouTubePlaylist", () => {
},
destroy: vi.fn(),
};
(globalThis as unknown as { YT: unknown }).YT = {
Player: class {
constructor(_el: unknown, opts: { events: Events }) {
player.events = opts.events;
return player;
}
},
};
// Forwards to `player`, which the tests inspect and steer.
class Player {
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
player.events = opts.events;
}
playVideo = player.playVideo;
playVideoAt = player.playVideoAt;
nextVideo = player.nextVideo;
previousVideo = player.previousVideo;
getPlaylist = player.getPlaylist;
getPlaylistIndex = () => player.index;
destroy = player.destroy;
}
window.YT = { Player };
return player;
}
// The IFrame API loads once per document and is cached in the module.
async function setup(embedUrl: string | null) {
vi.resetModules();
const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts");
const { useYouTubePlaylist } = await import(
"../../hooks/useYouTubePlaylist.ts"
);
return renderHook(({ url }) => {
const ref = useRef<HTMLIFrameElement | null>(document.createElement("iframe"));
const ref = useRef<HTMLIFrameElement | null>(
document.createElement("iframe"),
);
return useYouTubePlaylist(url, ref);
}, { initialProps: { url: embedUrl } });
}
@@ -261,7 +305,9 @@ describe("useYouTubePlaylist", () => {
{ videoId: "c", title: "C", duration: 30 },
],
}));
const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1");
const { result } = await setup(
"https://www.youtube.com/embed/videoseries?list=PL1",
);
await waitFor(() => expect(result.current.rows).toHaveLength(2));
expect(result.current.ready).toBe(false);
await waitFor(() => expect(player.events.onReady).toBeDefined());
@@ -285,12 +331,17 @@ describe("useYouTubePlaylist", () => {
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
await setup("https://www.youtube.com/embed/x?list=PL2");
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
document.dispatchEvent(new Event("visibilitychange"));
for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 }));
for (let i = 0; i < 5; i++) {
act(() => player.events.onStateChange!({ data: 2 }));
}
expect(player.playVideo).toHaveBeenCalledTimes(3);
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
@@ -307,8 +358,13 @@ describe("useYouTubePlaylist", () => {
test("a new embed drops the old list; unmount destroys the player", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const player = installYT();
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3");
route(
"GET /api/youtube/playlist",
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
);
const { result, rerender, unmount } = await setup(
"https://www.youtube.com/embed/x?list=PL3",
);
await waitFor(() => expect(result.current.rows).toHaveLength(1));
await waitFor(() => expect(player.events.onReady).toBeDefined());
route("GET /api/youtube/playlist", () => fail("x", 500));

View File

@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
import {
type MentionKey,
useMentionAutocomplete,
} from "../../hooks/useMentionAutocomplete.ts";
import { calls, deferred, route } from "../fetch.ts";
function key(k: string, mods: Partial<KeyboardEvent> = {}) {
function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
return {
key: k,
ctrlKey: false,
@@ -15,7 +17,7 @@ function key(k: string, mods: Partial<KeyboardEvent> = {}) {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...mods,
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
};
}
describe("useEmojiTrigger", () => {
@@ -125,13 +127,20 @@ describe("useMentionAutocomplete", () => {
}
test("looks up the @query after a pause and opens with results", async () => {
route("GET /api/users/search", (req) =>
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!)));
route(
"GET /api/users/search",
(req) =>
users.filter((u) =>
u.username.startsWith(req.url.searchParams.get("q")!)
),
);
const { hook, type } = setup();
await type("hi @al");
expect(hook.result.current.mentionOpen).toBe(true);
expect(hook.result.current.mentionResults).toHaveLength(3);
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al");
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe(
"al",
);
});
test("debounces keystrokes into one lookup", async () => {
@@ -168,7 +177,9 @@ describe("useMentionAutocomplete", () => {
const { hook, type } = setup();
await type("@al");
await type("@alz");
await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users }))));
await act(async () =>
slow.resolve(new Response(JSON.stringify({ success: true, data: users })))
);
expect(hook.result.current.mentionOpen).toBe(false);
});
@@ -231,7 +242,9 @@ describe("useMentionAutocomplete", () => {
test("keys pass through while closed", () => {
const { hook } = setup();
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false);
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(
false,
);
});
test("moving the caret off the mention closes it", async () => {

View File

@@ -1,7 +1,19 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import {
act,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import { type ReactNode, useRef } from "react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { useFading } from "../../hooks/useFading.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useTabParam } from "../../hooks/useTabParam.ts";
@@ -112,7 +124,13 @@ describe("useFeedCache", () => {
test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem(
"k",
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
JSON.stringify({
items: [1],
page: 1,
hasMore: false,
scrollY: 0,
savedAt: Date.now(),
}),
);
function Probe() {
const { cached } = useFeedCache("k", (x) => x);
@@ -120,7 +138,11 @@ describe("useFeedCache", () => {
}
function Home() {
const navigate = useNavigate();
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
}
render(
<MemoryRouter>
@@ -156,7 +178,13 @@ describe("useTabParam", () => {
return useTabParam(TABS, "dumps");
}, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
<MemoryRouter
initialEntries={[{
pathname: path.split("?")[0],
search: path.includes("?") ? "?" + path.split("?")[1] : "",
state,
}]}
>
{children}
</MemoryRouter>
),
@@ -165,8 +193,12 @@ describe("useTabParam", () => {
}
test("reads the tab after /~/", () => {
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists");
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
"playlists",
);
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
});
@@ -197,7 +229,8 @@ describe("useTabParam", () => {
describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => {
const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
({ title }: { title: string | null | undefined }) =>
useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } },
);
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
@@ -226,12 +259,7 @@ describe("useInfiniteScroll", () => {
}
const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
act(() => obs.report(isIntersecting));
};
test("loads more when the sentinel shows, only while enabled", () => {
@@ -269,7 +297,16 @@ describe("useInfiniteScroll", () => {
}
delays.push(waited);
}
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
expect(delays).toEqual([
1000,
2000,
4000,
8000,
16000,
30000,
30000,
30000,
]);
});
test("re-rendering doesn't restart the wait", () => {
@@ -292,18 +329,13 @@ describe("useInfiniteScroll", () => {
render(<Feed onMore={onMore} enabled />);
const at = (top: number) => {
const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() =>
obs.callback(
[{ isIntersecting: true, boundingClientRect: { top } } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
act(() => obs.report(true, top));
};
at(500);
at(500);
expect(onMore).toHaveBeenCalledTimes(1); // waiting
intersect(false);
intersect(true);
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
at(500);
expect(onMore).toHaveBeenCalledTimes(2);
at(500);
at(900); // the page grew: that load worked
@@ -395,7 +427,7 @@ describe("useDumpCardLink", () => {
<button
type="button"
onClick={(e) => {
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}}
>
play
@@ -431,14 +463,27 @@ describe("useDumpCardLink", () => {
test("a middle click opens a new tab, except over a link", () => {
const { open } = setup();
const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
const mouseDown = new MouseEvent("mousedown", {
button: 1,
bubbles: true,
cancelable: true,
});
screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
fireEvent(
screen.getByText("inner link"),
new MouseEvent("auxclick", { button: 1, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }));
fireEvent(
screen.getByTestId("card"),
new MouseEvent("auxclick", { button: 2, bubbles: true }),
);
expect(open).toHaveBeenCalledTimes(1);
});
@@ -469,7 +514,14 @@ describe("useAnchoredFloat", () => {
ref.current = el;
if (el) {
el.getBoundingClientRect = () =>
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
({
top,
bottom: top + 20,
left: 900,
right: 1000,
width: 100,
height: 20,
}) as DOMRect;
}
}}
/>
@@ -522,9 +574,10 @@ describe("useHeightTransition", () => {
height = 250;
rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1);
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]];
expect(frames[0].height).toBe("100px");
expect(frames[1].height).toBe("250px");
expect(animate.mock.calls[0][0]).toEqual([
expect.objectContaining({ height: "100px" }),
expect.objectContaining({ height: "250px" }),
]);
// No change in height, no animation.
rerender(<Box open={false} />);

View File

@@ -1,24 +1,30 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts";
import {
defaultWSValue,
WSContext,
type WSContextValue,
} from "../../contexts/WSContext.ts";
import type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts";
import { defaultOf } from "../render.tsx";
/** A WS context whose value a test can push new events into. */
function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultOf(WSContext);
const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
}
return { Wrapper, emit: (patch: Partial<WSContextValue>) => act(() => push(patch)) };
return {
Wrapper,
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
};
}
function useList<T>(initial: T[]) {
@@ -29,7 +35,10 @@ function useList<T>(initial: T[]) {
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => {
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) {
function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
const ws = liveWS();
const hook = renderHook(() => {
const list = useList(initial);
@@ -40,7 +49,10 @@ describe("useDumpListSync", () => {
}
test("drops deleted dumps", () => {
const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]);
const { hook, emit } = setup([
makeDump({ id: "a" }),
makeDump({ id: "b" }),
]);
emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]);
});
@@ -116,15 +128,25 @@ describe("usePlaylistListSync", () => {
test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } });
emit({
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
});
emit({
lastPlaylistEvent: {
type: "created",
playlistId: "b",
playlist: { ...p },
},
});
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
byOwner.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]);
@@ -138,7 +160,9 @@ describe("usePlaylistListSync", () => {
expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true });
followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
followed.emit({
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
});
expect(followed.hook.result.current.items).toEqual([]);
});
@@ -146,15 +170,27 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "two" },
},
});
expect(hook.result.current.items[0].title).toBe("two");
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, isPublic: false },
},
});
expect(hook.result.current.items).toEqual([]);
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } },
lastPlaylistEvent: {
type: "updated",
playlistId: "p",
playlist: { ...p, title: "back" },
},
});
expect(hook.result.current.items[0].title).toBe("back");
});
@@ -163,7 +199,9 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts);
emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } });
emit({
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
});
expect(hook.result.current.items).toEqual([]);
}
});
@@ -182,7 +220,11 @@ describe("usePlaylistListSync", () => {
test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] },
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y", "z"],
},
});
expect(hook.result.current.items[0].dumpCount).toBe(3);
});
@@ -190,7 +232,12 @@ describe("usePlaylistListSync", () => {
test("dumps_updated counts the members this viewer can't see too", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit({
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y"], dumpCount: 4 },
lastPlaylistEvent: {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
},
});
expect(hook.result.current.items[0].dumpCount).toBe(4);
});
@@ -230,7 +277,13 @@ describe("usePositionAwareSync", () => {
const hook = renderHook(() => {
const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null);
usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic);
usePositionAwareSync(
items,
setItems,
event,
(p) => !p.isPublic,
(p) => p.isPublic,
);
return { items, setItems, setEvent };
});
const c = initial[2];
@@ -240,7 +293,9 @@ describe("usePositionAwareSync", () => {
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone.
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }));
act(() =>
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
);
expect(ids(hook.result.current.items)).toEqual(["a"]);
});
});

View File

@@ -20,7 +20,8 @@ function wrapperFor(initial: AuthResponse | null) {
};
}
const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
const stored = () =>
JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
describe("useAuth", () => {
test("exposes the session", () => {
@@ -98,7 +99,9 @@ describe("authFetch", () => {
method: "POST",
body: new FormData(),
});
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false);
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(
false,
);
});
test("no Authorization header when signed out", async () => {
@@ -135,7 +138,12 @@ describe("authFetch", () => {
describe("authUpload", () => {
class FakeXHR {
static last: FakeXHR;
static last: FakeXHR | undefined;
/** The request just made; a test that expects one fails without it. */
static get current(): FakeXHR {
if (!FakeXHR.last) throw new Error("No XMLHttpRequest was made");
return FakeXHR.last;
}
method = "";
url = "";
headers: Record<string, string> = {};
@@ -196,7 +204,7 @@ describe("authUpload", () => {
body,
(f) => progress.push(f),
);
const xhr = FakeXHR.last;
const xhr = FakeXHR.current;
expect(xhr.method).toBe("POST");
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
expect(xhr.body).toBe(body);
@@ -215,9 +223,14 @@ describe("authUpload", () => {
const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT");
expect(FakeXHR.last.method).toBe("PUT");
FakeXHR.last.error();
const promise = result.current.authUpload(
"u",
new FormData(),
() => {},
"PUT",
);
expect(FakeXHR.current.method).toBe("PUT");
FakeXHR.current.error();
await expect(promise).rejects.toBeInstanceOf(TypeError);
});
@@ -227,14 +240,14 @@ describe("authUpload", () => {
wrapper: wrapperFor(makeSession()),
});
const promise = result.current.authUpload("u", new FormData(), () => {});
act(() => FakeXHR.last.respond(401, ""));
act(() => FakeXHR.current.respond(401, ""));
await promise;
await waitFor(() => expect(result.current.user).toBeNull());
});
test("an expired token short-circuits", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR);
FakeXHR.last = undefined as unknown as FakeXHR;
FakeXHR.last = undefined;
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() =>
@@ -258,4 +271,3 @@ describe("useRequiredAuth", () => {
expect(result.current.user.username).toBe("z");
});
});