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

@@ -45,7 +45,11 @@ function parseIPv6(s: string): number[] | null {
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : []; const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
const fill = 8 - head.length - rest.length; const fill = 8 - head.length - rest.length;
if (halves.length === 1 ? fill !== 0 : fill < 1) return null; if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest]; const groups = [
...head,
...Array(halves.length === 2 ? fill : 0).fill("0"),
...rest,
];
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null; if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.flatMap((g) => { return groups.flatMap((g) => {
const n = parseInt(g, 16); const n = parseInt(g, 16);
@@ -62,7 +66,8 @@ function isPrivateIPv6(b: number[]): boolean {
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
return true; return true;
} }
if ( // 64:ff9b::/96 NAT64 if (
// 64:ff9b::/96 NAT64
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
b.slice(4, 12).every((x) => x === 0) b.slice(4, 12).every((x) => x === 0)
) return isPrivateIPv4(b.slice(12)); ) return isPrivateIPv4(b.slice(12));

View File

@@ -27,7 +27,9 @@ export function emojiToCodepoint(emoji: string): string {
let wasmReady: Promise<unknown> | undefined; let wasmReady: Promise<unknown> | undefined;
function ensureWasm(): Promise<unknown> { function ensureWasm(): Promise<unknown> {
if (!wasmReady) { if (!wasmReady) {
const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm")); const wasmUrl = new URL(
import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"),
);
wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes)); wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes));
} }
return wasmReady; return wasmReady;
@@ -62,7 +64,10 @@ async function fetchGlyph(emoji: string): Promise<string> {
} }
function renderPng(svg: string, size: number, background?: string): Uint8Array { function renderPng(svg: string, size: number, background?: string): Uint8Array {
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background }); const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: size },
background,
});
return resvg.render().asPng(); return resvg.render().asPng();
} }
@@ -152,10 +157,16 @@ export async function generateSiteIcons(
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!); await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
} }
// Apple touch icon gets a solid background — iOS doesn't honor transparency. // Apple touch icon gets a solid background — iOS doesn't honor transparency.
await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor)); await Deno.writeFile(
out("apple-touch-icon.png"),
renderPng(svg, APPLE_SIZE, bgColor),
);
await Deno.writeFile( await Deno.writeFile(
out("favicon.ico"), out("favicon.ico"),
buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))), buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))),
); );
await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor)); await Deno.writeTextFile(
out("manifest.webmanifest"),
buildManifest(siteName, bgColor),
);
} }

View File

@@ -7,6 +7,7 @@ import {
Dump, Dump,
isRole, isRole,
type Notification, type Notification,
type NotificationData,
type NotificationType, type NotificationType,
type Playlist, type Playlist,
type RichContent, type RichContent,
@@ -494,7 +495,7 @@ export function notificationRowToApi(row: NotificationRow): Notification {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
type: row.type as NotificationType, type: row.type as NotificationType,
data: JSON.parse(row.data), data: JSON.parse(row.data) as NotificationData,
read: Boolean(row.read), read: Boolean(row.read),
createdAt: new Date(row.created_at), createdAt: new Date(row.created_at),
}; };

View File

@@ -118,6 +118,38 @@ export interface User {
email: string; email: string;
} }
/** A user as anyone may see them: no password hash, no email. */
export type PublicUser = Omit<User, "passwordHash" | "email">;
/** A user as they see themselves: everything but the password hash. */
export type SelfUser = Omit<User, "passwordHash">;
export function toPublicUser(
{ passwordHash: _, email: _email, ...user }: User,
): PublicUser {
return user;
}
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
return user;
}
/** A match for the @mention autocomplete. */
export interface UserSuggestion {
id: string;
username: string;
avatarMime: string | null;
}
/** Someone a user invited, directly or down the line. */
export interface Invitee {
id: string;
username: string;
avatarMime?: string;
invitedById: string;
createdAt: Date;
}
export interface LoginUserRequest { export interface LoginUserRequest {
username: string; username: string;
password: string; password: string;
@@ -144,6 +176,13 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
"password" in obj && typeof obj.password === "string"; "password" in obj && typeof obj.password === "string";
} }
/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */
export function fieldOf(obj: unknown, key: string): unknown {
return obj !== null && typeof obj === "object" && key in obj
? (obj as Record<string, unknown>)[key]
: undefined;
}
export function isRegisterUserRequest( export function isRegisterUserRequest(
obj: unknown, obj: unknown,
): obj is RegisterUserRequest { ): obj is RegisterUserRequest {
@@ -210,9 +249,24 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
return true; return true;
} }
export interface AuthResponse { /** The session token, plus the media-only one for file and thumbnail URLs. */
export interface AuthTokens {
token: string; token: string;
user: User; mediaToken: string;
}
export interface AuthResponse extends AuthTokens {
user: SelfUser;
}
export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">;
export interface InviteResponse {
token: string;
}
export interface AttachmentResponse {
id: string;
} }
export interface AuthPayload { export interface AuthPayload {
@@ -288,6 +342,22 @@ export interface PaginatedData<T> {
hasMore: boolean; hasMore: boolean;
} }
export interface SearchResults {
dumps: PaginatedData<Dump>;
users: PublicUser[];
playlists: Playlist[];
}
/**
* `reached` is separate because a failed fetch still yields a usable stub
* (hostname only): without it the create form can't tell "this page has no
* preview" from "this link is dead".
*/
export interface LinkPreview {
reached: boolean;
richContent: RichContent | null;
}
export class APIException extends Error { export class APIException extends Error {
readonly code: APIErrorCode; readonly code: APIErrorCode;
readonly status: number; readonly status: number;
@@ -747,7 +817,7 @@ export interface PlaylistDumpsUpdatedMessage {
export interface UserUpdatedMessage { export interface UserUpdatedMessage {
type: "user_updated"; type: "user_updated";
user: Omit<User, "passwordHash" | "email">; user: PublicUser;
} }
export interface CommentCreatedMessage { export interface CommentCreatedMessage {
@@ -822,6 +892,7 @@ export interface ChatReadUpdateMessage {
export type ServerToClientMessage = export type ServerToClientMessage =
| PingMessage | PingMessage
| PongMessage
| WelcomeMessage | WelcomeMessage
| PresenceUpdateMessage | PresenceUpdateMessage
| VotesUpdateMessage | VotesUpdateMessage

View File

@@ -4,7 +4,12 @@ import {
createAttachment, createAttachment,
getAttachment, getAttachment,
} from "../services/attachment-service.ts"; } from "../services/attachment-service.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import {
APIErrorCode,
APIException,
type APIResponse,
type AttachmentResponse,
} from "../model/interfaces.ts";
import { import {
ATTACHMENTS_DIR, ATTACHMENTS_DIR,
serveUploadedFile, serveUploadedFile,
@@ -43,8 +48,12 @@ router.post("/api/attachments", authMiddleware, async (ctx) => {
throw err; throw err;
} }
const responseBody: APIResponse<AttachmentResponse> = {
success: true,
data: { id: attachmentId },
};
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { success: true, data: { id: attachmentId } }; ctx.response.body = responseBody;
}); });
router.get("/api/attachments/:attachmentId", async (ctx) => { router.get("/api/attachments/:attachmentId", async (ctx) => {

View File

@@ -5,7 +5,14 @@ import {
broadcastUserUpdated, broadcastUserUpdated,
updateClientAvatar, updateClientAvatar,
} from "../services/ws-service.ts"; } from "../services/ws-service.ts";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import {
APIErrorCode,
APIException,
type APIResponse,
type SelfUser,
toPublicUser,
toSelfUser,
} from "../model/interfaces.ts";
import { import {
AVATARS_DIR, AVATARS_DIR,
serveUploadedFile, serveUploadedFile,
@@ -47,14 +54,16 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
} }
updateClientAvatar(authPayload.userId, mime); updateClientAvatar(authPayload.userId, mime);
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId); const user = getUserById(authPayload.userId);
// Let every client refresh this user's avatar wherever it's shown (feeds, // Let every client refresh this user's avatar wherever it's shown (feeds,
// comments, OP, profiles) — not just the presence row. updated_at, bumped on // comments, OP, profiles) — not just the presence row. updated_at, bumped on
// the avatar write, doubles as the cache-busting version. // the avatar write, doubles as the cache-busting version.
const { email: _email, ...broadcastUser } = publicUser; broadcastUserUpdated(toPublicUser(user));
broadcastUserUpdated(broadcastUser); const responseBody: APIResponse<SelfUser> = {
ctx.response.status = 200; success: true,
ctx.response.body = { success: true, data: publicUser }; data: toSelfUser(user),
};
ctx.response.body = responseBody;
}); });
router.get("/api/avatars/:userId", async (ctx) => { router.get("/api/avatars/:userId", async (ctx) => {

View File

@@ -1,5 +1,7 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import type { APIResponse } from "../model/interfaces.ts";
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts"; import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
import type { Tralbum } from "../services/bandcamp-tralbum.ts";
const bandcampRouter = new Router(); const bandcampRouter = new Router();
@@ -15,12 +17,15 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1"; const force = ctx.request.url.searchParams.get("force") === "1";
const album = await resolveBandcamp(url, { force }); const responseBody: APIResponse<Tralbum> = {
success: true,
data: await resolveBandcamp(url, { force }),
};
// Private: the signed URLs in the body are per-fetch, so they must not be // Private: the signed URLs in the body are per-fetch, so they must not be
// held in a shared cache. Well inside the 24h signature life either way. // held in a shared cache. Well inside the 24h signature life either way.
ctx.response.headers.set("Cache-Control", "private, max-age=3600"); ctx.response.headers.set("Cache-Control", "private, max-age=3600");
ctx.response.body = { success: true, data: album }; ctx.response.body = responseBody;
}); });
export default bandcampRouter; export default bandcampRouter;

View File

@@ -63,7 +63,7 @@ router.post(
authMiddleware, authMiddleware,
requirePermission("category:manage"), requirePermission("category:manage"),
async (ctx) => { async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isCreateCategoryRequest(body)) { if (!isCreateCategoryRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -86,7 +86,7 @@ router.put(
authMiddleware, authMiddleware,
requirePermission("category:manage"), requirePermission("category:manage"),
async (ctx) => { async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isUpdateCategoryRequest(body)) { if (!isUpdateCategoryRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,

View File

@@ -6,6 +6,9 @@ import {
type Comment, type Comment,
isCreateCommentRequest, isCreateCommentRequest,
isUpdateCommentRequest, isUpdateCommentRequest,
type PaginatedData,
type PublicUser,
toPublicUser,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts"; import { can } from "../lib/permissions.ts";
@@ -44,7 +47,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId); const dump = getDump(ctx.params.dumpId, userId);
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isCreateCommentRequest(body)) { if (!isCreateCommentRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -68,7 +71,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
router.patch("/comments/:commentId", authMiddleware, async (ctx) => { router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
const canModerate = can(ctx.state.user, "comment:moderate"); const canModerate = can(ctx.state.user, "comment:moderate");
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isUpdateCommentRequest(body)) { if (!isUpdateCommentRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -98,14 +101,15 @@ router.get("/comments/:commentId/likers", async (ctx) => {
page, page,
limit, limit,
); );
ctx.response.body = { const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true, success: true,
data: { data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), items: items.map(toPublicUser),
total, total,
hasMore: page * limit < total, hasMore: page * limit < total,
}, },
}; };
ctx.response.body = responseBody;
}); });
// DELETE /api/comments/:commentId — auth required // DELETE /api/comments/:commentId — auth required

View File

@@ -10,6 +10,8 @@ import {
isUpdateDumpRequest, isUpdateDumpRequest,
type PaginatedData, type PaginatedData,
type Playlist, type Playlist,
type PublicUser,
toPublicUser,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
@@ -45,7 +47,7 @@ const router = new Router({ prefix: "/api/dumps" });
function parseCategoryIds(value: FormDataEntryValue | null): string[] { function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return []; if (typeof value !== "string" || !value) return [];
try { try {
const parsed = JSON.parse(value); const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string") ? parsed.filter((v): v is string => typeof v === "string")
: []; : [];
@@ -87,7 +89,7 @@ router.post(
parseCategoryIds(formData.get("categoryIds")), parseCategoryIds(formData.get("categoryIds")),
); );
} else { } else {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isCreateUrlDumpRequest(body)) { if (!isCreateUrlDumpRequest(body)) {
throw new APIException( throw new APIException(
@@ -129,14 +131,15 @@ router.get("/:dumpId/voters", async (ctx) => {
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getDumpVoters(dump.id, page, limit); const { items, total } = getDumpVoters(dump.id, page, limit);
ctx.response.body = { const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true, success: true,
data: { data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), items: items.map(toPublicUser),
total, total,
hasMore: page * limit < total, hasMore: page * limit < total,
}, },
}; };
ctx.response.body = responseBody;
}); });
router.get("/:dumpId/related", async (ctx) => { router.get("/:dumpId/related", async (ctx) => {
@@ -215,7 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
router.put("/:dumpId", authMiddleware, async (ctx) => { router.put("/:dumpId", authMiddleware, async (ctx) => {
const dumpId = ctx.params.dumpId; const dumpId = ctx.params.dumpId;
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isUpdateDumpRequest(body)) { if (!isUpdateDumpRequest(body)) {
throw new APIException( throw new APIException(

View File

@@ -1,5 +1,10 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { APIErrorCode, APIException } from "../model/interfaces.ts"; import {
APIErrorCode,
APIException,
type APIResponse,
type InviteResponse,
} from "../model/interfaces.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { createInvite, validateInvite } from "../services/invite-service.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts";
@@ -10,16 +15,20 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) { if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
} }
const token = createInvite(ctx.state.user.userId); const responseBody: APIResponse<InviteResponse> = {
success: true,
data: { token: createInvite(ctx.state.user.userId) },
};
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { success: true, data: { token } }; ctx.response.body = responseBody;
}); });
// Validate an invite token (used by the register page before showing the form) // Validate an invite token (used by the register page before showing the form)
router.get("/:token", (ctx) => { router.get("/:token", (ctx) => {
try { try {
validateInvite(ctx.params.token); validateInvite(ctx.params.token);
ctx.response.body = { success: true, data: null }; const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
} catch { } catch {
throw new APIException( throw new APIException(
APIErrorCode.NOT_FOUND, APIErrorCode.NOT_FOUND,

View File

@@ -2,6 +2,8 @@ import { Router } from "@oak/oak";
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
type APIResponse,
type Notification,
type PaginatedData, type PaginatedData,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { parsePagination } from "../lib/pagination.ts"; import { parsePagination } from "../lib/pagination.ts";
@@ -25,14 +27,11 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
page, page,
limit, limit,
); );
ctx.response.body = { const responseBody: APIResponse<PaginatedData<Notification>> = {
success: true, success: true,
data: { data: { items, total, hasMore: page * limit < total },
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
}; };
ctx.response.body = responseBody;
}); });
// POST /api/notifications/read-all // POST /api/notifications/read-all

View File

@@ -3,9 +3,13 @@ import { parseOptionalAuth } from "../lib/auth.ts";
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
type APIResponse,
isCreatePlaylistRequest, isCreatePlaylistRequest,
isReorderPlaylistRequest, isReorderPlaylistRequest,
isUpdatePlaylistRequest, isUpdatePlaylistRequest,
type Playlist,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts"; import { can } from "../lib/permissions.ts";
@@ -34,13 +38,16 @@ const router = new Router<AuthState>({ prefix: "/api/playlists" });
router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
const { dumpId } = ctx.params; const { dumpId } = ctx.params;
const userId = ctx.state.user.userId; const userId = ctx.state.user.userId;
const memberships = getPlaylistMembershipsForDump(dumpId, userId); const responseBody: APIResponse<PlaylistMembership[]> = {
ctx.response.body = { success: true, data: memberships }; success: true,
data: getPlaylistMembershipsForDump(dumpId, userId),
};
ctx.response.body = responseBody;
}); });
// POST /api/playlists — create // POST /api/playlists — create
router.post("/", authMiddleware, async (ctx) => { router.post("/", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isCreatePlaylistRequest(body)) { if (!isCreatePlaylistRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -48,21 +55,27 @@ router.post("/", authMiddleware, async (ctx) => {
"Invalid request", "Invalid request",
); );
} }
const playlist = createPlaylist(body, ctx.state.user.userId); const responseBody: APIResponse<Playlist> = {
success: true,
data: createPlaylist(body, ctx.state.user.userId),
};
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = { success: true, data: playlist }; ctx.response.body = responseBody;
}); });
// GET /api/playlists/:playlistId — optional auth // GET /api/playlists/:playlistId — optional auth
router.get("/:playlistId", async (ctx) => { router.get("/:playlistId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx); const requestingUserId = await parseOptionalAuth(ctx);
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); const responseBody: APIResponse<PlaylistWithDumps> = {
ctx.response.body = { success: true, data: playlist }; success: true,
data: getPlaylist(ctx.params.playlistId, requestingUserId),
};
ctx.response.body = responseBody;
}); });
// PUT /api/playlists/:playlistId — update metadata // PUT /api/playlists/:playlistId — update metadata
router.put("/:playlistId", authMiddleware, async (ctx) => { router.put("/:playlistId", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isUpdatePlaylistRequest(body)) { if (!isUpdatePlaylistRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -76,7 +89,8 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"), can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.body = { success: true, data: playlist }; const responseBody: APIResponse<Playlist> = { success: true, data: playlist };
ctx.response.body = responseBody;
}); });
// DELETE /api/playlists/:playlistId // DELETE /api/playlists/:playlistId
@@ -158,7 +172,8 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"), can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.body = { success: true, data: playlist }; const responseBody: APIResponse<Playlist> = { success: true, data: playlist };
ctx.response.body = responseBody;
}); });
// GET /api/playlists/:playlistId/image — serve playlist image // GET /api/playlists/:playlistId/image — serve playlist image
@@ -177,7 +192,7 @@ router.get("/:playlistId/image", async (ctx) => {
// PUT /api/playlists/:playlistId/order — reorder // PUT /api/playlists/:playlistId/order — reorder
router.put("/:playlistId/order", authMiddleware, async (ctx) => { router.put("/:playlistId/order", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isReorderPlaylistRequest(body)) { if (!isReorderPlaylistRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -191,7 +206,8 @@ router.put("/:playlistId/order", authMiddleware, async (ctx) => {
ctx.state.user.userId, ctx.state.user.userId,
can(ctx.state.user, "playlist:moderate"), can(ctx.state.user, "playlist:moderate"),
); );
ctx.response.body = { success: true, data: null }; const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
}); });
export default router; export default router;

View File

@@ -4,7 +4,12 @@ import {
isValidHttpUrl, isValidHttpUrl,
tryFetchRichContent, tryFetchRichContent,
} from "../services/rich-content-service.ts"; } from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts"; import {
APIErrorCode,
type APIFailure,
type APIResponse,
type LinkPreview,
} from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
const previewRouter = new Router(); const previewRouter = new Router();
@@ -25,11 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([
previewRouter.get("/api/preview", authMiddleware, async (ctx) => { previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
ctx.response.status = 400; const responseBody: APIFailure = {
ctx.response.body = {
success: false, success: false,
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" }, error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
}; };
ctx.response.status = 400;
ctx.response.body = responseBody;
return; return;
} }
// `reached` is reported separately because a failed fetch still yields a // `reached` is reported separately because a failed fetch still yields a
@@ -37,10 +43,11 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
// "this page has no preview" from "this link is dead", and shows the same // "this page has no preview" from "this link is dead", and shows the same
// bare card for both. // bare card for both.
const { ok, content } = await tryFetchRichContent(url); const { ok, content } = await tryFetchRichContent(url);
ctx.response.body = { const responseBody: APIResponse<LinkPreview> = {
success: true, success: true,
data: { reached: ok, richContent: content ?? null }, data: { reached: ok, richContent: content ?? null },
}; };
ctx.response.body = responseBody;
}); });
/** /**

View File

@@ -4,6 +4,7 @@ import { parsePagination } from "../lib/pagination.ts";
import { searchDumps } from "../services/dump-service.ts"; import { searchDumps } from "../services/dump-service.ts";
import { searchUsersGlobal } from "../services/user-service.ts"; import { searchUsersGlobal } from "../services/user-service.ts";
import { searchPlaylists } from "../services/playlist-service.ts"; import { searchPlaylists } from "../services/playlist-service.ts";
import type { APIResponse, SearchResults } from "../model/interfaces.ts";
const router = new Router({ prefix: "/api/search" }); const router = new Router({ prefix: "/api/search" });
@@ -14,7 +15,7 @@ router.get("/", async (ctx) => {
const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined; const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined;
if (!q) { if (!q) {
ctx.response.body = { const responseBody: APIResponse<SearchResults> = {
success: true, success: true,
data: { data: {
dumps: { items: [], total: 0, hasMore: false }, dumps: { items: [], total: 0, hasMore: false },
@@ -22,6 +23,7 @@ router.get("/", async (ctx) => {
playlists: [], playlists: [],
}, },
}; };
ctx.response.body = responseBody;
return; return;
} }
@@ -34,7 +36,7 @@ router.get("/", async (ctx) => {
const users = searchUsersGlobal(q, 20); const users = searchUsersGlobal(q, 20);
const playlists = searchPlaylists(q, 20, requestingUserId); const playlists = searchPlaylists(q, 20, requestingUserId);
ctx.response.body = { const responseBody: APIResponse<SearchResults> = {
success: true, success: true,
data: { data: {
dumps: { dumps: {
@@ -46,6 +48,7 @@ router.get("/", async (ctx) => {
playlists, playlists,
}, },
}; };
ctx.response.body = responseBody;
}); });
export default router; export default router;

View File

@@ -3,11 +3,25 @@ import { Router } from "@oak/oak";
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
type APIResponse,
type AuthResponse,
type AuthTokens,
type Dump,
fieldOf,
type Invitee,
isLoginUserRequest, isLoginUserRequest,
isRegisterUserRequest,
isRole, isRole,
isUpdateUserRequest, isUpdateUserRequest,
type MediaTokenResponse,
type PaginatedData, type PaginatedData,
type Playlist,
type PublicUser,
type SelfUser,
toPublicUser,
toSelfUser,
type User, type User,
type UserSuggestion,
validateRegisterUserRequest, validateRegisterUserRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
@@ -60,7 +74,7 @@ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
// The session token, plus the media-only token that goes in file and // The session token, plus the media-only token that goes in file and
// thumbnail URLs (see createMediaToken). // thumbnail URLs (see createMediaToken).
async function issueTokens(user: User) { async function issueTokens(user: User): Promise<AuthTokens> {
return { return {
token: await createJWT({ token: await createJWT({
userId: user.id, userId: user.id,
@@ -73,11 +87,14 @@ async function issueTokens(user: User) {
// Register a new user (requires a valid invite token) // Register a new user (requires a valid invite token)
router.post("/register", async (ctx) => { router.post("/register", async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const registerError = validateRegisterUserRequest(body); if (!isRegisterUserRequest(body)) {
if (registerError) { throw new APIException(
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError); APIErrorCode.VALIDATION_ERROR,
400,
validateRegisterUserRequest(body) ?? "Invalid request",
);
} }
// Claim the invite up front (throws 404/409 if bad) and hand it back if the // Claim the invite up front (throws 404/409 if bad) and hand it back if the
@@ -106,17 +123,17 @@ router.post("/register", async (ctx) => {
}).catch((err) => console.error("[register] welcome email failed:", err)); }).catch((err) => console.error("[register] welcome email failed:", err));
} }
const { passwordHash: _, ...publicUser } = user; const responseBody: APIResponse<AuthResponse> = {
ctx.response.status = 201;
ctx.response.body = {
success: true, success: true,
data: { ...await issueTokens(user), user: publicUser }, data: { ...await issueTokens(user), user: toSelfUser(user) },
}; };
ctx.response.status = 201;
ctx.response.body = responseBody;
}); });
// Login // Login
router.post("/login", async (ctx) => { router.post("/login", async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isLoginUserRequest(body)) { if (!isLoginUserRequest(body)) {
throw new APIException( throw new APIException(
@@ -157,11 +174,11 @@ router.post("/login", async (ctx) => {
); );
} }
const { passwordHash: _, ...publicUser } = user; const responseBody: APIResponse<AuthResponse> = {
ctx.response.body = {
success: true, success: true,
data: { ...await issueTokens(user), user: publicUser }, data: { ...await issueTokens(user), user: toSelfUser(user) },
}; };
ctx.response.body = responseBody;
}); });
// Get current user profile // Get current user profile
@@ -175,14 +192,11 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
); );
} }
const { passwordHash: _, ...publicUser } = getUserById( const responseBody: APIResponse<SelfUser> = {
ctx.state.user.userId,
);
ctx.response.body = {
success: true, success: true,
data: publicUser, data: toSelfUser(getUserById(ctx.state.user.userId)),
}; };
ctx.response.body = responseBody;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -196,28 +210,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
// A media token for a session that predates them (see createMediaToken). // A media token for a session that predates them (see createMediaToken).
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = { const responseBody: APIResponse<MediaTokenResponse> = {
success: true, success: true,
data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
}; };
ctx.response.body = responseBody;
}); });
// Request a password reset email (unauthenticated; always returns 200) // Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => { router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const email = typeof body?.email === "string" ? body.email.trim() : ""; const email = fieldOf(body, "email");
if (email) { const trimmed = typeof email === "string" ? email.trim() : "";
await requestPasswordReset(email).catch((err) => if (trimmed) {
await requestPasswordReset(trimmed).catch((err) =>
console.error("[request-password-reset]", err) console.error("[request-password-reset]", err)
); );
} }
ctx.response.body = { success: true }; const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
}); });
// Consume a reset token and set a new password (unauthenticated) // Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => { router.post("/reset-password", async (ctx) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const { token, newPassword } = (body ?? {}) as Record<string, unknown>; const token = fieldOf(body, "token");
const newPassword = fieldOf(body, "newPassword");
if (typeof token !== "string" || typeof newPassword !== "string") { if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -226,16 +244,15 @@ router.post("/reset-password", async (ctx) => {
); );
} }
await resetPassword(token, newPassword); await resetPassword(token, newPassword);
ctx.response.body = { success: true }; const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
}); });
// Change current user's password (requires current password verification) // Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const { currentPassword, newPassword } = (body ?? {}) as Record< const currentPassword = fieldOf(body, "currentPassword");
string, const newPassword = fieldOf(body, "newPassword");
unknown
>;
if (typeof currentPassword !== "string" || typeof newPassword !== "string") { if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
throw new APIException( throw new APIException(
@@ -271,12 +288,16 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const updated = await updateUser(ctx.state.user.userId, { const updated = await updateUser(ctx.state.user.userId, {
password: newPassword, password: newPassword,
}); });
ctx.response.body = { success: true, data: await issueTokens(updated) }; const responseBody: APIResponse<AuthTokens> = {
success: true,
data: await issueTokens(updated),
};
ctx.response.body = responseBody;
}); });
// Update current user profile (description, etc.) // Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => { router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
if (!isUpdateUserRequest(body)) { if (!isUpdateUserRequest(body)) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,
@@ -292,9 +313,13 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
description: body.description, description: body.description,
email: body.email, email: body.email,
}); });
const { passwordHash: _, email: _email, ...publicUser } = updated; const publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser); broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser }; const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
}); });
// Set another user's role. Requires the user:manage permission. // Set another user's role. Requires the user:manage permission.
@@ -303,10 +328,14 @@ router.patch(
authMiddleware, authMiddleware,
requirePermission("user:manage"), requirePermission("user:manage"),
async (ctx: AuthContext<"/:userId/role">) => { async (ctx: AuthContext<"/:userId/role">) => {
const body = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const role: unknown = body?.role; const role = fieldOf(body, "role");
if (!isRole(role)) { if (!isRole(role)) {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role"); throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid role",
);
} }
// Disallow changing your own role: prevents an admin from accidentally // Disallow changing your own role: prevents an admin from accidentally
// locking themselves out, and self-promotion is meaningless anyway. // locking themselves out, and self-promotion is meaningless anyway.
@@ -318,24 +347,33 @@ router.patch(
); );
} }
const updated = await updateUser(ctx.params.userId, { role }); const updated = await updateUser(ctx.params.userId, { role });
const { passwordHash: _, email: _email, ...publicUser } = updated; const publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser); broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser }; const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
}, },
); );
// User search for @mention autocomplete // User search for @mention autocomplete
router.get("/search", (ctx) => { router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const results = searchUsers(q, 8); const responseBody: APIResponse<UserSuggestion[]> = {
ctx.response.body = { success: true, data: results }; success: true,
data: searchUsers(q, 8),
};
ctx.response.body = responseBody;
}); });
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId) // Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => { router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId); const responseBody: APIResponse<PublicUser> = {
const { passwordHash: _, email: _email, ...publicUser } = user; success: true,
ctx.response.body = { success: true, data: publicUser }; data: toPublicUser(getUserById(ctx.params.userId)),
};
ctx.response.body = responseBody;
}); });
// Followed users for a user (public) // Followed users for a user (public)
@@ -343,14 +381,15 @@ router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersByUser(user.id, page, limit); const { items, total } = getFollowedUsersByUser(user.id, page, limit);
ctx.response.body = { const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true, success: true,
data: { data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), items: items.map(toPublicUser),
total, total,
hasMore: page * limit < total, hasMore: page * limit < total,
}, },
}; };
ctx.response.body = responseBody;
}); });
// Followed playlists for a user (public only) // Followed playlists for a user (public only)
@@ -358,14 +397,11 @@ router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
ctx.response.body = { const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true, success: true,
data: { data: { items, total, hasMore: page * limit < total },
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
}; };
ctx.response.body = responseBody;
}); });
// Playlists by user (optional auth: include private only if requester === owner) // Playlists by user (optional auth: include private only if requester === owner)
@@ -379,28 +415,30 @@ router.get("/:username/playlists", async (ctx) => {
page, page,
limit, limit,
); );
ctx.response.body = { const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true, success: true,
data: { data: { items, total, hasMore: page * limit < total },
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
}; };
ctx.response.body = responseBody;
}); });
// Invite tree for a user (public) // Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => { router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
const tree = getInviteTree(user.id); const responseBody: APIResponse<Invitee[]> = {
ctx.response.body = { success: true, data: tree }; success: true,
data: getInviteTree(user.id),
};
ctx.response.body = responseBody;
}); });
// Public user profile by username (no passwordHash) // Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => { router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username); const responseBody: APIResponse<PublicUser> = {
const { passwordHash: _, email: _email, ...publicUser } = user; success: true,
ctx.response.body = { success: true, data: publicUser }; data: toPublicUser(getUserByUsername(ctx.params.username)),
};
ctx.response.body = responseBody;
}); });
// Dumps posted by user (optional auth: owner sees their private dumps) // Dumps posted by user (optional auth: owner sees their private dumps)
@@ -410,14 +448,11 @@ router.get("/:username/dumps", async (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const includePrivate = requestingUserId === user.id; const includePrivate = requestingUserId === user.id;
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
ctx.response.body = { const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true, success: true,
data: { data: { items, total, hasMore: page * limit < total },
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
}; };
ctx.response.body = responseBody;
}); });
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners) // Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
@@ -431,14 +466,11 @@ router.get("/:username/votes", async (ctx) => {
limit, limit,
requestingUserId, requestingUserId,
); );
ctx.response.body = { const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true, success: true,
data: { data: { items, total, hasMore: page * limit < total },
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
}; };
ctx.response.body = responseBody;
}); });
export default router; export default router;

View File

@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
import { import {
APIException, APIException,
type ClientToServerMessage, type ClientToServerMessage,
type ServerToClientMessage,
type User, type User,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
@@ -193,7 +194,7 @@ function handleMessage(client: WsClient, data: unknown): void {
} }
// Sends only while the socket is open: `send` on a closing socket throws. // Sends only while the socket is open: `send` on a closing socket throws.
function send(socket: WebSocket, data: Record<string, unknown>): void { function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data)); if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
} }
@@ -219,7 +220,6 @@ function handleVote(
type: "vote_ack", type: "vote_ack",
dumpId, dumpId,
action, action,
success: true,
voteCount, voteCount,
}); });

View File

@@ -1,5 +1,9 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts"; import type { APIResponse } from "../model/interfaces.ts";
import {
resolveYouTubePlaylist,
type YouTubePlaylist,
} from "../services/youtube-playlist-service.ts";
const youtubeRouter = new Router(); const youtubeRouter = new Router();
@@ -11,10 +15,13 @@ const youtubeRouter = new Router();
*/ */
youtubeRouter.get("/api/youtube/playlist", async (ctx) => { youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? ""; const listId = ctx.request.url.searchParams.get("list") ?? "";
const playlist = await resolveYouTubePlaylist(listId); const responseBody: APIResponse<YouTubePlaylist> = {
success: true,
data: await resolveYouTubePlaylist(listId),
};
ctx.response.headers.set("Cache-Control", "public, max-age=3600"); ctx.response.headers.set("Cache-Control", "public, max-age=3600");
ctx.response.body = { success: true, data: playlist }; ctx.response.body = responseBody;
}); });
export default youtubeRouter; export default youtubeRouter;

View File

@@ -147,7 +147,9 @@ export function updateCategory(
if (req.slug !== undefined && req.slug !== existing.slug) { if (req.slug !== undefined && req.slug !== existing.slug) {
validateSlug(req.slug); validateSlug(req.slug);
} }
const newName = req.name !== undefined ? requireName(req.name) : existing.name; const newName = req.name !== undefined
? requireName(req.name)
: existing.name;
const newPosition = req.position ?? existing.position; const newPosition = req.position ?? existing.position;
const now = new Date(); const now = new Date();

View File

@@ -115,7 +115,9 @@ export async function resetPassword(
try { try {
await updateUser(payload.userId, { password: newPassword }); await updateUser(payload.userId, { password: newPassword });
} catch (err) { } catch (err) {
db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`) db.prepare(
`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`,
)
.run(token); .run(token);
throw err; throw err;
} }

View File

@@ -1,9 +1,13 @@
import { import {
APIErrorCode, APIErrorCode,
APIException, APIException,
type Invitee,
type PublicUser,
type RegisterUserRequest, type RegisterUserRequest,
toPublicUser,
type UpdateUserRequest, type UpdateUserRequest,
type User, type User,
type UserSuggestion,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
import { closeUserSockets, disconnectUser } from "./ws-service.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts";
@@ -129,7 +133,7 @@ export function getUserByEmail(email: string): User | null {
export function searchUsers( export function searchUsers(
query: string, query: string,
limit: number, limit: number,
): { id: string; username: string; avatarMime: string | null }[] { ): UserSuggestion[] {
if (!query) return []; if (!query) return [];
const rows = db.prepare( const rows = db.prepare(
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`, `SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`,
@@ -145,13 +149,14 @@ export function searchUsers(
})); }));
} }
export function searchUsersGlobal(query: string, limit: number): User[] { // For the public search page: profiles only, never hashes or emails.
export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
if (!query.trim()) return []; if (!query.trim()) return [];
const pattern = `%${query}%`; const pattern = `%${query}%`;
const rows = db.prepare( const rows = db.prepare(
`${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`, `${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`,
).all(pattern, pattern, limit); ).all(pattern, pattern, limit);
return rows.filter(isUserRow).map(userRowToApi); return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
} }
export function listUsers(): User[] { export function listUsers(): User[] {
@@ -231,15 +236,7 @@ export function updateUserAvatar(userId: string, mime: string): void {
} }
} }
export function getInviteTree( export function getInviteTree(userId: string): Invitee[] {
userId: string,
): {
id: string;
username: string;
avatarMime?: string;
invitedById: string;
createdAt: Date;
}[] {
const rows = db.prepare(` const rows = db.prepare(`
WITH RECURSIVE tree AS ( WITH RECURSIVE tree AS (
SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth

View File

@@ -4,9 +4,9 @@ import type {
Dump, Dump,
OnlineUser, OnlineUser,
Playlist, Playlist,
PublicUser,
Role, Role,
ServerToClientMessage, ServerToClientMessage,
User,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { import {
isMemberVisibleTo, isMemberVisibleTo,
@@ -223,9 +223,7 @@ export function broadcastPlaylistDumpsUpdated(
} }
} }
export function broadcastUserUpdated( export function broadcastUserUpdated(user: PublicUser): void {
user: Omit<User, "passwordHash" | "email">,
): void {
for (const client of clients) { for (const client of clients) {
send(client.socket, { type: "user_updated", user }); send(client.socket, { type: "user_updated", user });
} }

View File

@@ -72,29 +72,43 @@ function firstString(node: unknown, key: string): string | undefined {
return found.find((v): v is string => typeof v === "string"); return found.find((v): v is string => typeof v === "string");
} }
// deno-lint-ignore no-explicit-any /** Follows `path` into parsed JSON; undefined wherever a step is missing. */
type Json = any; function at(node: unknown, ...path: (string | number)[]): unknown {
let cur = node;
for (const key of path) {
if (cur === null || typeof cur !== "object") return undefined;
cur = (cur as Record<string | number, unknown>)[key];
}
return cur;
}
function fromLockup(lockup: Json): YouTubePlaylistTrack | null { function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
const videoId = lockup.contentId; const videoId = at(lockup, "contentId");
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content; const title = at(
lockup,
"metadata",
"lockupMetadataViewModel",
"title",
"content",
);
if (typeof videoId !== "string" || typeof title !== "string") return null; if (typeof videoId !== "string" || typeof title !== "string") return null;
const badges: unknown[] = []; const badges: unknown[] = [];
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges); collect(at(lockup, "contentImage"), "thumbnailBadgeViewModel", badges);
const duration = badges const duration = badges
.map((b) => (b as Json)?.text) .map((b) => at(b, "text"))
.filter((t): t is string => typeof t === "string") .filter((t): t is string => typeof t === "string")
.map(parseClock) .map(parseClock)
.find((d) => d !== undefined); .find((d) => d !== undefined);
return { videoId, title, duration }; return { videoId, title, duration };
} }
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null { function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
const videoId = r?.videoId; const videoId = at(r, "videoId");
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText; const title = at(r, "title", "runs", 0, "text") ??
at(r, "title", "simpleText");
if (typeof videoId !== "string" || typeof title !== "string") return null; if (typeof videoId !== "string" || typeof title !== "string") return null;
const secs = Number(r.lengthSeconds); const secs = Number(at(r, "lengthSeconds"));
return { return {
videoId, videoId,
title, title,
@@ -111,7 +125,7 @@ export function parsePlaylistPage(
const end = html.indexOf(";</script>", start); const end = html.indexOf(";</script>", start);
if (end < 0) return null; if (end < 0) return null;
let data: Json; let data: unknown;
try { try {
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end)); data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
} catch { } catch {
@@ -119,9 +133,9 @@ export function parsePlaylistPage(
} }
const lockups: unknown[] = []; const lockups: unknown[] = [];
collect(data?.contents, "lockupViewModel", lockups); collect(at(data, "contents"), "lockupViewModel", lockups);
const legacy: unknown[] = []; const legacy: unknown[] = [];
collect(data?.contents, "playlistVideoRenderer", legacy); collect(at(data, "contents"), "playlistVideoRenderer", legacy);
// A video can appear in a playlist twice; both rows are kept, so the list // A video can appear in a playlist twice; both rows are kept, so the list
// stays positional and matches what the embed plays. // stays positional and matches what the embed plays.
@@ -131,8 +145,10 @@ export function parsePlaylistPage(
].filter((t): t is YouTubePlaylistTrack => t !== null); ].filter((t): t is YouTubePlaylistTrack => t !== null);
if (tracks.length === 0) return null; if (tracks.length === 0) return null;
const title = data?.metadata?.playlistMetadataRenderer?.title ?? const metaTitle = at(data, "metadata", "playlistMetadataRenderer", "title");
firstString(data?.header, "content"); const title = typeof metaTitle === "string"
? metaTitle
: firstString(at(data, "header"), "content");
return { listId, title, tracks }; return { listId, title, tracks };
} }

View File

@@ -1,5 +1,10 @@
// Tokens, passwords and the checks that turn a token into a user. // Tokens, passwords and the checks that turn a token into a user.
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts"; import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
import type {
AuthResponse,
AuthTokens,
MediaTokenResponse,
} from "../model/interfaces.ts";
import { import {
assert, assert,
assertEquals, assertEquals,
@@ -137,7 +142,7 @@ test("optional auth: a missing or placeholder token is anonymous, a bad one is 4
); );
const bad = await get(path, { headers: { Authorization: "Bearer nope" } }); const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
assertEquals(bad.status, 401); assertEquals(bad.status, 401);
assertEquals(bad.body.error.code, "UNAUTHORIZED"); assertEquals(bad.error.code, "UNAUTHORIZED");
}); });
test("required auth: missing, malformed and forged tokens are 401", async () => { test("required auth: missing, malformed and forged tokens are 401", async () => {
@@ -160,15 +165,15 @@ test("required auth: missing, malformed and forged tokens are 401", async () =>
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => {
const user = await createUser(); const user = await createUser();
const ok = await post("/api/users/login", { const ok = await post<AuthResponse>("/api/users/login", {
json: { username: user.username, password: PASSWORD }, json: { username: user.username, password: PASSWORD },
}); });
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
assertEquals(ok.body.data.user.id, user.id); assertEquals(ok.data.user.id, user.id);
assertEquals(ok.body.data.user.passwordHash, undefined); assertFalse("passwordHash" in ok.data.user);
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id); assertEquals((await verifyJWT(ok.data.token))?.userId, user.id);
assertEquals( assertEquals(
(await verifyMediaToken(ok.body.data.mediaToken))?.userId, (await verifyMediaToken(ok.data.mediaToken))?.userId,
user.id, user.id,
); );
@@ -180,7 +185,7 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which
}); });
assertEquals(wrong.status, 401); assertEquals(wrong.status, 401);
assertEquals(unknown.status, 401); assertEquals(unknown.status, 401);
assertEquals(wrong.body.error.message, unknown.body.error.message); assertEquals(wrong.error.message, unknown.error.message);
assertEquals( assertEquals(
(await post("/api/users/login", { json: { username: user.username } })) (await post("/api/users/login", { json: { username: user.username } }))
@@ -222,7 +227,7 @@ function invite(inviterId: string, createdAt = new Date()): string {
test("POST /api/users/register: an invite opens one account", async () => { test("POST /api/users/register: an invite opens one account", async () => {
const inviter = await createUser(); const inviter = await createUser();
const inviteToken = invite(inviter.id); const inviteToken = invite(inviter.id);
const res = await post("/api/users/register", { const res = await post<AuthResponse>("/api/users/register", {
json: { json: {
username: "newbie_reg", username: "newbie_reg",
password: "longenough", password: "longenough",
@@ -231,10 +236,10 @@ test("POST /api/users/register: an invite opens one account", async () => {
}, },
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
assertEquals(res.body.data.user.username, "newbie_reg"); assertEquals(res.data.user.username, "newbie_reg");
assertEquals(res.body.data.user.role, "user"); assertEquals(res.data.user.role, "user");
assertEquals(res.body.data.user.passwordHash, undefined); assertFalse("passwordHash" in res.data.user);
assert(res.body.data.token); assert(res.data.token);
const row = db.prepare( const row = db.prepare(
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`, `SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
).get() as { invited_by: string }; ).get() as { invited_by: string };
@@ -271,7 +276,7 @@ test("POST /api/users/register: validation and unknown invites", async () => {
for (const [json, message] of cases) { for (const [json, message] of cases) {
const res = await post("/api/users/register", { json }); const res = await post("/api/users/register", { json });
assertEquals(res.status, 400, JSON.stringify(json)); assertEquals(res.status, 400, JSON.stringify(json));
assert(message.test(res.body.error.message), res.body.error.message); assert(message.test(res.error.message), res.error.message);
} }
const unknown = await post("/api/users/register", { const unknown = await post("/api/users/register", {
json: { ...base, inviteToken: "made-up" }, json: { ...base, inviteToken: "made-up" },
@@ -372,16 +377,16 @@ test("change-password: needs the current one, revokes old tokens, returns new on
// iat has one-second resolution: make sure the old token is from an earlier // iat has one-second resolution: make sure the old token is from an earlier
// second than the change. // second than the change.
await new Promise((r) => setTimeout(r, 1100)); await new Promise((r) => setTimeout(r, 1100));
const ok = await post("/api/users/me/change-password", { const ok = await post<AuthTokens>("/api/users/me/change-password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
}); });
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
assertEquals(await authenticateMedia(user.mediaToken), null); assertEquals(await authenticateMedia(user.mediaToken), null);
const fresh = { Authorization: `Bearer ${ok.body.data.token}` }; const fresh = { Authorization: `Bearer ${ok.data.token}` };
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id); assertEquals(await authenticateMedia(ok.data.mediaToken), user.id);
const login = await post("/api/users/login", { const login = await post("/api/users/login", {
json: { username: user.username, password: "brand-new-pass" }, json: { username: user.username, password: "brand-new-pass" },
@@ -452,7 +457,9 @@ test("reset-password: an unstored, forged or wrong-purpose token fails", async (
test("GET /api/users/me/media-token issues a media token for the caller", async () => { test("GET /api/users/me/media-token issues a media token for the caller", async () => {
const user = await createUser(); const user = await createUser();
const res = await get("/api/users/me/media-token", { auth: user.auth }); const res = await get<MediaTokenResponse>("/api/users/me/media-token", {
assertEquals(res.status, 200); auth: user.auth,
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id); });
assertEquals(res.status, 200);
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
}); });

View File

@@ -10,19 +10,28 @@ import {
put, put,
test, test,
} from "./helpers.ts"; } from "./helpers.ts";
import { assertEquals, assertStringIncludes } from "@std/assert"; import type {
Category,
Dump,
PaginatedData,
SearchResults,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
test("categories: listed by position, then slug; found by slug", async () => { test("categories: listed by position, then slug; found by slug", async () => {
insertCategory({ slug: "zeta", position: 0 }); insertCategory({ slug: "zeta", position: 0 });
insertCategory({ slug: "alpha", position: 1 }); insertCategory({ slug: "alpha", position: 1 });
insertCategory({ slug: "beta", position: 0 }); insertCategory({ slug: "beta", position: 0 });
const list = await get("/api/categories"); const list = await get<Category[]>("/api/categories");
assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [ assertEquals(list.data.map((c) => c.slug), [
"beta", "beta",
"zeta", "zeta",
"alpha", "alpha",
]); ]);
assertEquals((await get("/api/categories/alpha")).body.data.slug, "alpha"); assertEquals(
(await get<Category>("/api/categories/alpha")).data.slug,
"alpha",
);
assertEquals((await get("/api/categories/nope")).status, 404); assertEquals((await get("/api/categories/nope")).status, 404);
}); });
@@ -35,9 +44,12 @@ test("categories: only category:manage may create, update or delete", async () =
(await post("/api/categories", { auth: mod.auth, json })).status, (await post("/api/categories", { auth: mod.auth, json })).status,
403, 403,
); );
const created = await post("/api/categories", { auth: admin.auth, json }); const created = await post<Category>("/api/categories", {
auth: admin.auth,
json,
});
assertEquals(created.status, 201); assertEquals(created.status, 201);
const id = created.body.data.id; const id = created.data.id;
assertEquals( assertEquals(
(await put(`/api/categories/${id}`, { (await put(`/api/categories/${id}`, {
auth: mod.auth, auth: mod.auth,
@@ -62,7 +74,7 @@ test("categories: only category:manage may create, update or delete", async () =
test("categories: slug and name rules, and friendly conflicts", async () => { test("categories: slug and name rules, and friendly conflicts", async () => {
const admin = await createUser({ role: "admin" }); const admin = await createUser({ role: "admin" });
const create = (json: unknown) => const create = (json: unknown) =>
post("/api/categories", { auth: admin.auth, json }); post<Category>("/api/categories", { auth: admin.auth, json });
for ( for (
const [json, message] of [ const [json, message] of [
[{ slug: "Bad Slug", name: "x" }, "lowercase"], [{ slug: "Bad Slug", name: "x" }, "lowercase"],
@@ -74,32 +86,32 @@ test("categories: slug and name rules, and friendly conflicts", async () => {
) { ) {
const res = await create(json); const res = await create(json);
assertEquals(res.status, 400, JSON.stringify(json)); assertEquals(res.status, 400, JSON.stringify(json));
assertStringIncludes(res.body.error.message, message); assertStringIncludes(res.error.message, message);
} }
assertEquals((await create({ slug: "ok" })).status, 400); assertEquals((await create({ slug: "ok" })).status, 400);
const first = await create({ slug: "music", name: " Music " }); const first = await create({ slug: "music", name: " Music " });
assertEquals(first.body.data.name, "Music"); assertEquals(first.data.name, "Music");
const dupSlug = await create({ slug: "music", name: "Other" }); const dupSlug = await create({ slug: "music", name: "Other" });
assertEquals(dupSlug.status, 409); assertEquals(dupSlug.status, 409);
assertStringIncludes(dupSlug.body.error.message, "Slug"); assertStringIncludes(dupSlug.error.message, "Slug");
const dupName = await create({ slug: "other", name: "Music" }); const dupName = await create({ slug: "other", name: "Music" });
assertEquals(dupName.status, 409); assertEquals(dupName.status, 409);
assertStringIncludes(dupName.body.error.message, "Name"); assertStringIncludes(dupName.error.message, "Name");
const second = await create({ slug: "films", name: "Films" }); const second = await create({ slug: "films", name: "Films" });
const rename = await put(`/api/categories/${second.body.data.id}`, { const rename = await put(`/api/categories/${second.data.id}`, {
auth: admin.auth, auth: admin.auth,
json: { slug: "music" }, json: { slug: "music" },
}); });
assertEquals(rename.status, 409); assertEquals(rename.status, 409);
const ok = await put(`/api/categories/${second.body.data.id}`, { const ok = await put<Category>(`/api/categories/${second.data.id}`, {
auth: admin.auth, auth: admin.auth,
json: { name: "Movies", position: 3 }, json: { name: "Movies", position: 3 },
}); });
assertEquals(ok.body.data.slug, "films"); assertEquals(ok.data.slug, "films");
assertEquals(ok.body.data.name, "Movies"); assertEquals(ok.data.name, "Movies");
assertEquals(ok.body.data.position, 3); assertEquals(ok.data.position, 3);
}); });
test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => { test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => {
@@ -118,13 +130,15 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
) )
.run(d.id, cat); .run(d.id, cat);
} }
const anon = await get("/api/categories/music/dumps"); const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps");
assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [ assertEquals(anon.data.items.map((d) => d.title), [
"in", "in",
]); ]);
assertEquals(anon.body.data.items[0].categoryIds, [cat]); assertEquals(anon.data.items[0].categoryIds, [cat]);
assertEquals( assertEquals(
(await get("/api/categories/music/dumps", { auth: owner.auth })).body.data (await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
auth: owner.auth,
})).data
.total, .total,
2, 2,
); );
@@ -133,7 +147,10 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
// Deleting the category unlinks its dumps without deleting them. // Deleting the category unlinks its dumps without deleting them.
const admin = await createUser({ role: "admin" }); const admin = await createUser({ role: "admin" });
await del(`/api/categories/${cat}`, { auth: admin.auth }); await del(`/api/categories/${cat}`, { auth: admin.auth });
assertEquals((await get(`/api/dumps/${inCat.id}`)).body.data.categoryIds, []); assertEquals(
(await get<Dump>(`/api/dumps/${inCat.id}`)).data.categoryIds,
[],
);
}); });
test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => { test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => {
@@ -162,23 +179,28 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () =
isPublic: false, isPublic: false,
}); });
const anon = (await get("/api/search?q=banana")).body.data; const anon = (await get<SearchResults>("/api/search?q=banana")).data;
assertEquals(anon.dumps.total, 3); assertEquals(anon.dumps.total, 3);
assertEquals(anon.users.map((u: { id: string }) => u.id), [other.id]); assertEquals(anon.users.map((u) => u.id), [other.id]);
assertEquals(anon.playlists.map((p: { title: string }) => p.title), [ // Profiles only: a search must never hand out hashes or emails.
assertFalse("passwordHash" in anon.users[0]);
assertFalse("email" in anon.users[0]);
assertEquals(anon.playlists.map((p) => p.title), [
"Banana hits", "Banana hits",
]); ]);
const mine = const mine =
(await get("/api/search?q=banana", { auth: owner.auth })).body.data; (await get<SearchResults>("/api/search?q=banana", { auth: owner.auth }))
.data;
assertEquals(mine.dumps.total, 4); assertEquals(mine.dumps.total, 4);
assertEquals(mine.playlists.length, 2); assertEquals(mine.playlists.length, 2);
const paged = (await get("/api/search?q=banana&limit=2")).body.data.dumps; const paged =
(await get<SearchResults>("/api/search?q=banana&limit=2")).data.dumps;
assertEquals(paged.items.length, 2); assertEquals(paged.items.length, 2);
assertEquals(paged.hasMore, true); assertEquals(paged.hasMore, true);
assertEquals((await get("/api/search?q=%20%20")).body.data, { assertEquals((await get<SearchResults>("/api/search?q=%20%20")).data, {
dumps: { items: [], total: 0, hasMore: false }, dumps: { items: [], total: 0, hasMore: false },
users: [], users: [],
playlists: [], playlists: [],

View File

@@ -11,7 +11,7 @@ import {
markChatRead, markChatRead,
updateChatMessage, updateChatMessage,
} from "../services/chat-service.ts"; } from "../services/chat-service.ts";
import { APIException } from "../model/interfaces.ts"; import { APIException, type ChatMessage } from "../model/interfaces.ts";
function insertMessage( function insertMessage(
userId: string, userId: string,
@@ -130,8 +130,8 @@ test("GET /api/chat: members only, with limit and both cursors", async () => {
const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n))); const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n)));
assertEquals((await get("/api/chat")).status, 401); assertEquals((await get("/api/chat")).status, 401);
const bodies = async (q: string) => const bodies = async (q: string) =>
(await get(`/api/chat${q}`, { auth: user.auth })).body.data.map( (await get<ChatMessage[]>(`/api/chat${q}`, { auth: user.auth })).data.map(
(m: { body: string }) => m.body, (m) => m.body,
); );
assertEquals(await bodies(""), ["m1", "m2", "m3"]); assertEquals(await bodies(""), ["m1", "m2", "m3"]);
assertEquals(await bodies("?limit=1"), ["m3"]); assertEquals(await bodies("?limit=1"), ["m3"]);

View File

@@ -10,35 +10,41 @@ import {
post, post,
test, test,
} from "./helpers.ts"; } from "./helpers.ts";
import { assertEquals, assertExists } from "@std/assert"; import { assertEquals, assertExists, assertFalse } from "@std/assert";
import { import {
castCommentLike, castCommentLike,
removeCommentLike, removeCommentLike,
} from "../services/comment-like-service.ts"; } from "../services/comment-like-service.ts";
import { APIException } from "../model/interfaces.ts"; import {
APIException,
type Comment,
type Dump,
type PaginatedData,
type PublicUser,
} from "../model/interfaces.ts";
test("comments: post, list in order, count on the dump", async () => { test("comments: post, list in order, count on the dump", async () => {
const owner = await createUser(); const owner = await createUser();
const other = await createUser(); const other = await createUser();
const { id } = insertDump({ userId: owner.id }); const { id } = insertDump({ userId: owner.id });
const first = await post(`/api/dumps/${id}/comments`, { const first = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: other.auth, auth: other.auth,
json: { body: " first " }, json: { body: " first " },
}); });
assertEquals(first.status, 201); assertEquals(first.status, 201);
assertEquals(first.body.data.body, "first"); assertEquals(first.data.body, "first");
assertEquals(first.body.data.authorUsername, other.username); assertEquals(first.data.authorUsername, other.username);
const reply = await post(`/api/dumps/${id}/comments`, { const reply = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: owner.auth, auth: owner.auth,
json: { body: "reply", parentId: first.body.data.id }, json: { body: "reply", parentId: first.data.id },
}); });
assertEquals(reply.body.data.parentId, first.body.data.id); assertEquals(reply.data.parentId, first.data.id);
const list = await get(`/api/dumps/${id}/comments`); const list = await get<Comment[]>(`/api/dumps/${id}/comments`);
assertEquals(list.body.data.map((c: { body: string }) => c.body), [ assertEquals(list.data.map((c) => c.body), [
"first", "first",
"reply", "reply",
]); ]);
assertEquals((await get(`/api/dumps/${id}`)).body.data.commentCount, 2); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.commentCount, 2);
}); });
test("comments: validation, auth and unknown dumps", async () => { test("comments: validation, auth and unknown dumps", async () => {
@@ -103,7 +109,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () =>
json: { body: "self" }, json: { body: "self" },
}); });
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const c = await post(`/api/dumps/${id}/comments`, { const c = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: other.auth, auth: other.auth,
json: { body: "nice" }, json: { body: "nice" },
}); });
@@ -115,7 +121,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () =>
}; };
assertEquals(note.user_id, owner.id); assertEquals(note.user_id, owner.id);
assertEquals(note.type, "dump_commented"); assertEquals(note.type, "dump_commented");
assertEquals(JSON.parse(note.data).commentId, c.body.data.id); assertEquals(JSON.parse(note.data).commentId, c.data.id);
assertEquals(JSON.parse(note.data).dumpTitle, "Mine"); assertEquals(JSON.parse(note.data).dumpTitle, "Mine");
}); });
@@ -133,13 +139,13 @@ test("PATCH /api/comments/:id: author and moderator edit, others can't", async (
.status, .status,
401, 401,
); );
const edited = await patch(`/api/comments/${commentId}`, { const edited = await patch<Comment>(`/api/comments/${commentId}`, {
auth: author.auth, auth: author.auth,
json: { body: " edited " }, json: { body: " edited " },
}); });
assertEquals(edited.status, 200); assertEquals(edited.status, 200);
assertEquals(edited.body.data.body, "edited"); assertEquals(edited.data.body, "edited");
assertExists(edited.body.data.updatedAt); assertExists(edited.data.updatedAt);
assertEquals( assertEquals(
(await patch(`/api/comments/${commentId}`, { (await patch(`/api/comments/${commentId}`, {
auth: mod.auth, auth: mod.auth,
@@ -182,10 +188,10 @@ test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edit
(await del(`/api/comments/${commentId}`, { auth: author.auth })).status, (await del(`/api/comments/${commentId}`, { auth: author.auth })).status,
200, 200,
); );
const [c] = (await get(`/api/dumps/${dumpId}/comments`)).body.data; const [c] = (await get<Comment[]>(`/api/dumps/${dumpId}/comments`)).data;
assertEquals(c.deleted, true); assertEquals(c.deleted, true);
assertEquals(c.body, ""); assertEquals(c.body, "");
assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0); assertEquals((await get<Dump>(`/api/dumps/${dumpId}`)).data.commentCount, 0);
assertEquals( assertEquals(
(await patch(`/api/comments/${commentId}`, { (await patch(`/api/comments/${commentId}`, {
auth: author.auth, auth: author.auth,
@@ -218,9 +224,11 @@ test("comment likes: count, conflicts, likers list and notifications", async ()
1, 1,
); );
const likers = await get(`/api/comments/${commentId}/likers`); const likers = await get<PaginatedData<PublicUser>>(
assertEquals(likers.body.data.total, 2); `/api/comments/${commentId}/likers`,
assertEquals(likers.body.data.items[0].email, undefined); );
assertEquals(likers.data.total, 2);
assertFalse("email" in likers.data.items[0]);
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1); assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
try { try {

View File

@@ -17,10 +17,18 @@ import {
test, test,
TEST_DIR, TEST_DIR,
} from "./helpers.ts"; } from "./helpers.ts";
import type {
Dump,
DumpUrlMatch,
PaginatedData,
Playlist,
PublicUser,
} from "../model/interfaces.ts";
import { import {
assert, assert,
assertEquals, assertEquals,
assertExists, assertExists,
assertFalse,
assertMatch, assertMatch,
assertNotEquals, assertNotEquals,
} from "@std/assert"; } from "@std/assert";
@@ -41,18 +49,18 @@ function readFile(path: string): Uint8Array | null {
test("POST /api/dumps: a URL dump takes its title from the page", async () => { test("POST /api/dumps: a URL dump takes its title from the page", async () => {
const user = await createUser(); const user = await createUser();
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", { const res = await post<Dump>("/api/dumps", {
auth: user.auth, auth: user.auth,
json: { url: "https://example.com/a?utm_source=x", comment: "look" }, json: { url: "https://example.com/a?utm_source=x", comment: "look" },
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const dump = res.body.data; const dump = res.data;
assertEquals(dump.kind, "url"); assertEquals(dump.kind, "url");
assertEquals(dump.title, "Scraped title"); assertEquals(dump.title, "Scraped title");
assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/); assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/);
assertEquals(dump.userId, user.id); assertEquals(dump.userId, user.id);
assertEquals(dump.isPrivate, false); assertEquals(dump.isPrivate, false);
assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg"); assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg");
const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`)
.get(dump.id) as { url: string; url_canonical: string }; .get(dump.id) as { url: string; url_canonical: string };
// The exact URL is kept; the canonical key drops the tracking parameter. // The exact URL is kept; the canonical key drops the tracking parameter.
@@ -64,22 +72,22 @@ test("POST /api/dumps: a typed title wins; an unreachable page still posts", asy
const user = await createUser(); const user = await createUser();
{ {
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", { const res = await post<Dump>("/api/dumps", {
auth: user.auth, auth: user.auth,
json: { url: "https://example.com/b", title: " My title " }, json: { url: "https://example.com/b", title: " My title " },
}); });
assertEquals(res.body.data.title, "My title"); assertEquals(res.data.title, "My title");
} }
{ {
using _ = stubNetwork(() => { using _ = stubNetwork(() => {
throw new TypeError("offline"); throw new TypeError("offline");
}); });
const res = await post("/api/dumps", { const res = await post<Dump>("/api/dumps", {
auth: user.auth, auth: user.auth,
json: { url: "https://www.offline.example/c" }, json: { url: "https://www.offline.example/c" },
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
assertEquals(res.body.data.title, "offline.example"); assertEquals(res.data.title, "offline.example");
} }
}); });
@@ -108,14 +116,14 @@ test("POST /api/dumps: categories are stored and deduplicated", async () => {
const user = await createUser(); const user = await createUser();
const music = insertCategory({ slug: "music" }); const music = insertCategory({ slug: "music" });
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", { const res = await post<Dump>("/api/dumps", {
auth: user.auth, auth: user.auth,
json: { url: "https://example.com/d", categoryIds: [music, music] }, json: { url: "https://example.com/d", categoryIds: [music, music] },
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
assertEquals(res.body.data.categoryIds, [music]); assertEquals(res.data.categoryIds, [music]);
assertEquals( assertEquals(
(await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds, (await get<Dump>(`/api/dumps/${res.data.id}`)).data.categoryIds,
[music], [music],
); );
}); });
@@ -133,12 +141,12 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind
test("POST /api/dumps: a file dump is stored and served back", async () => { test("POST /api/dumps: a file dump is stored and served back", async () => {
const user = await createUser(); const user = await createUser();
const res = await post("/api/dumps", { const res = await post<Dump>("/api/dumps", {
auth: user.auth, auth: user.auth,
form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }), form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }),
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const dump = res.body.data; const dump = res.data;
assertEquals(dump.kind, "file"); assertEquals(dump.kind, "file");
assertEquals(dump.title, "pixel.png"); assertEquals(dump.title, "pixel.png");
assertEquals(dump.fileMime, "image/png"); assertEquals(dump.fileMime, "image/png");
@@ -202,7 +210,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
json: { url: "https://example.com/private", isPrivate: true }, json: { url: "https://example.com/private", isPrivate: true },
}); });
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const pub = await post("/api/dumps", { const pub = await post<Dump>("/api/dumps", {
auth: poster.auth, auth: poster.auth,
json: { url: "https://example.com/public" }, json: { url: "https://example.com/public" },
}); });
@@ -212,7 +220,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
assertEquals(notes.length, 1); assertEquals(notes.length, 1);
assertEquals(notes[0].user_id, follower.id); assertEquals(notes[0].user_id, follower.id);
assertEquals(notes[0].type, "user_dump_posted"); assertEquals(notes[0].type, "user_dump_posted");
assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id); assertEquals(JSON.parse(notes[0].data).dumpId, pub.data.id);
}); });
// ── Read ───────────────────────────────────────────────────────────────────── // ── Read ─────────────────────────────────────────────────────────────────────
@@ -220,8 +228,8 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
test("GET /api/dumps/:id works by id and by slug", async () => { test("GET /api/dumps/:id works by id and by slug", async () => {
const user = await createUser(); const user = await createUser();
const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id); assertEquals((await get<Dump>(`/api/dumps/${slug}`)).data.id, id);
assertEquals((await get(`/api/dumps/nope`)).status, 404); assertEquals((await get(`/api/dumps/nope`)).status, 404);
assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404);
}); });
@@ -265,22 +273,25 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only",
isPrivate: true, isPrivate: true,
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(), createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
}); });
const page1 = await get("/api/dumps?limit=2"); const page1 = await get<PaginatedData<Dump>>("/api/dumps?limit=2");
assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [ assertEquals(page1.data.items.map((d) => d.title), [
"d4", "d4",
"d3", "d3",
]); ]);
assertEquals(page1.body.data.total, 5); assertEquals(page1.data.total, 5);
assertEquals(page1.body.data.hasMore, true); assertEquals(page1.data.hasMore, true);
const page3 = await get("/api/dumps?limit=2&page=3"); const page3 = await get<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
assertEquals(page3.body.data.items.length, 1); assertEquals(page3.data.items.length, 1);
assertEquals(page3.body.data.hasMore, false); assertEquals(page3.data.hasMore, false);
const mine = await get("/api/dumps?limit=1", { auth: owner.auth }); const mine = await get<PaginatedData<Dump>>("/api/dumps?limit=1", {
assertEquals(mine.body.data.items[0].title, "secret"); auth: owner.auth,
assertEquals(mine.body.data.total, 6); });
assertEquals(mine.data.items[0].title, "secret");
assertEquals(mine.data.total, 6);
assertEquals( assertEquals(
(await get("/api/dumps", { auth: other.auth })).body.data.total, (await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
.total,
5, 5,
); );
}); });
@@ -300,19 +311,26 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
isPrivate: true, isPrivate: true,
}); });
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
const anon = await get(`/api/dumps/by-url?url=${q}`); const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`);
assertEquals(anon.body.data.map((d: { title: string }) => d.title), [ assertEquals(anon.data.map((d) => d.title), [
"public", "public",
]); ]);
assertEquals(anon.body.data[0].username, owner.username); assertEquals(anon.data[0].username, owner.username);
const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth }); const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
assertEquals(mine.body.data.length, 2); auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals( assertEquals(
(await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data (await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
auth: other.auth,
})).data
.length, .length,
1, 1,
); );
assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []); assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=garbage`)).data,
[],
);
}); });
test("GET /api/dumps/:id/voters lists voters without private fields", async () => { test("GET /api/dumps/:id/voters lists voters without private fields", async () => {
@@ -322,11 +340,11 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () =
db.prepare( db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(id, voter.id, new Date().toISOString()); ).run(id, voter.id, new Date().toISOString());
const res = await get(`/api/dumps/${id}/voters`); const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
assertEquals(res.body.data.total, 1); assertEquals(res.data.total, 1);
assertEquals(res.body.data.items[0].username, voter.username); assertEquals(res.data.items[0].username, voter.username);
assertEquals(res.body.data.items[0].email, undefined); assertFalse("email" in res.data.items[0]);
assertEquals(res.body.data.items[0].passwordHash, undefined); assertFalse("passwordHash" in res.data.items[0]);
}); });
test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => { test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => {
@@ -345,7 +363,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
// A new dump whose comment links the target by its URL and the secret dump by // A new dump whose comment links the target by its URL and the secret dump by
// its page — and itself, which is ignored. // its page — and itself, which is ignored.
const src = await post("/api/dumps", { const src = await post<Dump>("/api/dumps", {
auth: other.auth, auth: other.auth,
json: { json: {
url: "https://example.com/src", url: "https://example.com/src",
@@ -353,21 +371,21 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
`see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, `see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
}, },
}); });
const srcId = src.body.data.id; const srcId = src.data.id;
const fromSrc = await get(`/api/dumps/${srcId}/related`); const fromSrc = await get<Dump[]>(`/api/dumps/${srcId}/related`);
assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [ assertEquals(fromSrc.data.map((d) => d.title), [
"target", "target",
]); ]);
const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, { const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
auth: other.auth, auth: other.auth,
}); });
assertEquals(fromSrcAsOwner.body.data.length, 2); assertEquals(fromSrcAsOwner.data.length, 2);
const toTarget = await get(`/api/dumps/${target.id}/related`); const toTarget = await get<Dump[]>(`/api/dumps/${target.id}/related`);
assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]); assertEquals(toTarget.data.map((d) => d.id), [srcId]);
// Editing the comment away removes the links. // Editing the comment away removes the links.
await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } }); await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } });
assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []); assertEquals((await get<Dump[]>(`/api/dumps/${target.id}/related`)).data, []);
}); });
test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => { test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => {
@@ -386,12 +404,14 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
).run(p.id, id, new Date().toISOString()); ).run(p.id, id, new Date().toISOString());
} }
assertEquals( assertEquals(
(await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data (await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
.map((p: { title: string }) => p.title), .data
.map((p) => p.title),
["Public"], ["Public"],
); );
assertEquals( assertEquals(
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data (await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: user.auth }))
.data
.length, .length,
2, 2,
); );
@@ -413,21 +433,21 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async (
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
401, 401,
); );
const res = await put(`/api/dumps/${slug}`, { const res = await put<Dump>(`/api/dumps/${slug}`, {
auth: owner.auth, auth: owner.auth,
json: { title: "New title", comment: "hello" }, json: { title: "New title", comment: "hello" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.title, "New title"); assertEquals(res.data.title, "New title");
assertMatch(res.body.data.slug, /^new-title-/); assertMatch(res.data.slug ?? "", /^new-title-/);
assertNotEquals(res.body.data.slug, slug); assertNotEquals(res.data.slug, slug);
assertExists(res.body.data.updatedAt); assertExists(res.data.updatedAt);
const byMod = await put(`/api/dumps/${id}`, { const byMod = await put(`/api/dumps/${id}`, {
auth: mod.auth, auth: mod.auth,
json: { title: "Moderated" }, json: { title: "Moderated" },
}); });
assertEquals(byMod.status, 200); assertEquals(byMod.status, 200);
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated"); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Moderated");
}); });
test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => { test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => {
@@ -453,11 +473,11 @@ test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", a
// Same URL: no fetch, title kept. // Same URL: no fetch, title kept.
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } }); await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } });
assertEquals(net.calls.length, 0); assertEquals(net.calls.length, 0);
const moved = await put(`/api/dumps/${id}`, { const moved = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { url: "https://b.test/?utm_medium=z" }, json: { url: "https://b.test/?utm_medium=z" },
}); });
assertEquals(moved.body.data.title, "Scraped title"); assertEquals(moved.data.title, "Scraped title");
assertEquals(net.calls.length, 1); assertEquals(net.calls.length, 1);
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get( const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
id, id,
@@ -476,21 +496,21 @@ test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async
auth: owner.auth, auth: owner.auth,
json: { categoryIds: [a] }, json: { categoryIds: [a] },
}); });
const kept = await put(`/api/dumps/${id}`, { const kept = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { title: "t" }, json: { title: "t" },
}); });
assertEquals(kept.body.data.categoryIds, [a]); assertEquals(kept.data.categoryIds, [a]);
const replaced = await put(`/api/dumps/${id}`, { const replaced = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { categoryIds: [b] }, json: { categoryIds: [b] },
}); });
assertEquals(replaced.body.data.categoryIds, [b]); assertEquals(replaced.data.categoryIds, [b]);
const cleared = await put(`/api/dumps/${id}`, { const cleared = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { categoryIds: [] }, json: { categoryIds: [] },
}); });
assertEquals(cleared.body.data.categoryIds, []); assertEquals(cleared.data.categoryIds, []);
}); });
test("PUT /api/dumps/:id: an unknown category changes nothing", async () => { test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
@@ -506,7 +526,7 @@ test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
json: { title: "After", categoryIds: [a, "nope"] }, json: { title: "After", categoryIds: [a, "nope"] },
}); });
assertEquals(res.status, 400); assertEquals(res.status, 400);
const dump = (await get(`/api/dumps/${id}`)).body.data; const dump = (await get<Dump>(`/api/dumps/${id}`)).data;
assertEquals(dump.title, "Before"); assertEquals(dump.title, "Before");
assertEquals(dump.categoryIds, [a]); assertEquals(dump.categoryIds, [a]);
}); });
@@ -528,7 +548,7 @@ test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async ()
}); });
assertEquals(res.status, 400); assertEquals(res.status, 400);
assertEquals(readFile(`dumps/${id}`), original); assertEquals(readFile(`dumps/${id}`), original);
assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt"); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.fileName, "a.txt");
}); });
test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => { test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => {
@@ -553,7 +573,7 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
title: "file", title: "file",
fileName: "a.txt", fileName: "a.txt",
}); });
const res = await put(`/api/dumps/${id}`, { const res = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { json: {
title: "Renamed", title: "Renamed",
@@ -563,9 +583,9 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
}, },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.title, "Renamed"); assertEquals(res.data.title, "Renamed");
assertEquals(res.body.data.isPrivate, true); assertEquals(res.data.isPrivate, true);
assertEquals(res.body.data.url, undefined); assertEquals(res.data.url, undefined);
}); });
test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => { test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
@@ -583,13 +603,13 @@ test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
.status, .status,
403, 403,
); );
const res = await put(`/api/dumps/${id}/file`, { const res = await put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth, auth: owner.auth,
form: form(), form: form(),
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.fileName, "b.png"); assertEquals(res.data.fileName, "b.png");
assertEquals(res.body.data.title, "Replaced"); assertEquals(res.data.title, "Replaced");
assertEquals(readFile(`dumps/${id}`), PNG); assertEquals(readFile(`dumps/${id}`), PNG);
const urlDump = insertDump({ userId: owner.id }); const urlDump = insertDump({ userId: owner.id });
@@ -608,7 +628,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
const other = await createUser(); const other = await createUser();
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" }); const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
const replace = (fields: Record<string, string>) => const replace = (fields: Record<string, string>) =>
put(`/api/dumps/${id}/file`, { put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", fields), form: formWithFile(PNG, "b.png", "image/png", fields),
}); });
@@ -616,15 +636,15 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
(await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200; (await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200;
let res = await replace({ isPrivate: "true" }); let res = await replace({ isPrivate: "true" });
assertEquals(res.body.data.isPrivate, true); assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false); assertEquals(await visible(), false);
assertEquals((await get(`/api/files/${id}`)).status, 404); assertEquals((await get(`/api/files/${id}`)).status, 404);
// Left out, it stays private. // Left out, it stays private.
res = await replace({}); res = await replace({});
assertEquals(res.body.data.isPrivate, true); assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false); assertEquals(await visible(), false);
res = await replace({ isPrivate: "false" }); res = await replace({ isPrivate: "false" });
assertEquals(res.body.data.isPrivate, false); assertEquals(res.data.isPrivate, false);
assertEquals(await visible(), true); assertEquals(await visible(), true);
// A refused replacement changes neither. // A refused replacement changes neither.
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' }); res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
@@ -654,19 +674,21 @@ test("custom thumbnails: set, served, removed", async () => {
})).status, })).status,
400, 400,
); );
const set = await put(`/api/dumps/${id}/thumbnail`, { const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(set.status, 200); assertEquals(set.status, 200);
assertEquals(set.body.data.thumbnailMime, "image/png"); assertEquals(set.data.thumbnailMime, "image/png");
const thumb = await get(`/api/thumbnails/${id}`); const thumb = await get(`/api/thumbnails/${id}`);
assertEquals(thumb.status, 200); assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png"); assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG); assertEquals(thumb.bytes, PNG);
const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth }); const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
assertEquals(removed.body.data.thumbnailMime, undefined); auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null); assertEquals(readFile(`thumbnails/${id}-custom`), null);
// A URL dump without its own thumbnail has none to serve. // A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); assertEquals((await get(`/api/thumbnails/${id}`)).status, 404);
@@ -688,19 +710,19 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
using _ = stubNetwork(() => { using _ = stubNetwork(() => {
throw new TypeError("down"); throw new TypeError("down");
}); });
const res = await post(`/api/dumps/${id}/refresh-metadata`, { const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth, auth: owner.auth,
}); });
assertEquals(res.status, 502); assertEquals(res.status, 502);
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept"); assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Kept");
} }
{ {
using _ = stubNetwork(() => PAGE.clone()); using _ = stubNetwork(() => PAGE.clone());
const res = await post(`/api/dumps/${id}/refresh-metadata`, { const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth, auth: owner.auth,
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.title, "Scraped title"); assertEquals(res.data.title, "Scraped title");
} }
const file = insertDump({ userId: owner.id, fileName: "f.txt" }); const file = insertDump({ userId: owner.id, fileName: "f.txt" });
assertEquals( assertEquals(
@@ -760,14 +782,12 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
} }
for (const sub of ["dumps", "votes"]) { for (const sub of ["dumps", "votes"]) {
const path = `/api/users/${owner.username}/${sub}`; const path = `/api/users/${owner.username}/${sub}`;
assertEquals((await get(path)).body.data.total, 1, sub); const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.total;
assertEquals(await total(), 1, sub);
assertEquals(await total({ auth: other.auth }), 1, sub);
assertEquals( assertEquals(
(await get(path, { auth: other.auth })).body.data.total, await total({ auth: owner.auth }),
1,
sub,
);
assertEquals(
(await get(path, { auth: owner.auth })).body.data.total,
2, 2,
sub, sub,
); );

View File

@@ -7,7 +7,7 @@ import { db } from "../model/db.ts";
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts"; import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
import { makeSlug } from "../lib/slugify.ts"; import { makeSlug } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts";
import type { Role } from "../model/interfaces.ts"; import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
export { app, db, TEST_DIR }; export { app, db, TEST_DIR };
@@ -245,28 +245,35 @@ export function count(sql: string, ...params: (string | number)[]): number {
export const BASE_URL = "http://gerbeur.test"; export const BASE_URL = "http://gerbeur.test";
export interface ApiResponse { export interface ApiResponse<T = unknown> {
status: number; status: number;
headers: Headers; headers: Headers;
// deno-lint-ignore no-explicit-any /** The parsed body when it's JSON, null otherwise. */
body: any; body: APIResponse<T> | null;
bytes: Uint8Array; bytes: Uint8Array;
/** The body's `data`; throws if the request didn't succeed. */
readonly data: T;
/** The body's `error`; throws if the request didn't fail. */
readonly error: APIError;
} }
/** type RequestOptions = {
* Sends a request through the full middleware stack in-process — no socket.
* `json` sends a JSON body, `form` a multipart one.
*/
export async function request(
method: string,
path: string,
opts: {
auth?: { Authorization: string }; auth?: { Authorization: string };
json?: unknown; json?: unknown;
form?: FormData; form?: FormData;
headers?: Record<string, string>; headers?: Record<string, string>;
} = {}, };
): Promise<ApiResponse> {
/**
* Sends a request through the full middleware stack in-process — no socket.
* `json` sends a JSON body, `form` a multipart one. `T` is what the endpoint
* answers with in `data`.
*/
export async function request<T = unknown>(
method: string,
path: string,
opts: RequestOptions = {},
): Promise<ApiResponse<T>> {
const headers = new Headers({ ...opts.auth, ...opts.headers }); const headers = new Headers({ ...opts.auth, ...opts.headers });
let body: BodyInit | undefined; let body: BodyInit | undefined;
if (opts.json !== undefined) { if (opts.json !== undefined) {
@@ -281,23 +288,41 @@ export async function request(
if (!res) throw new Error(`No response for ${method} ${path}`); if (!res) throw new Error(`No response for ${method} ${path}`);
const bytes = new Uint8Array(await res.arrayBuffer()); const bytes = new Uint8Array(await res.arrayBuffer());
const text = new TextDecoder().decode(bytes); const text = new TextDecoder().decode(bytes);
let parsed: unknown = null; let parsed: APIResponse<T> | null = null;
if ((res.headers.get("content-type") ?? "").includes("json") && text) { if ((res.headers.get("content-type") ?? "").includes("json") && text) {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} }
return { status: res.status, headers: res.headers, body: parsed, bytes }; const what = `${method} ${path} (${res.status})`;
return {
status: res.status,
headers: res.headers,
body: parsed,
bytes,
get data() {
if (!parsed?.success) {
throw new Error(`${what} failed: ${parsed?.error.message ?? text}`);
}
return parsed.data;
},
get error() {
if (parsed?.success !== false) {
throw new Error(`${what} didn't fail`);
}
return parsed.error;
},
};
} }
export const get = (path: string, opts?: Parameters<typeof request>[2]) => export const get = <T = unknown>(path: string, opts?: RequestOptions) =>
request("GET", path, opts); request<T>("GET", path, opts);
export const post = (path: string, opts?: Parameters<typeof request>[2]) => export const post = <T = unknown>(path: string, opts?: RequestOptions) =>
request("POST", path, opts); request<T>("POST", path, opts);
export const put = (path: string, opts?: Parameters<typeof request>[2]) => export const put = <T = unknown>(path: string, opts?: RequestOptions) =>
request("PUT", path, opts); request<T>("PUT", path, opts);
export const patch = (path: string, opts?: Parameters<typeof request>[2]) => export const patch = <T = unknown>(path: string, opts?: RequestOptions) =>
request("PATCH", path, opts); request<T>("PATCH", path, opts);
export const del = (path: string, opts?: Parameters<typeof request>[2]) => export const del = <T = unknown>(path: string, opts?: RequestOptions) =>
request("DELETE", path, opts); request<T>("DELETE", path, opts);
// ── Files ──────────────────────────────────────────────────────────────────── // ── Files ────────────────────────────────────────────────────────────────────

View File

@@ -2,8 +2,11 @@
// their parsers and their routes. All network access is stubbed. // their parsers and their routes. All network access is stubbed.
import { get, html, stubNetwork } from "./helpers.ts"; import { get, html, stubNetwork } from "./helpers.ts";
import { assert, assertEquals, assertFalse } from "@std/assert"; import { assert, assertEquals, assertFalse } from "@std/assert";
import { parseTralbum } from "../services/bandcamp-tralbum.ts"; import { parseTralbum, type Tralbum } from "../services/bandcamp-tralbum.ts";
import { parsePlaylistPage } from "../services/youtube-playlist-service.ts"; import {
parsePlaylistPage,
type YouTubePlaylist,
} from "../services/youtube-playlist-service.ts";
function escapeAttr(s: string): string { function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;"); return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
@@ -147,10 +150,12 @@ Deno.test("parsePlaylistPage: current and legacy rows", () => {
Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => { Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => {
using net = stubNetwork(() => html(tralbumPage(ALBUM))); using net = stubNetwork(() => html(tralbumPage(ALBUM)));
const url = "https://cached.bandcamp.com/album/x"; const url = "https://cached.bandcamp.com/album/x";
const res = await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`); const res = await get<Tralbum>(
`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`,
);
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
assertEquals(res.body.data.tracks.length, 4); assertEquals(res.data.tracks.length, 4);
// Same page (different query/case/trailing slash) → cache hit. // Same page (different query/case/trailing slash) → cache hit.
await get( await get(
`/api/bandcamp/tracks?url=${ `/api/bandcamp/tracks?url=${
@@ -188,9 +193,9 @@ Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () =
? new Response("", { status: 404 }) ? new Response("", { status: 404 })
: html(ytPage(YT_DATA)); : html(ytPage(YT_DATA));
}); });
const ok = await get("/api/youtube/playlist?list=PLgood"); const ok = await get<YouTubePlaylist>("/api/youtube/playlist?list=PLgood");
assertEquals(ok.status, 200); assertEquals(ok.status, 200);
assertEquals(ok.body.data.tracks.length, 2); assertEquals(ok.data.tracks.length, 2);
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
// The pre-answered consent cookie keeps EU requests off the consent page. // The pre-answered consent cookie keeps EU requests off the consent page.
assertEquals(cookie, "SOCS=CAI"); assertEquals(cookie, "SOCS=CAI");

View File

@@ -1,4 +1,5 @@
import { import {
type ApiResponse,
count, count,
createUser, createUser,
db, db,
@@ -13,29 +14,35 @@ import {
test, test,
TEST_DIR, TEST_DIR,
} from "./helpers.ts"; } from "./helpers.ts";
import type {
PaginatedData,
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../model/interfaces.ts";
import { assert, assertEquals, assertMatch } from "@std/assert"; import { assert, assertEquals, assertMatch } from "@std/assert";
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] { function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
return res.body.data.dumps.map((d) => d.id); return res.data.dumps.map((d) => d.id);
} }
test("POST /api/playlists creates; GET finds it by id or slug", async () => { test("POST /api/playlists creates; GET finds it by id or slug", async () => {
const user = await createUser(); const user = await createUser();
const res = await post("/api/playlists", { const res = await post<Playlist>("/api/playlists", {
auth: user.auth, auth: user.auth,
json: { title: "Road Trip", description: "songs", isPublic: true }, json: { title: "Road Trip", description: "songs", isPublic: true },
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const { id, slug } = res.body.data; const { id, slug } = res.data;
assertMatch(slug, /^road-trip-/); assertMatch(slug ?? "", /^road-trip-/);
assertEquals( assertEquals(
(await get(`/api/playlists/${id}`)).body.data.title, (await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
"Road Trip", "Road Trip",
); );
const bySlug = await get(`/api/playlists/${slug}`); const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`);
assertEquals(bySlug.body.data.id, id); assertEquals(bySlug.data.id, id);
assertEquals(bySlug.body.data.ownerUsername, user.username); assertEquals(bySlug.data.ownerUsername, user.username);
assertEquals(bySlug.body.data.dumps, []); assertEquals(bySlug.data.dumps, []);
assertEquals( assertEquals(
(await post("/api/playlists", { auth: user.auth, json: { title: "" } })) (await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
.status, .status,
@@ -63,8 +70,11 @@ test("private playlists are the owner's alone", async () => {
); );
insertPlaylist({ userId: owner.id }); insertPlaylist({ userId: owner.id });
const path = `/api/users/${owner.username}/playlists`; const path = `/api/users/${owner.username}/playlists`;
assertEquals((await get(path)).body.data.total, 1); assertEquals((await get<PaginatedData<Playlist>>(path)).data.total, 1);
assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2); assertEquals(
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.total,
2,
);
}); });
test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => { test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => {
@@ -109,7 +119,10 @@ test("adding dumps: newest on top, no duplicates, only dumps you can see", async
.status, .status,
403, 403,
); );
assertEquals((await get(`/api/playlists/${id}`)).body.data.dumpCount, 2); assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.dumpCount,
2,
);
}); });
test("a public playlist hides its private dumps from everyone but their owner", async () => { test("a public playlist hides its private dumps from everyone but their owner", async () => {
@@ -201,19 +214,19 @@ test("removing a dump; updating; deleting", async () => {
})).status, })).status,
403, 403,
); );
const updated = await put(`/api/playlists/${id}`, { const updated = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { title: "New", description: "d", isPublic: false }, json: { title: "New", description: "d", isPublic: false },
}); });
assertEquals(updated.body.data.title, "New"); assertEquals(updated.data.title, "New");
assertMatch(updated.body.data.slug, /^new-/); assertMatch(updated.data.slug ?? "", /^new-/);
assertEquals(updated.body.data.isPublic, false); assertEquals(updated.data.isPublic, false);
const cleared = await put(`/api/playlists/${id}`, { const cleared = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth, auth: owner.auth,
json: { description: null }, json: { description: null },
}); });
assertEquals(cleared.body.data.description, undefined); assertEquals(cleared.data.description, undefined);
assertEquals(cleared.body.data.title, "New"); assertEquals(cleared.data.title, "New");
assertEquals( assertEquals(
(await del(`/api/playlists/${id}`, { auth: other.auth })).status, (await del(`/api/playlists/${id}`, { auth: other.auth })).status,
@@ -238,11 +251,14 @@ test("memberships: which of my playlists hold this dump", async () => {
insertPlaylist({ userId: other.id, title: "theirs" }); insertPlaylist({ userId: other.id, title: "theirs" });
const d = insertDump({ userId: owner.id }); const d = insertDump({ userId: owner.id });
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth }); await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, { const res = await get<PlaylistMembership[]>(
`/api/playlists/by-dump/${d.id}/memberships`,
{
auth: owner.auth, auth: owner.auth,
}); },
);
const byTitle = Object.fromEntries( const byTitle = Object.fromEntries(
res.body.data.map(( res.data.map((
m: { playlist: { title: string }; hasDump: boolean }, m: { playlist: { title: string }; hasDump: boolean },
) => [ ) => [
m.playlist.title, m.playlist.title,
@@ -292,14 +308,14 @@ test("playlist images: upload and serve", async () => {
.status, .status,
403, 403,
); );
const res = await post(`/api/playlists/${slug}/image`, { const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.imageMime, "image/png"); assertEquals(res.data.imageMime, "image/png");
// A new cover counts as an edit, so image URLs versioned on it change. // A new cover counts as an edit, so image URLs versioned on it change.
assert(res.body.data.updatedAt); assert(res.data.updatedAt);
const served = await get(`/api/playlists/${slug}/image`); const served = await get(`/api/playlists/${slug}/image`);
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);
@@ -356,17 +372,17 @@ test("playlist images: a failed write leaves the row and the old cover alone", a
test("playlist images: a replaced cover is served under the new version", async () => { test("playlist images: a replaced cover is served under the new version", async () => {
const owner = await createUser(); const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id }); const { id } = insertPlaylist({ userId: owner.id });
const first = await post(`/api/playlists/${id}/image`, { const first = await post<Playlist>(`/api/playlists/${id}/image`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
const other = new Uint8Array([...PNG, 1, 2, 3]); const other = new Uint8Array([...PNG, 1, 2, 3]);
const second = await post(`/api/playlists/${id}/image`, { const second = await post<Playlist>(`/api/playlists/${id}/image`, {
auth: owner.auth, auth: owner.auth,
form: formWithFile(other), form: formWithFile(other),
}); });
assert(second.body.data.updatedAt !== first.body.data.updatedAt); assert(second.data.updatedAt !== first.data.updatedAt);
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other); assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other);
}); });

View File

@@ -1,5 +1,6 @@
// Follows, feeds, notifications and invites. // Follows, feeds, notifications and invites.
import { import {
type ApiResponse,
count, count,
createUser, createUser,
db, db,
@@ -12,10 +13,20 @@ import {
post, post,
test, test,
} from "./helpers.ts"; } from "./helpers.ts";
import { assertEquals, assertMatch } from "@std/assert"; import type {
Comment,
Dump,
FollowStatus,
InviteResponse,
Notification,
PaginatedData,
Playlist,
PublicUser,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, assertMatch } from "@std/assert";
function titles(res: { body: { data: { items: { title: string }[] } } }) { function titles(res: ApiResponse<PaginatedData<Dump>>): string[] {
return res.body.data.items.map((d) => d.title); return res.data.items.map((d) => d.title);
} }
test("following users: status, idempotence, no self-follow, one notification", async () => { test("following users: status, idempotence, no self-follow, one notification", async () => {
@@ -34,13 +45,13 @@ test("following users: status, idempotence, no self-follow, one notification", a
400, 400,
); );
assertEquals( assertEquals(
(await get("/api/follows/status", { auth: me.auth })).body.data, (await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data,
{ followedUserIds: [them.id], followedPlaylistIds: [] }, { followedUserIds: [them.id], followedPlaylistIds: [] },
); );
// Unfollow and follow again: still just the one notification. // Unfollow and follow again: still just the one notification.
await del(`/api/follows/users/${them.id}`, { auth: me.auth }); await del(`/api/follows/users/${them.id}`, { auth: me.auth });
assertEquals( assertEquals(
(await get("/api/follows/status", { auth: me.auth })).body.data (await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
.followedUserIds, .followedUserIds,
[], [],
); );
@@ -52,11 +63,13 @@ test("following users: status, idempotence, no self-follow, one notification", a
), ),
1, 1,
); );
const list = await get(`/api/users/${me.username}/followed-users`); const list = await get<PaginatedData<PublicUser>>(
assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [ `/api/users/${me.username}/followed-users`,
);
assertEquals(list.data.items.map((u) => u.id), [
them.id, them.id,
]); ]);
assertEquals(list.body.data.items[0].email, undefined); assertFalse("email" in list.data.items[0]);
}); });
test("following playlists: public ones only, and not your own notification", async () => { test("following playlists: public ones only, and not your own notification", async () => {
@@ -86,13 +99,15 @@ test("following playlists: public ones only, and not your own notification", asy
1, 1,
); );
assertEquals( assertEquals(
(await get(`/api/users/${me.username}/followed-playlists`)).body.data (await get<PaginatedData<Playlist>>(
`/api/users/${me.username}/followed-playlists`,
)).data
.items[0].id, .items[0].id,
pub.id, pub.id,
); );
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth });
assertEquals( assertEquals(
(await get("/api/follows/status", { auth: me.auth })).body.data (await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
.followedPlaylistIds, .followedPlaylistIds,
[], [],
); );
@@ -121,9 +136,11 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
insertDump({ userId: stranger.id, title: "stranger" }); insertDump({ userId: stranger.id, title: "stranger" });
await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); await post(`/api/follows/users/${followed.id}`, { auth: me.auth });
const feed = await get("/api/follows/feed/users", { auth: me.auth }); const feed = await get<PaginatedData<Dump>>("/api/follows/feed/users", {
auth: me.auth,
});
assertEquals(titles(feed), ["new", "old"]); assertEquals(titles(feed), ["new", "old"]);
assertEquals(feed.body.data.items[0].categoryIds, [music]); assertEquals(feed.data.items[0].categoryIds, [music]);
assertEquals( assertEquals(
titles( titles(
await get("/api/follows/feed/users?category=music", { auth: me.auth }), await get("/api/follows/feed/users?category=music", { auth: me.auth }),
@@ -132,8 +149,9 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
); );
// An unknown category filters everything out rather than nothing. // An unknown category filters everything out rather than nothing.
assertEquals( assertEquals(
(await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body (await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", {
.data, auth: me.auth,
})).data,
{ items: [], total: 0, hasMore: false }, { items: [], total: 0, hasMore: false },
); );
assertEquals((await get("/api/follows/feed/users")).status, 401); assertEquals((await get("/api/follows/feed/users")).status, 401);
@@ -155,14 +173,18 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth });
} }
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
const feed = await get("/api/follows/feed/playlists", { auth: me.auth }); const feed = await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
auth: me.auth,
});
assertEquals(titles(feed), ["shared"]); assertEquals(titles(feed), ["shared"]);
assertEquals(feed.body.data.total, 1); assertEquals(feed.data.total, 1);
// A playlist gone private drops out of the feed. // A playlist gone private drops out of the feed.
db.prepare(`UPDATE playlists SET is_public = 0;`).run(); db.prepare(`UPDATE playlists SET is_public = 0;`).run();
assertEquals( assertEquals(
(await get("/api/follows/feed/playlists", { auth: me.auth })).body.data (await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
auth: me.auth,
})).data
.total, .total,
0, 0,
); );
@@ -180,19 +202,22 @@ test("notifications: listed newest first, marked read one or all, never someone
}); });
await post(`/api/follows/users/${other.id}`, { auth: me.auth }); await post(`/api/follows/users/${other.id}`, { auth: me.auth });
const list = await get("/api/notifications", { auth: me.auth }); const list = await get<PaginatedData<Notification>>("/api/notifications", {
assertEquals(list.body.data.total, 2); auth: me.auth,
});
assertEquals(list.data.total, 2);
assertEquals( assertEquals(
list.body.data.items.map((n: { type: string }) => n.type), list.data.items.map((n) => n.type),
["dump_commented", "user_followed"], ["dump_commented", "user_followed"],
); );
assertEquals( assertEquals(
list.body.data.items.every((n: { read: boolean }) => !n.read), list.data.items.every((n) => !n.read),
true, true,
); );
const theirs = const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
(await get("/api/notifications", { auth: other.auth })).body.data auth: other.auth,
})).data
.items[0].id; .items[0].id;
assertEquals( assertEquals(
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth })) (await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
@@ -200,16 +225,19 @@ test("notifications: listed newest first, marked read one or all, never someone
204, 204,
); );
assertEquals( assertEquals(
(await get("/api/notifications", { auth: other.auth })).body.data.items[0] (await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data.items[0]
.read, .read,
false, false,
); );
const first = list.body.data.items[0].id; const first = list.data.items[0].id;
await patch(`/api/notifications/${first}/read`, { auth: me.auth }); await patch(`/api/notifications/${first}/read`, { auth: me.auth });
const after = const after = (await get<PaginatedData<Notification>>("/api/notifications", {
(await get("/api/notifications", { auth: me.auth })).body.data.items; auth: me.auth,
assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]); })).data.items;
assertEquals(after.map((n) => n.read), [true, false]);
assertEquals( assertEquals(
(await post("/api/notifications/read-all", { auth: me.auth })).status, (await post("/api/notifications/read-all", { auth: me.auth })).status,
@@ -232,7 +260,7 @@ test("mentions: notify the named, once per context, never the author or an email
const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` +
`me @${author.username}, mail x@${named.username}.com, ` + `me @${author.username}, mail x@${named.username}.com, ` +
`[@${named.username}](link) @nobody_known`; `[@${named.username}](link) @nobody_known`;
const c = await post(`/api/dumps/${dumpId}/comments`, { const c = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
auth: author.auth, auth: author.auth,
json: { body }, json: { body },
}); });
@@ -245,12 +273,12 @@ test("mentions: notify the named, once per context, never the author or an email
mentionerId: author.id, mentionerId: author.id,
mentionerUsername: author.username, mentionerUsername: author.username,
contextType: "comment", contextType: "comment",
contextId: c.body.data.id, contextId: c.data.id,
contextTitle: "Topic", contextTitle: "Topic",
dumpId, dumpId,
}); });
// Editing the comment doesn't notify a second time. // Editing the comment doesn't notify a second time.
await patch(`/api/comments/${c.body.data.id}`, { await patch(`/api/comments/${c.data.id}`, {
auth: author.auth, auth: author.auth,
json: { body: `again @${named.username}` }, json: { body: `again @${named.username}` },
}); });
@@ -265,9 +293,9 @@ test("mentions: notify the named, once per context, never the author or an email
test("invites: anyone signed in can make one; it validates until used or stale", async () => { test("invites: anyone signed in can make one; it validates until used or stale", async () => {
const user = await createUser(); const user = await createUser();
assertEquals((await post("/api/invites")).status, 401); assertEquals((await post("/api/invites")).status, 401);
const res = await post("/api/invites", { auth: user.auth }); const res = await post<InviteResponse>("/api/invites", { auth: user.auth });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const token = res.body.data.token; const token = res.data.token;
assertMatch(token, /^[A-Za-z0-9_-]{22}$/); assertMatch(token, /^[A-Za-z0-9_-]{22}$/);
assertEquals((await get(`/api/invites/${token}`)).status, 200); assertEquals((await get(`/api/invites/${token}`)).status, 200);
assertEquals((await get(`/api/invites/unknown`)).status, 404); assertEquals((await get(`/api/invites/unknown`)).status, 404);

View File

@@ -12,7 +12,12 @@ import {
test, test,
TEST_DIR, TEST_DIR,
} from "./helpers.ts"; } from "./helpers.ts";
import { assertEquals, assertStringIncludes } from "@std/assert"; import type {
AttachmentResponse,
Comment,
SelfUser,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789"); const BYTES = new TextEncoder().encode("0123456789");
@@ -214,12 +219,12 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async
test("attachments: upload an image, serve it back", async () => { test("attachments: upload an image, serve it back", async () => {
const user = await createUser(); const user = await createUser();
const res = await post("/api/attachments", { const res = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth, auth: user.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(res.status, 201); assertEquals(res.status, 201);
const id = res.body.data.id; const id = res.data.id;
const served = await get(`/api/attachments/${id}`); const served = await get(`/api/attachments/${id}`);
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);
@@ -265,13 +270,13 @@ test("attachments: refuses non-images, non-multipart and anonymous uploads", asy
test("attachments are claimed by the first text that links them", async () => { test("attachments are claimed by the first text that links them", async () => {
const user = await createUser(); const user = await createUser();
const { id: dumpId } = insertDump({ userId: user.id }); const { id: dumpId } = insertDump({ userId: user.id });
const up = await post("/api/attachments", { const up = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth, auth: user.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
const attachmentId = up.body.data.id; const attachmentId = up.data.id;
const link = `![img](/api/attachments/${attachmentId})`; const link = `![img](/api/attachments/${attachmentId})`;
const comment = await post(`/api/dumps/${dumpId}/comments`, { const comment = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
auth: user.auth, auth: user.auth,
json: { body: link }, json: { body: link },
}); });
@@ -279,26 +284,26 @@ test("attachments are claimed by the first text that links them", async () => {
(db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get( (db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get(
attachmentId, attachmentId,
) as { resource_id: string | null }).resource_id; ) as { resource_id: string | null }).resource_id;
assertEquals(owner(), comment.body.data.id); assertEquals(owner(), comment.data.id);
// A second text can't take it over. // A second text can't take it over.
await post(`/api/dumps/${dumpId}/comments`, { await post(`/api/dumps/${dumpId}/comments`, {
auth: user.auth, auth: user.auth,
json: { body: link }, json: { body: link },
}); });
assertEquals(owner(), comment.body.data.id); assertEquals(owner(), comment.data.id);
}); });
test("avatars: upload, serve, and 404 when there is none", async () => { test("avatars: upload, serve, and 404 when there is none", async () => {
const user = await createUser(); const user = await createUser();
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404); assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404); assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
const res = await post("/api/avatars/me", { const res = await post<SelfUser>("/api/avatars/me", {
auth: user.auth, auth: user.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.avatarMime, "image/png"); assertEquals(res.data.avatarMime, "image/png");
assertEquals(res.body.data.passwordHash, undefined); assertFalse("passwordHash" in res.data);
const served = await get(`/api/avatars/${user.id}`); const served = await get(`/api/avatars/${user.id}`);
assertEquals(served.status, 200); assertEquals(served.status, 200);
assertEquals(served.bytes, PNG); assertEquals(served.bytes, PNG);

View File

@@ -7,37 +7,43 @@ import {
patch, patch,
test, test,
} from "./helpers.ts"; } from "./helpers.ts";
import { assertEquals } from "@std/assert"; import type {
Invitee,
PublicUser,
SelfUser,
UserSuggestion,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse } from "@std/assert";
test("public profile: by username or id, without private fields", async () => { test("public profile: by username or id, without private fields", async () => {
const user = await createUser(); const user = await createUser();
for ( for (
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`] const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`]
) { ) {
const res = await get(path); const res = await get<PublicUser>(path);
assertEquals(res.status, 200, path); assertEquals(res.status, 200, path);
assertEquals(res.body.data.username, user.username); assertEquals(res.data.username, user.username);
assertEquals(res.body.data.email, undefined); assertFalse("email" in res.data);
assertEquals(res.body.data.passwordHash, undefined); assertFalse("passwordHash" in res.data);
} }
assertEquals((await get(`/api/users/no_one_here`)).status, 404); assertEquals((await get(`/api/users/no_one_here`)).status, 404);
const me = await get("/api/users/me", { auth: user.auth }); const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
assertEquals(me.body.data.email, user.email); assertEquals(me.data.email, user.email);
assertEquals(me.body.data.passwordHash, undefined); assertFalse("passwordHash" in me.data);
}); });
test("PATCH /api/users/me: description and email only", async () => { test("PATCH /api/users/me: description and email only", async () => {
const user = await createUser(); const user = await createUser();
const taken = await createUser(); const taken = await createUser();
const res = await patch("/api/users/me", { const res = await patch<PublicUser>("/api/users/me", {
auth: user.auth, auth: user.auth,
json: { description: "about me", email: "new@example.test" }, json: { description: "about me", email: "new@example.test" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.description, "about me"); assertEquals(res.data.description, "about me");
assertEquals(res.body.data.email, undefined); assertFalse("email" in res.data);
assertEquals( assertEquals(
(await get("/api/users/me", { auth: user.auth })).body.data.email, (await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email,
"new@example.test", "new@example.test",
); );
// A role, username or password in the body is ignored, not applied. // A role, username or password in the body is ignored, not applied.
@@ -102,13 +108,13 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => {
.status, .status,
400, 400,
); );
const res = await patch(`/api/users/${user.id}/role`, { const res = await patch<PublicUser>(`/api/users/${user.id}/role`, {
auth: admin.auth, auth: admin.auth,
json: { role: "moderator" }, json: { role: "moderator" },
}); });
assertEquals(res.status, 200); assertEquals(res.status, 200);
assertEquals(res.body.data.role, "moderator"); assertEquals(res.data.role, "moderator");
assertEquals(res.body.data.email, undefined); assertFalse("email" in res.data);
// The promoted user's existing token now carries moderator powers. // The promoted user's existing token now carries moderator powers.
const { id: dumpId } = insertDump({ userId: admin.id }); const { id: dumpId } = insertDump({ userId: admin.id });
assertEquals( assertEquals(
@@ -127,14 +133,14 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => {
test("GET /api/users/search: prefix match for mentions, capped at 8", async () => { test("GET /api/users/search: prefix match for mentions, capped at 8", async () => {
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` }); for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
await createUser({ username: "other_zed" }); await createUser({ username: "other_zed" });
const res = await get("/api/users/search?q=zed"); const res = await get<UserSuggestion[]>("/api/users/search?q=zed");
assertEquals(res.body.data.length, 8); assertEquals(res.data.length, 8);
assertEquals(res.body.data[0], { assertEquals(res.data[0], {
id: res.body.data[0].id, id: res.data[0].id,
username: "zed_0", username: "zed_0",
avatarMime: null, avatarMime: null,
}); });
assertEquals((await get("/api/users/search?q=")).body.data, []); assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []);
}); });
test("GET /api/users/:username/invitees: the invite tree below a user", async () => { test("GET /api/users/:username/invitees: the invite tree below a user", async () => {
@@ -147,13 +153,14 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async ()
invitedBy: child.id, invitedBy: child.id,
createdAt: "2024-01-02T00:00:00Z", createdAt: "2024-01-02T00:00:00Z",
}); });
const tree = (await get(`/api/users/${root.username}/invitees`)).body.data; const tree =
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data;
assertEquals( assertEquals(
tree.map(( tree.map((
n: { username: string; invitedById: string }, n: { username: string; invitedById: string },
) => [n.username, n.invitedById]), ) => [n.username, n.invitedById]),
[[child.username, root.id], [grandchild.username, child.id]], [[child.username, root.id], [grandchild.username, child.id]],
); );
const profile = await get(`/api/users/${child.username}`); const profile = await get<PublicUser>(`/api/users/${child.username}`);
assertEquals(profile.body.data.invitedByUsername, root.username); assertEquals(profile.data.invitedByUsername, root.username);
}); });

View File

@@ -18,17 +18,32 @@ import {
type TestUser, type TestUser,
} from "./helpers.ts"; } from "./helpers.ts";
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert"; import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
import type {
AuthTokens,
Dump,
ServerToClientMessage,
} from "../model/interfaces.ts";
// deno-lint-ignore no-explicit-any // deno.json's "dom" lib types WebSocket's second argument as protocols only,
type Msg = Record<string, any>; // hiding Deno's own overload, which takes handshake headers.
const DenoWebSocket = WebSocket as unknown as new (
url: string,
options: { headers: HeadersInit },
) => WebSocket;
type Msg = ServerToClientMessage;
type MsgOf<T extends Msg["type"]> = Extract<Msg, { type: T }>;
interface Client { interface Client {
ws: WebSocket; ws: WebSocket;
messages: Msg[]; messages: Msg[];
/** Resolves with the next unread message of `type` (and matching `where`). */ /** Resolves with the next unread message of `type` (and matching `where`). */
next(type: string, where?: (m: Msg) => boolean): Promise<Msg>; next<T extends Msg["type"]>(
type: T,
where?: (m: MsgOf<T>) => boolean,
): Promise<MsgOf<T>>;
/** Every message of `type` received so far. */ /** Every message of `type` received so far. */
all(type: string): Msg[]; all<T extends Msg["type"]>(type: T): MsgOf<T>[];
send(data: unknown): void; send(data: unknown): void;
/** Round-trips a ping, so anything the server sent before it has arrived. */ /** Round-trips a ping, so anything the server sent before it has arrived. */
sync(): Promise<void>; sync(): Promise<void>;
@@ -53,9 +68,9 @@ async function withServer(
const connect = (user?: TestUser, origin = "http://allowed.test") => { const connect = (user?: TestUser, origin = "http://allowed.test") => {
const query = user ? `?token=${user.token}` : ""; const query = user ? `?token=${user.token}` : "";
const ws = new WebSocket( const ws = new DenoWebSocket(
`ws://127.0.0.1:${port}/ws${query}`, `ws://127.0.0.1:${port}/ws${query}`,
{ headers: { origin } } as unknown as string[], { headers: { origin } },
); );
const messages: Msg[] = []; const messages: Msg[] = [];
const read = new Set<Msg>(); const read = new Set<Msg>();
@@ -71,11 +86,14 @@ async function withServer(
ws, ws,
messages, messages,
closed, closed,
async next(type, where = () => true) { async next<T extends Msg["type"]>(
type: T,
where: (m: MsgOf<T>) => boolean = () => true,
) {
const deadline = Date.now() + 3000; const deadline = Date.now() + 3000;
while (true) { while (true) {
const hit = messages.find((m) => const hit = messages.find((m): m is MsgOf<T> =>
!read.has(m) && m.type === type && where(m) !read.has(m) && m.type === type && where(m as MsgOf<T>)
); );
if (hit) { if (hit) {
read.add(hit); read.add(hit);
@@ -94,7 +112,8 @@ async function withServer(
]); ]);
} }
}, },
all: (type) => messages.filter((m) => m.type === type), all: <T extends Msg["type"]>(type: T) =>
messages.filter((m): m is MsgOf<T> => m.type === type),
send: (data) => send: (data) =>
ws.send(typeof data === "string" ? data : JSON.stringify(data)), ws.send(typeof data === "string" ? data : JSON.stringify(data)),
async sync() { async sync() {
@@ -157,7 +176,7 @@ wsTest("welcome: anonymous and signed-in state", async (connect) => {
const me = await connect(user); const me = await connect(user);
const [mine] = me.all("welcome"); const [mine] = me.all("welcome");
assertEquals(mine.myVotes, [dumpId]); assertEquals(mine.myVotes, [dumpId]);
assertEquals(mine.users.map((u: Msg) => u.username), [user.username]); assertEquals(mine.users.map((u) => u.username), [user.username]);
assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z"); assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z");
// The anonymous client hears about the arrival. // The anonymous client hears about the arrival.
const presence = await anon.next( const presence = await anon.next(
@@ -217,7 +236,6 @@ wsTest(
type: "vote_ack", type: "vote_ack",
dumpId, dumpId,
action: "cast", action: "cast",
success: true,
voteCount: 1, voteCount: 1,
}); });
const update = await watcher.next("votes_update"); const update = await watcher.next("votes_update");
@@ -480,11 +498,11 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
const x = await connect(other); const x = await connect(other);
// A public file dump arrives for everyone; a private one for no one. // A public file dump arrives for everyone; a private one for no one.
const pub = await post("/api/dumps", { const pub = await post<Dump>("/api/dumps", {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG), form: formWithFile(PNG),
}); });
assertEquals((await x.next("dump_created")).dump.id, pub.body.data.id); assertEquals((await x.next("dump_created")).dump.id, pub.data.id);
await post("/api/dumps", { await post("/api/dumps", {
auth: owner.auth, auth: owner.auth,
form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }), form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }),
@@ -493,16 +511,16 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
assertEquals(x.all("dump_created").length, 1); assertEquals(x.all("dump_created").length, 1);
// Going private is a deletion for everyone else; going public an arrival. // Going private is a deletion for everyone else; going public an arrival.
await put(`/api/dumps/${pub.body.data.id}`, { await put(`/api/dumps/${pub.data.id}`, {
auth: owner.auth, auth: owner.auth,
json: { isPrivate: true }, json: { isPrivate: true },
}); });
assertEquals((await x.next("dump_deleted")).dumpId, pub.body.data.id); assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id);
await put(`/api/dumps/${pub.body.data.id}`, { await put(`/api/dumps/${pub.data.id}`, {
auth: owner.auth, auth: owner.auth,
json: { isPrivate: false }, json: { isPrivate: false },
}); });
await x.next("dump_created", (m) => m.dump.id === pub.body.data.id); await x.next("dump_created", (m) => m.dump.id === pub.data.id);
// Comments on a private dump stay quiet. // Comments on a private dump stay quiet.
const priv = insertDump({ userId: owner.id, isPrivate: true }); const priv = insertDump({ userId: owner.id, isPrivate: true });
@@ -510,7 +528,7 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
auth: owner.auth, auth: owner.auth,
json: { body: "shh" }, json: { body: "shh" },
}); });
await post(`/api/dumps/${pub.body.data.id}/comments`, { await post(`/api/dumps/${pub.data.id}/comments`, {
auth: owner.auth, auth: owner.auth,
json: { body: "hi" }, json: { body: "hi" },
}); });
@@ -531,7 +549,7 @@ wsTest("a password change closes the user's sockets", async (connect) => {
const user = await createUser(); const user = await createUser();
const c = await connect(user); const c = await connect(user);
await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts
const res = await post("/api/users/me/change-password", { const res = await post<AuthTokens>("/api/users/me/change-password", {
auth: user.auth, auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "a-new-password" }, json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
}); });
@@ -541,8 +559,8 @@ wsTest("a password change closes the user's sockets", async (connect) => {
// Reconnecting with the old token is anonymous now. // Reconnecting with the old token is anonymous now.
const again = await connect(user); const again = await connect(user);
assertEquals(again.all("welcome")[0].users, []); assertEquals(again.all("welcome")[0].users, []);
assertExists(res.body.data.token); assertExists(res.data.token);
assertFalse(res.body.data.token === user.token); assertFalse(res.data.token === user.token);
}); });
wsTest( wsTest(

View File

@@ -31,11 +31,11 @@
"@std/assert": "jsr:@std/assert@^1.0.19", "@std/assert": "jsr:@std/assert@^1.0.19",
"@std/testing": "jsr:@std/testing@^1.0.21", "@std/testing": "jsr:@std/testing@^1.0.21",
"@oak/oak": "jsr:@oak/oak@^17.2.0", "@oak/oak": "jsr:@oak/oak@^17.2.0",
"@panva/jose": "jsr:@panva/jose@^6.2.3", "@panva/jose": "jsr:@panva/jose@^6.2.12",
"@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1",
"nodemailer": "npm:nodemailer@^9.0.1", "nodemailer": "npm:nodemailer@^10.0.10",
"marked": "npm:marked@^18.0.5", "marked": "npm:marked@^18.0.14",
"@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2" "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2"
}, },
"allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"] "allowScripts": ["npm:@swc/core@1.16.2"]
} }

877
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,41 +15,41 @@
"@fontsource-variable/saira": "5.3.0", "@fontsource-variable/saira": "5.3.0",
"@fontsource-variable/space-grotesk": "5.3.0", "@fontsource-variable/space-grotesk": "5.3.0",
"@fontsource/instrument-serif": "5.3.0", "@fontsource/instrument-serif": "5.3.0",
"@lingui/cli": "6.4.0", "@lingui/cli": "6.8.0",
"@lingui/conf": "6.4.0", "@lingui/conf": "6.8.0",
"@lingui/core": "6.4.0", "@lingui/core": "6.8.0",
"@lingui/format-po": "6.4.0", "@lingui/format-po": "6.8.0",
"@lingui/react": "6.4.0", "@lingui/react": "6.8.0",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@lingui/swc-plugin": "6.5.0", "@lingui/swc-plugin": "6.7.0",
"@vitejs/plugin-react-swc": "^4.3.1", "@vitejs/plugin-react-swc": "^4.3.3",
"emojibase-data": "17.0.0", "emojibase-data": "17.0.0",
"frimousse": "^0.3.0", "frimousse": "^0.4.0",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.89.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.0.1", "react-router": "^8.4.0",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@lingui/vite-plugin": "6.4.0", "@lingui/vite-plugin": "6.8.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.2", "@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7", "@testing-library/user-event": "^14.6.7",
"@types/node": "^26.0.1", "@types/node": "^26.6.3",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"eslint": "^10.6.0", "eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.7.0", "globals": "^17.12.0",
"jsdom": "^30.1.1", "jsdom": "^30.1.1",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.70.1",
"vite": "^8.1.0", "vite": "^8.3.1",
"vitest": "^5.0.2" "vitest": "^5.0.2"
} }
} }

View File

@@ -432,7 +432,8 @@
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
line-height: 1.5; line-height: 1.5;
transition: border-color var(--dur-slow), box-shadow var(--dur-slow), background-color var(--dur-slow); transition: border-color var(--dur-slow), box-shadow var(--dur-slow),
background-color var(--dur-slow);
outline: none; outline: none;
} }
@@ -710,13 +711,25 @@
} }
@keyframes dump-panel-in { @keyframes dump-panel-in {
from { opacity: 0; transform: translateX(12px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: none;
}
} }
@keyframes dump-panel-in-back { @keyframes dump-panel-in-back {
from { opacity: 0; transform: translateX(-12px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: none;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -912,8 +925,12 @@
} }
@keyframes dump-upload-slide { @keyframes dump-upload-slide {
0% { transform: translateX(-100%); } 0% {
100% { transform: translateX(250%); } transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -1593,7 +1610,8 @@ body.has-player {
opacity: 0; opacity: 0;
transform: translateY(1rem) scale(0.9); transform: translateY(1rem) scale(0.9);
pointer-events: none; pointer-events: none;
transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, background var(--dur-base) ease, transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease,
background var(--dur-base) ease,
top 0.4s cubic-bezier(0.4, 0, 0.2, 1); top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -1670,7 +1688,8 @@ body.has-fab .page-content {
opacity: 0; opacity: 0;
transform: translateY(1rem) scale(0.9); transform: translateY(1rem) scale(0.9);
pointer-events: none; pointer-events: none;
transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, background var(--dur-base) ease, transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease,
background var(--dur-base) ease,
top 0.4s cubic-bezier(0.4, 0, 0.2, 1); top 0.4s cubic-bezier(0.4, 0, 0.2, 1);
} }
@@ -1889,7 +1908,8 @@ body.has-player .chat-fab {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base); transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -2371,7 +2391,8 @@ body.has-player .chat-fab {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
transition: background var(--dur-base), border-color var(--dur-base), color var(--dur-base); transition: background var(--dur-base), border-color var(--dur-base), color
var(--dur-base);
} }
.profile-email-btn--save { .profile-email-btn--save {
@@ -2579,7 +2600,8 @@ body.has-player .chat-fab {
font-size: var(--text-sm); font-size: var(--text-sm);
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: border-color var(--dur-base), color var(--dur-base), background var(--dur-base); transition: border-color var(--dur-base), color var(--dur-base), background
var(--dur-base);
} }
.btn-border:hover { .btn-border:hover {
@@ -3598,7 +3620,8 @@ body.has-player .chat-fab {
font-weight: 600; font-weight: 600;
letter-spacing: var(--tab-tracking); letter-spacing: var(--tab-tracking);
text-transform: var(--tab-text-transform); text-transform: var(--tab-text-transform);
transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base); transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -3956,8 +3979,7 @@ body.has-player .chat-fab {
opacity: 1; opacity: 1;
} }
.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) .dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay,
.rich-content-play-overlay,
.rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay { .rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay {
transform: scale(1.2); transform: scale(1.2);
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
@@ -3965,8 +3987,7 @@ body.has-player .chat-fab {
} }
/* The one playing keeps its badge lit, in the accent, showing ⏸. */ /* The one playing keeps its badge lit, in the accent, showing ⏸. */
.dump-card-preview .rich-content-thumbnail-btn.is-playing .dump-card-preview .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay {
.rich-content-play-overlay {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-on-accent); color: var(--color-on-accent);
} }
@@ -5100,7 +5121,8 @@ body.has-player .chat-fab {
font-size: var(--text-sm); font-size: var(--text-sm);
padding: 0.25rem 0.7rem; padding: 0.25rem 0.7rem;
opacity: 0.7; opacity: 0.7;
transition: opacity var(--dur-base), border-color var(--dur-base), color var(--dur-base); transition: opacity var(--dur-base), border-color var(--dur-base), color
var(--dur-base);
} }
.playlist-edit-btn:hover, .playlist-edit-btn:hover,
@@ -5188,7 +5210,8 @@ body.has-player .chat-fab {
font-size: var(--text-xs); font-size: var(--text-xs);
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: opacity var(--dur-base), border-color var(--dur-base), color var(--dur-base); transition: opacity var(--dur-base), border-color var(--dur-base), color
var(--dur-base);
white-space: nowrap; white-space: nowrap;
} }
@@ -5496,7 +5519,8 @@ body.has-player .chat-fab {
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
transition: color var(--dur-fast), border-color var(--dur-fast), background var(--dur-fast); transition: color var(--dur-fast), border-color var(--dur-fast), background
var(--dur-fast);
} }
.comment-action-btn:hover { .comment-action-btn:hover {
@@ -5920,7 +5944,8 @@ body.has-player .chat-fab {
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
min-width: 0; min-width: 0;
transition: border-color var(--dur-base), transform var(--dur-base), box-shadow var(--dur-base); transition: border-color var(--dur-base), transform var(--dur-base),
box-shadow var(--dur-base);
} }
.journal-card:hover { .journal-card:hover {
@@ -6007,8 +6032,7 @@ body.has-player .chat-fab {
background: rgba(0, 0, 0, 0.75); background: rgba(0, 0, 0, 0.75);
} }
.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) .journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) .rich-content-play-overlay {
.rich-content-play-overlay {
transform: none; transform: none;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
} }
@@ -6801,8 +6825,16 @@ body.has-player .chat-fab {
} }
@keyframes tooltip-in { @keyframes tooltip-in {
from { opacity: 0; translate: 0 4px; scale: 0.96; } from {
to { opacity: 1; translate: 0 0; scale: 1; } opacity: 0;
translate: 0 4px;
scale: 0.96;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -6810,7 +6842,9 @@ body.has-player .chat-fab {
animation-name: tooltip-fade; animation-name: tooltip-fade;
} }
@keyframes tooltip-fade { @keyframes tooltip-fade {
from { opacity: 0; } from {
opacity: 0;
}
} }
} }
@@ -7172,7 +7206,8 @@ body.has-player .chat-fab {
font-family: inherit; font-family: inherit;
line-height: 1.4; line-height: 1.4;
cursor: pointer; cursor: pointer;
transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base), transform var(--dur-fast); transition: border-color var(--dur-base), background var(--dur-base), color
var(--dur-base), transform var(--dur-fast);
} }
.category-chip:hover:not(:disabled) { .category-chip:hover:not(:disabled) {
@@ -7282,7 +7317,8 @@ body.has-player .chat-fab {
font-size: var(--text-xs); font-size: var(--text-xs);
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: background var(--dur-base), color var(--dur-base), filter var(--dur-base); transition: background var(--dur-base), color var(--dur-base), filter
var(--dur-base);
} }
.category-editor-btn:hover:not(:disabled) { .category-editor-btn:hover:not(:disabled) {

View File

@@ -100,7 +100,10 @@ function CategoryRedirect() {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
const { search } = useLocation(); const { search } = useLocation();
return ( return (
<Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`} replace /> <Navigate
to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`}
replace
/>
); );
} }

View File

@@ -108,7 +108,8 @@ export function AppHeader(
<> <>
<header className="app-header app-header--has-center"> <header className="app-header app-header--has-center">
<Link to="/" className="app-header-brand"> <Link to="/" className="app-header-brand">
{SITE_EMOJI}<span className="app-header-brand-name"> {SITE_EMOJI}
<span className="app-header-brand-name">
{" "} {" "}
{SITE_NAME} {SITE_NAME}
</span> </span>
@@ -179,8 +180,10 @@ export function AppHeader(
</nav> </nav>
</header> </header>
{/* Stays mounted so the row can slide open and shut; `inert` keeps the {
collapsed input out of the tab order. */} /* Stays mounted so the row can slide open and shut; `inert` keeps the
collapsed input out of the tab order. */
}
<div <div
className={`header-search-reveal${ className={`header-search-reveal${
searchExpanded ? " header-search-reveal--open" : "" searchExpanded ? " header-search-reveal--open" : ""

Binary file not shown.

View File

@@ -29,7 +29,11 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const form = useApiForm<Values>({ const form = useApiForm<Values>({
mode: "onChange", mode: "onChange",
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
}); });
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => { const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {

View File

@@ -410,7 +410,11 @@ export function DumpCreateModal(
}) })
.catch(() => { .catch(() => {
if (signal.aborted) return; if (signal.aborted) return;
setUrlFetchState({ status: "done", reached: false, richContent: null }); setUrlFetchState({
status: "done",
reached: false,
richContent: null,
});
}); });
// Signed in, so that a private dump of the poster's own counts as a // Signed in, so that a private dump of the poster's own counts as a
@@ -845,8 +849,10 @@ export function DumpCreateModal(
</> </>
)} )}
{/* Hidden only while a link is being written: an empty {
panel offers both ways in at once, no mode to pick. */} /* Hidden only while a link is being written: an empty
panel offers both ways in at once, no mode to pick. */
}
{!(mode === "url" && url.trim()) && ( {!(mode === "url" && url.trim()) && (
<FileField<CreateValues> <FileField<CreateValues>
name="file" name="file"
@@ -874,8 +880,10 @@ export function DumpCreateModal(
) )
: ( : (
<> <>
{/* The link itself is a panel back, so name the thing {
these choices are about. */} /* The link itself is a panel back, so name the thing
these choices are about. */
}
<p className="dump-panel-recap" title={recap}> <p className="dump-panel-recap" title={recap}>
<span aria-hidden="true"> <span aria-hidden="true">
{mode === "url" ? "🔗" : "📎"} {mode === "url" ? "🔗" : "📎"}

View File

@@ -68,8 +68,10 @@ export function DumpFab() {
tabIndex={headerHidden ? 0 : -1} tabIndex={headerHidden ? 0 : -1}
onClick={() => setModalOpen(true)} onClick={() => setModalOpen(true)}
> >
{/* Symmetric SVG plus — centers exactly via flex, regardless of the {
theme's font metrics (a text "+" sits slightly off the box center). */} /* Symmetric SVG plus — centers exactly via flex, regardless of the
theme's font metrics (a text "+" sits slightly off the box center). */
}
<svg <svg
className="dump-fab-icon" className="dump-fab-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -1,7 +1,11 @@
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { TabBar } from "./TabBar.tsx"; import { TabBar } from "./TabBar.tsx";
import { CategorySwitcher } from "./CategorySwitcher.tsx"; import { CategorySwitcher } from "./CategorySwitcher.tsx";
@@ -20,7 +24,8 @@ export function FeedTabBar() {
const defaultTab: FeedTab = preferredTab === "followed" && !user const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB ? DEFAULT_FEED_TAB
: preferredTab; : preferredTab;
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "") && const active: FeedTab =
(FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
(feedTab !== "followed" || !!user) (feedTab !== "followed" || !!user)
? (feedTab as FeedTab) ? (feedTab as FeedTab)
: defaultTab; : defaultTab;

View File

@@ -6,7 +6,11 @@ import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDumpCardLink } from "../hooks/useDumpCardLink.ts"; import { useDumpCardLink } from "../hooks/useDumpCardLink.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts"; import {
hasQuote,
hasThumbnail,
type JournalShape,
} from "../utils/journalLayout.ts";
import { VoteButton } from "./VoteButton.tsx"; import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
@@ -123,7 +127,10 @@ export function JournalCard(
); );
const vote = ( const vote = (
<div className="journal-card-vote" onClick={(e) => e.stopPropagation()}> <div
className="journal-card-vote"
onClick={(e) => e.stopPropagation()}
>
<VoteButton <VoteButton
dumpId={dump.id} dumpId={dump.id}
count={voteCount} count={voteCount}
@@ -142,8 +149,7 @@ export function JournalCard(
</div> </div>
); );
const className = const className = `journal-card journal-card--${shape} journal-card--${mode}`;
`journal-card journal-card--${shape} journal-card--${mode}`;
if (mode === "image") { if (mode === "image") {
// feature shows its note; tall/wide/square keep the image as the subject. // feature shows its note; tall/wide/square keep the image as the subject.

View File

@@ -146,9 +146,7 @@ export function Markdown(
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={REMARK_PLUGINS} remarkPlugins={REMARK_PLUGINS}
components={inline components={inline ? INLINE_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
? INLINE_MARKDOWN_COMPONENTS
: MARKDOWN_COMPONENTS}
> >
{children} {children}
</ReactMarkdown> </ReactMarkdown>

View File

@@ -172,7 +172,8 @@ function SeekBar(
step={0.01} step={0.01}
value={current} value={current}
// Pointer events, so a finger holds the thumb as a mouse does. // Pointer events, so a finger holds the thumb as a mouse does.
onPointerDown={() => onDragChange(true)} onPointerDown={() =>
onDragChange(true)}
onPointerUp={() => onDragChange(false)} onPointerUp={() => onDragChange(false)}
onPointerCancel={() => onDragChange(false)} onPointerCancel={() => onDragChange(false)}
onChange={(e) => onSeek(Number(e.target.value))} onChange={(e) => onSeek(Number(e.target.value))}

View File

@@ -29,8 +29,14 @@ interface ThumbnailProps {
* an empty framed box and no signal anywhere that a load had failed. * an empty framed box and no signal anywhere that a load had failed.
*/ */
export default function Thumbnail( export default function Thumbnail(
{ src, alt = "", className, loading = "lazy", placeholder, placeholderClassName }: {
ThumbnailProps, src,
alt = "",
className,
loading = "lazy",
placeholder,
placeholderClassName,
}: ThumbnailProps,
) { ) {
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">( const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
"direct", "direct",

View File

@@ -40,8 +40,9 @@ export default function ThumbnailPlaceholder(
setIconFailed(false); setIconFailed(false);
} }
const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as const style = {
React.CSSProperties; "--thumb-tint": tintFor({ accentColor, url, seed }),
} as React.CSSProperties;
return ( return (
<div <div

View File

@@ -12,5 +12,7 @@ export function FormError({ title }: { title?: string }) {
const { errors } = useFormState(); const { errors } = useFormState();
const message = errors.root?.message; const message = errors.root?.message;
if (!message) return null; if (!message) return null;
return <ErrorCard title={title ?? t`Something went wrong`} message={message} />; return (
<ErrorCard title={title ?? t`Something went wrong`} message={message} />
);
} }

View File

@@ -10,7 +10,8 @@ import {
import { friendlyFetchError } from "../../utils/apiError.ts"; import { friendlyFetchError } from "../../utils/apiError.ts";
import { parseAPIResponse } from "../../model.ts"; import { parseAPIResponse } from "../../model.ts";
export interface UseApiFormReturn<T extends FieldValues> extends UseFormReturn<T> { export interface UseApiFormReturn<T extends FieldValues>
extends UseFormReturn<T> {
/** /**
* Wraps an async submit handler with the shared API-form lifecycle: clears the * Wraps an async submit handler with the shared API-form lifecycle: clears the
* previous root error, runs the handler while RHF keeps `formState.isSubmitting` * previous root error, runs the handler while RHF keeps `formState.isSubmitting`

View File

@@ -1,4 +1,10 @@
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { import {
CategoriesContext, CategoriesContext,
type CategoriesContextValue, type CategoriesContextValue,

View File

@@ -10,7 +10,8 @@ export interface FollowContextValue {
isLoaded: boolean; isLoaded: boolean;
} }
export const FollowContext = createContext<FollowContextValue>({ /** What a consumer outside any provider gets. */
export const defaultFollowValue: FollowContextValue = {
followedUserIds: new Set(), followedUserIds: new Set(),
followedPlaylistIds: new Set(), followedPlaylistIds: new Set(),
followUser: async () => {}, followUser: async () => {},
@@ -18,4 +19,8 @@ export const FollowContext = createContext<FollowContextValue>({
followPlaylist: async () => {}, followPlaylist: async () => {},
unfollowPlaylist: async () => {}, unfollowPlaylist: async () => {},
isLoaded: false, isLoaded: false,
}); };
export const FollowContext = createContext<FollowContextValue>(
defaultFollowValue,
);

View File

@@ -111,7 +111,8 @@ export interface PlayerContextValue {
onError(): void; onError(): void;
} }
export const PlayerContext = createContext<PlayerContextValue>({ /** What a consumer outside any provider gets. */
export const defaultPlayerValue: PlayerContextValue = {
current: null, current: null,
playing: false, playing: false,
currentTime: 0, currentTime: 0,
@@ -137,4 +138,8 @@ export const PlayerContext = createContext<PlayerContextValue>({
onTimeUpdate: () => {}, onTimeUpdate: () => {},
onEnded: () => {}, onEnded: () => {},
onError: () => {}, onError: () => {},
}); };
export const PlayerContext = createContext<PlayerContextValue>(
defaultPlayerValue,
);

View File

@@ -129,7 +129,8 @@ export interface WSContextValue {
setChatOpen: (open: boolean) => void; setChatOpen: (open: boolean) => void;
} }
export const WSContext = createContext<WSContextValue>({ /** What a consumer outside any provider gets. */
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting", wsStatus: "connecting",
wsErrorMessage: null, wsErrorMessage: null,
onlineUsers: [], onlineUsers: [],
@@ -172,4 +173,6 @@ export const WSContext = createContext<WSContextValue>({
reconcileChat: () => {}, reconcileChat: () => {},
setChatTyping: () => {}, setChatTyping: () => {},
setChatOpen: () => {}, setChatOpen: () => {},
}); };
export const WSContext = createContext<WSContextValue>(defaultWSValue);

View File

@@ -822,7 +822,8 @@ export function WSProvider({ children }: WSProviderProps) {
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingVote]); }, [schedulePendingVote]);
const castCommentLike = useCallback((commentId: string, shownCount?: number) => { const castCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only // Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing. // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
@@ -849,14 +850,20 @@ export function WSProvider({ children }: WSProviderProps) {
if (socketRef.current?.readyState === WebSocket.OPEN) { if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send( socketRef.current.send(
JSON.stringify( JSON.stringify(
{ type: "comment_like_cast", commentId } satisfies OutgoingWSMessage, {
type: "comment_like_cast",
commentId,
} satisfies OutgoingWSMessage,
), ),
); );
} }
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]); },
[schedulePendingLike],
);
const removeCommentLike = useCallback((commentId: string, shownCount?: number) => { const removeCommentLike = useCallback(
(commentId: string, shownCount?: number) => {
// Optimistic update. Until a live event arrives there's no count here, only // Optimistic update. Until a live event arrives there's no count here, only
// the one the caller got over REST and is showing. // the one the caller got over REST and is showing.
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
@@ -894,7 +901,9 @@ export function WSProvider({ children }: WSProviderProps) {
); );
} }
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
}, [schedulePendingLike]); },
[schedulePendingLike],
);
const injectDump = useCallback((dump: Dump) => { const injectDump = useCallback((dump: Dump) => {
setRecentDumps((prev) => { setRecentDumps((prev) => {

View File

@@ -10,7 +10,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -18,7 +19,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/saira/files/saira-latin-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/saira/files/saira-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@font-face { @font-face {
@@ -26,7 +28,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -34,7 +37,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/saira/files/saira-latin-wght-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/saira/files/saira-latin-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@@ -44,7 +48,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 300 700; font-weight: 300 700;
src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-ext-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -52,7 +57,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 300 700; font-weight: 300 700;
src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@@ -62,7 +68,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-normal.woff2) format('woff2'); src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-normal.woff2)
format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -70,7 +77,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2) format('woff2'); src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@font-face { @font-face {
@@ -78,7 +86,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-italic.woff2) format('woff2'); src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-italic.woff2)
format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -86,7 +95,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2) format('woff2'); src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@@ -96,7 +106,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 800; font-weight: 100 800;
src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -104,7 +115,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 800; font-weight: 100 800;
src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@font-face { @font-face {
@@ -112,7 +124,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 800; font-weight: 100 800;
src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -120,7 +133,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 800; font-weight: 100 800;
src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@@ -130,7 +144,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -138,7 +153,8 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2) format('woff2-variations'); src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@font-face { @font-face {
@@ -146,7 +162,8 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
@@ -154,6 +171,7 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2) format('woff2-variations'); src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }

View File

@@ -64,8 +64,11 @@ export function useAnchoredFloat(
const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2); const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2);
const content = Math.max( const content = Math.max(
minContentHeight, minContentHeight,
Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) - Math.min(
chromeHeight), maxContentHeight,
(placeAbove ? roomAbove : roomBelow) -
chromeHeight,
),
); );
// Anchoring by `bottom` when above means the panel's own height never // Anchoring by `bottom` when above means the panel's own height never
@@ -101,7 +104,14 @@ export function useAnchoredFloat(
globalThis.removeEventListener("resize", place); globalThis.removeEventListener("resize", place);
observer.disconnect(); observer.disconnect();
}; };
}, [open, anchorRef, width, chromeHeight, minContentHeight, maxContentHeight]); }, [
open,
anchorRef,
width,
chromeHeight,
minContentHeight,
maxContentHeight,
]);
// Gated on `open` rather than cleared on close: the stale value is never // Gated on `open` rather than cleared on close: the stale value is never
// painted, since the layout effect re-places the panel before the next paint. // painted, since the layout effect re-places the panel before the next paint.

View File

@@ -116,8 +116,14 @@ export const useAuth = () => {
}); });
// Mirrors what `fetch` throws on a network failure, so the shared // Mirrors what `fetch` throws on a network failure, so the shared
// `friendlyFetchError` path renders it the same way. // `friendlyFetchError` path renders it the same way.
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch"))); xhr.addEventListener(
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch"))); "error",
() => reject(new TypeError("Failed to fetch")),
);
xhr.addEventListener(
"abort",
() => reject(new TypeError("Failed to fetch")),
);
xhr.send(body); xhr.send(body);
}); });

View File

@@ -1,5 +1,9 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../config/feedTabs.ts";
const STORAGE_KEY = "default-feed-tab"; const STORAGE_KEY = "default-feed-tab";

View File

@@ -31,9 +31,7 @@ let badge = 0;
function applyTitle() { function applyTitle() {
const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, ""); const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, "");
document.title = badge > 0 document.title = badge > 0 ? `(${badge > 99 ? "99+" : badge}) ${base}` : base;
? `(${badge > 99 ? "99+" : badge}) ${base}`
: base;
} }
/** /**

View File

@@ -1,4 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { insideCode } from "../utils/editorText.ts"; import { insideCode } from "../utils/editorText.ts";
@@ -44,6 +50,18 @@ function getMentionQuery(
return { query: match[1], start: match.index }; return { query: match[1], start: match.index };
} }
/** The parts of a keydown the mention list reads. */
export type MentionKey = Pick<
KeyboardEvent,
| "key"
| "ctrlKey"
| "metaKey"
| "shiftKey"
| "altKey"
| "preventDefault"
| "stopPropagation"
>;
export function useMentionAutocomplete( export function useMentionAutocomplete(
/** Replaces `[start, end)` of the textarea's value, with a space after /** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */ * when `word` is set; see TextEditor. */
@@ -145,7 +163,7 @@ export function useMentionAutocomplete(
/** Returns whether it handled the key. */ /** Returns whether it handled the key. */
const handleMentionKeyDown = useCallback( const handleMentionKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => { (e: MentionKey): boolean => {
const s = stateRef.current; const s = stateRef.current;
if (!s.open || s.results.length === 0) return false; if (!s.open || s.results.length === 0) return false;
if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {

View File

@@ -1,5 +1,5 @@
import { import {
type Ref, type RefCallback,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@@ -41,7 +41,7 @@ interface UseUserDumpFeedResult {
state: State; state: State;
setState: React.Dispatch<React.SetStateAction<State>>; setState: React.Dispatch<React.SetStateAction<State>>;
setItems: (fn: (prev: Dump[]) => Dump[]) => void; setItems: (fn: (prev: Dump[]) => Dump[]) => void;
sentinelRef: Ref<HTMLDivElement>; sentinelRef: RefCallback<HTMLDivElement>;
} }
/** /**

View File

@@ -66,10 +66,13 @@ const PAUSED = 2;
const RESUME_WINDOW_MS = 4000; const RESUME_WINDOW_MS = 4000;
const MAX_RESUMES = 3; const MAX_RESUMES = 3;
interface YTGlobal { // The globals YouTube's IFrame API script sets and calls.
declare global {
interface Window {
YT?: YTNamespace; YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void; onYouTubeIframeAPIReady?: () => void;
} }
}
const API_SRC = "https://www.youtube.com/iframe_api"; const API_SRC = "https://www.youtube.com/iframe_api";
@@ -79,7 +82,7 @@ let apiPromise: Promise<YTNamespace> | null = null;
function loadPlayerApi(): Promise<YTNamespace> { function loadPlayerApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise; if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => { apiPromise = new Promise((resolve, reject) => {
const w = globalThis as unknown as YTGlobal; const w = window;
if (w.YT?.Player) { if (w.YT?.Player) {
resolve(w.YT); resolve(w.YT);
return; return;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -145,7 +145,9 @@ export function DumpEdit() {
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => null); const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`); throw new Error(
body?.error?.message ?? t`Delete failed (${res.status})`,
);
} }
} catch (err) { } catch (err) {
setDeleteError(friendlyFetchError(err)); setDeleteError(friendlyFetchError(err));

View File

@@ -11,7 +11,11 @@ import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import {
DEFAULT_FEED_TAB,
FEED_TABS,
type FeedTab,
} from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -93,7 +97,8 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
: preferredTab; : preferredTab;
// "followed" needs a session: a guest there (a shared link, or signed out // "followed" needs a session: a guest there (a shared link, or signed out
// on it) gets the default feed rather than an empty page. // on it) gets the default feed rather than an empty page.
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") && const tab: FeedTab =
(FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
(rawTab !== "followed" || !!user) (rawTab !== "followed" || !!user)
? (rawTab as FeedTab) ? (rawTab as FeedTab)
: defaultTab; : defaultTab;
@@ -287,7 +292,9 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
const categoryId = category?.id; const categoryId = category?.id;
const inScope = useCallback( const inScope = useCallback(
(d: Dump) => (d: Dump) =>
categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true, categorySlug
? !!categoryId && !!d.categoryIds?.includes(categoryId)
: true,
[categorySlug, categoryId], [categorySlug, categoryId],
); );
@@ -322,7 +329,10 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
<AppHeader <AppHeader
centerSlot={ centerSlot={
<div className="header-center-slot"> <div className="header-center-slot">
<PresenceRow fitWithin=".app-header-center" fitBeside=".feed-sort" /> <PresenceRow
fitWithin=".app-header-center"
fitBeside=".feed-sort"
/>
<FeedTabBar /> <FeedTabBar />
</div> </div>
} }

View File

@@ -555,7 +555,11 @@ export function PlaylistDetail() {
prev.status === "loaded" prev.status === "loaded"
? { ? {
...prev, ...prev,
playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps }, playlist: {
...prev.playlist,
...updated,
dumps: prev.playlist.dumps,
},
} }
: prev : prev
); );
@@ -873,8 +877,10 @@ function PlaylistEditForm(
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
{/* `display: contents` keeps the image-beside-content flex layout while {
still giving us a real <form> for submit + Enter handling. */} /* `display: contents` keeps the image-beside-content flex layout while
still giving us a real <form> for submit + Enter handling. */
}
<form style={{ display: "contents" }} onSubmit={onSubmit}> <form style={{ display: "contents" }} onSubmit={onSubmit}>
<Controller <Controller
name="image" name="image"

View File

@@ -69,8 +69,12 @@ export function UserRegister() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify( body: JSON.stringify(
{ username, password, inviteToken: token, email } satisfies {
RegisterRequest, username,
password,
inviteToken: token,
email,
} satisfies RegisterRequest,
), ),
}), }),
); );

View File

@@ -41,10 +41,12 @@ export function JournalFeed(
return ( return (
<> <>
{/* Each sub-block is its own grid, with a themable <hr> between them. {
/* Each sub-block is its own grid, with a themable <hr> between them.
A break sits between two grids rather than inside one because a grid A break sits between two grids rather than inside one because a grid
item would be stretched to the 9rem row track — out here its height item would be stretched to the 9rem row track — out here its height
is its own, down to nothing for a theme that wants no separation. */} is its own, down to nothing for a theme that wants no separation. */
}
<div className="journal-feed"> <div className="journal-feed">
{blocks.map((block, index) => ( {blocks.map((block, index) => (
<Fragment key={block.key}> <Fragment key={block.key}>

View File

@@ -35,14 +35,34 @@ describe("ChatModal: history", () => {
test("shows the latest messages under a day separator, grouping runs", async () => { test("shows the latest messages under a day separator, grouping runs", async () => {
const t0 = Date.now() - 60_000; const t0 = Date.now() - 60_000;
await setup([ await setup([
makeChatMessage({ id: "1", userId: "ann", authorUsername: "ann", body: "one", createdAt: new Date(t0) }), makeChatMessage({
makeChatMessage({ id: "2", userId: "ann", authorUsername: "ann", body: "two", createdAt: new Date(t0 + 1000) }), id: "1",
makeChatMessage({ id: "3", userId: "bob", authorUsername: "bob", body: "three", createdAt: new Date(t0 + 2000) }), userId: "ann",
authorUsername: "ann",
body: "one",
createdAt: new Date(t0),
}),
makeChatMessage({
id: "2",
userId: "ann",
authorUsername: "ann",
body: "two",
createdAt: new Date(t0 + 1000),
}),
makeChatMessage({
id: "3",
userId: "bob",
authorUsername: "bob",
body: "three",
createdAt: new Date(t0 + 2000),
}),
]); ]);
expect(screen.getByText("Today")).toBeInTheDocument(); expect(screen.getByText("Today")).toBeInTheDocument();
// "two" continues ann's run: no second author link. // "two" continues ann's run: no second author link.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1); expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
expect(screen.getByText("two").closest(".chat-message")).toHaveClass("chat-message--grouped"); expect(screen.getByText("two").closest(".chat-message")).toHaveClass(
"chat-message--grouped",
);
expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50"); expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50");
}); });
@@ -57,27 +77,55 @@ describe("ChatModal: history", () => {
}); });
test("a full page offers older messages", async () => { test("a full page offers older messages", async () => {
const page = Array.from({ length: 50 }, (_, i) => const page = Array.from(
makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) }) { length: 50 },
(_, i) =>
makeChatMessage({
id: `m${i + 10}`,
body: `msg ${i + 10}`,
createdAt: new Date(Date.now() - (50 - i) * 1000),
}),
); );
route("GET /api/chat", (req) => route(
"GET /api/chat",
(req) =>
req.url.searchParams.has("before") req.url.searchParams.has("before")
? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })]) ? wire([
: wire(page)); makeChatMessage({
id: "m0",
body: "the oldest",
createdAt: new Date(Date.now() - 999_000),
}),
])
: wire(page),
);
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<ChatModal onClose={() => {}} />, { session: me }); renderApp(<ChatModal onClose={() => {}} />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load older messages" })); await user.click(
await screen.findByRole("button", { name: "Load older messages" }),
);
await screen.findByText("the oldest"); await screen.findByText("the oldest");
const req = calls("GET /api/chat").find((r) => r.url.searchParams.has("before"))!; const req = calls("GET /api/chat").find((r) =>
r.url.searchParams.has("before")
)!;
expect(req.url.searchParams.get("beforeId")).toBe("m10"); expect(req.url.searchParams.get("beforeId")).toBe("m10");
expect(screen.queryByRole("button", { name: "Load older messages" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Load older messages" })).not
.toBeInTheDocument();
}); });
test("the 'new' divider sits before the first unread message from someone else", async () => { test("the 'new' divider sits before the first unread message from someone else", async () => {
const readAt = new Date(Date.now() - 30_000); const readAt = new Date(Date.now() - 30_000);
await setup([ await setup([
makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }), makeChatMessage({
makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }), id: "old",
body: "seen",
createdAt: new Date(Date.now() - 60_000),
}),
makeChatMessage({
id: "new1",
body: "unseen",
createdAt: new Date(Date.now() - 10_000),
}),
], { welcome: { chatReadAt: readAt.toISOString() } }); ], { welcome: { chatReadAt: readAt.toISOString() } });
const divider = document.querySelector(".chat-new-sep")!; const divider = document.querySelector(".chat-new-sep")!;
expect(divider).toHaveTextContent("New"); expect(divider).toHaveTextContent("New");
@@ -91,7 +139,10 @@ describe("ChatModal: history", () => {
test("tells the server the chat is open, and closed", async () => { test("tells the server the chat is open, and closed", async () => {
const { socket } = await setup(); const { socket } = await setup();
expect(sent(socket, "chat_focus")).toContainEqual({ type: "chat_focus", open: true }); expect(sent(socket, "chat_focus")).toContainEqual({
type: "chat_focus",
open: true,
});
}); });
}); });
@@ -106,7 +157,15 @@ describe("ChatModal: sending", () => {
socket.receive({ socket.receive({
type: "chat_message", type: "chat_message",
nonce: send.nonce, nonce: send.nonce,
message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })), message: wire(
makeChatMessage({
id: "echo",
userId: "me",
authorUsername: "me",
body: "hello there",
createdAt: new Date(),
}),
),
}); });
expect(screen.queryByText("Sending…")).not.toBeInTheDocument(); expect(screen.queryByText("Sending…")).not.toBeInTheDocument();
expect(screen.getByText("hello there")).toBeInTheDocument(); expect(screen.getByText("hello there")).toBeInTheDocument();
@@ -130,13 +189,25 @@ describe("ChatModal: sending", () => {
const { user, socket, composer } = await setup(); const { user, socket, composer } = await setup();
await user.type(composer, "oops{Enter}"); await user.type(composer, "oops{Enter}");
const [send] = sent(socket, "chat_send"); const [send] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" }); socket.receive({
const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement; type: "error",
scope: "chat",
nonce: send.nonce,
message: "Slow down",
});
const failed = screen.getByText("Slow down").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(failed).getByRole("button", { name: "Retry" })); await user.click(within(failed).getByRole("button", { name: "Retry" }));
expect(sent(socket, "chat_send")).toHaveLength(2); expect(sent(socket, "chat_send")).toHaveLength(2);
const [, retry] = sent(socket, "chat_send"); const [, retry] = sent(socket, "chat_send");
socket.receive({ type: "error", scope: "chat", nonce: retry.nonce, message: "Still no" }); socket.receive({
type: "error",
scope: "chat",
nonce: retry.nonce,
message: "Still no",
});
await user.type(composer, "draft"); await user.type(composer, "draft");
await user.click(screen.getByRole("button", { name: "Edit" })); await user.click(screen.getByRole("button", { name: "Edit" }));
expect(composer).toHaveValue("draft\n\noops"); expect(composer).toHaveValue("draft\n\noops");
@@ -144,21 +215,32 @@ describe("ChatModal: sending", () => {
}); });
test("typing is announced, and its end too", async () => { test("typing is announced, and its end too", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true }); vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "Date"],
shouldAdvanceTime: true,
});
try { try {
const { socket, composer } = await setup(); const { socket, composer } = await setup();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(composer, "abc"); await user.type(composer, "abc");
expect(sent(socket, "chat_typing")).toEqual([{ type: "chat_typing", typing: true }]); expect(sent(socket, "chat_typing")).toEqual([{
type: "chat_typing",
typing: true,
}]);
act(() => vi.advanceTimersByTime(3_600)); act(() => vi.advanceTimersByTime(3_600));
expect(sent(socket, "chat_typing").at(-1)).toEqual({ type: "chat_typing", typing: false }); expect(sent(socket, "chat_typing").at(-1)).toEqual({
type: "chat_typing",
typing: false,
});
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
test("the draft and reply outlive the chat", async () => { test("the draft and reply outlive the chat", async () => {
const first = await setup([makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" })]); const first = await setup([
makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" }),
]);
await first.user.type(first.composer, "half a thought"); await first.user.type(first.composer, "half a thought");
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!); const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
expect(stored.draft).toBe("half a thought"); expect(stored.draft).toBe("half a thought");
@@ -167,38 +249,68 @@ describe("ChatModal: sending", () => {
describe("ChatModal: replies, edits, deletes", () => { describe("ChatModal: replies, edits, deletes", () => {
const theirs = () => const theirs = () =>
makeChatMessage({ id: "t1", userId: "ann", authorUsername: "ann", body: "ask me **anything**", createdAt: new Date(Date.now() - 20_000) }); makeChatMessage({
id: "t1",
userId: "ann",
authorUsername: "ann",
body: "ask me **anything**",
createdAt: new Date(Date.now() - 20_000),
});
const mine = () => const mine = () =>
makeChatMessage({ id: "m1", userId: "me", authorUsername: "me", body: "my words", createdAt: new Date(Date.now() - 10_000) }); makeChatMessage({
id: "m1",
userId: "me",
authorUsername: "me",
body: "my words",
createdAt: new Date(Date.now() - 10_000),
});
test("replying: the bar, the reference, and Escape cancelling just the reply", async () => { test("replying: the bar, the reference, and Escape cancelling just the reply", async () => {
const { user, socket, composer, onClose } = await setup([theirs()]); const { user, socket, composer, onClose } = await setup([theirs()]);
const msg = screen.getByText("anything").closest(".chat-message")! as HTMLElement; const msg = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(msg).getByRole("button", { name: "Reply" })); await user.click(within(msg).getByRole("button", { name: "Reply" }));
expect(screen.getByText("Replying to")).toBeInTheDocument(); expect(screen.getByText("Replying to")).toBeInTheDocument();
expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent("ask me anything"); expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent(
"ask me anything",
);
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByText("Replying to")).not.toBeInTheDocument(); expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.click(within(msg).getByRole("button", { name: "Reply" })); await user.click(within(msg).getByRole("button", { name: "Reply" }));
await user.type(composer, "sure{Enter}"); await user.type(composer, "sure{Enter}");
expect(sent(socket, "chat_send")[0]).toMatchObject({ body: "sure", replyToId: "t1" }); expect(sent(socket, "chat_send")[0]).toMatchObject({
body: "sure",
replyToId: "t1",
});
}); });
test("only your own messages can be edited and deleted", async () => { test("only your own messages can be edited and deleted", async () => {
await setup([theirs(), mine()]); await setup([theirs(), mine()]);
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement; const t = screen.getByText("anything").closest(
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; ".chat-message",
expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); )! as HTMLElement;
const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).queryByRole("button", { name: "Edit" })).not
.toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument(); expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument();
expect(within(m).getByRole("button", { name: "Delete" })).toBeInTheDocument(); expect(within(m).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
}); });
test("moderators can delete anyone's", async () => { test("moderators can delete anyone's", async () => {
await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse }); await setup([theirs()], {
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement; session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse,
expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument(); });
const t = screen.getByText("anything").closest(
".chat-message",
)! as HTMLElement;
expect(within(t).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
}); });
test("Up in an empty composer edits your last message; Enter saves", async () => { test("Up in an empty composer edits your last message; Enter saves", async () => {
@@ -208,33 +320,51 @@ describe("ChatModal: replies, edits, deletes", () => {
const box = await screen.findByDisplayValue("my words"); const box = await screen.findByDisplayValue("my words");
await user.clear(box); await user.clear(box);
await user.type(box, "better words{Enter}"); await user.type(box, "better words{Enter}");
expect(sent(socket, "chat_edit")).toEqual([{ type: "chat_edit", id: "m1", body: "better words" }]); expect(sent(socket, "chat_edit")).toEqual([{
type: "chat_edit",
id: "m1",
body: "better words",
}]);
expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument(); expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument();
}); });
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => { test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
const { user, socket, onClose } = await setup([mine()]); const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Edit" })); await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(sent(socket, "chat_edit")).toHaveLength(0); expect(sent(socket, "chat_edit")).toHaveLength(0);
await user.click(within(m).getByRole("button", { name: "Edit" })); await user.click(within(m).getByRole("button", { name: "Edit" }));
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save" })).not
.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
test("deleting asks first; Escape on the question keeps the chat open", async () => { test("deleting asks first; Escape on the question keeps the chat open", async () => {
const { user, socket, onClose } = await setup([mine()]); const { user, socket, onClose } = await setup([mine()]);
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; const m = screen.getByText("my words").closest(
".chat-message",
)! as HTMLElement;
await user.click(within(m).getByRole("button", { name: "Delete" })); await user.click(within(m).getByRole("button", { name: "Delete" }));
expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?"); expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Delete this message?",
);
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.click(within(m).getByRole("button", { name: "Delete" })); await user.click(within(m).getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" })); await user.click(
expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]); within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete",
}),
);
expect(sent(socket, "chat_delete")).toEqual([{
type: "chat_delete",
id: "m1",
}]);
socket.receive({ type: "chat_message_deleted", id: "m1" }); socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(screen.queryByText("my words")).not.toBeInTheDocument(); expect(screen.queryByText("my words")).not.toBeInTheDocument();
}); });
@@ -242,10 +372,17 @@ describe("ChatModal: replies, edits, deletes", () => {
test("a reply to a deleted message says so", async () => { test("a reply to a deleted message says so", async () => {
const { socket } = await setup([ const { socket } = await setup([
theirs(), theirs(),
makeChatMessage({ id: "r", body: "a reply", replyToId: "t1", replyToAuthor: "ann", replyToBody: "ask me" }), makeChatMessage({
id: "r",
body: "a reply",
replyToId: "t1",
replyToAuthor: "ann",
replyToBody: "ask me",
}),
]); ]);
socket.receive({ type: "chat_message_deleted", id: "t1" }); socket.receive({ type: "chat_message_deleted", id: "t1" });
expect(screen.getByText("deleted message").closest("button")).toBeDisabled(); expect(screen.getByText("deleted message").closest("button"))
.toBeDisabled();
}); });
}); });
@@ -254,19 +391,31 @@ describe("ChatModal: live", () => {
const { socket } = await setup(); const { socket } = await setup();
socket.receive({ socket.receive({
type: "chat_message", type: "chat_message",
message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })), message: wire(
makeChatMessage({
id: "x",
body: "hey @me look",
createdAt: new Date(),
}),
),
}); });
expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention"); expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass(
"chat-message--mention",
);
}); });
test("an emoji-only message is shown large", async () => { test("an emoji-only message is shown large", async () => {
await setup([makeChatMessage({ body: "🎉🎉" })]); await setup([makeChatMessage({ body: "🎉🎉" })]);
expect(document.querySelector(".chat-message-text--jumbo")).toHaveTextContent("🎉🎉"); expect(document.querySelector(".chat-message-text--jumbo"))
.toHaveTextContent("🎉🎉");
}); });
test("who's typing", async () => { test("who's typing", async () => {
const { socket } = await setup(); const { socket } = await setup();
socket.receive({ type: "chat_typing_update", users: [{ userId: "ann", username: "ann", hasAvatar: false }] }); socket.receive({
type: "chat_typing_update",
users: [{ userId: "ann", username: "ann", hasAvatar: false }],
});
expect(screen.getByText("ann is typing…")).toBeInTheDocument(); expect(screen.getByText("ann is typing…")).toBeInTheDocument();
socket.receive({ socket.receive({
type: "chat_typing_update", type: "chat_typing_update",
@@ -276,7 +425,10 @@ describe("ChatModal: live", () => {
], ],
}); });
expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument(); expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument();
socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] }); socket.receive({
type: "chat_typing_update",
users: [{ userId: "me", username: "me", hasAvatar: false }],
});
expect(screen.queryByText(/typing…/)).not.toBeInTheDocument(); expect(screen.queryByText(/typing…/)).not.toBeInTheDocument();
}); });
@@ -289,16 +441,27 @@ describe("ChatModal: live", () => {
}); });
test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => { test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => {
const old = makeChatMessage({ id: "o", body: "kept", createdAt: new Date(Date.now() - 50_000) }); const old = makeChatMessage({
const edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) }); id: "o",
body: "kept",
createdAt: new Date(Date.now() - 50_000),
});
const edited = makeChatMessage({
id: "e",
body: "before",
createdAt: new Date(Date.now() - 40_000),
});
const { socket } = await setup([old, edited]); const { socket } = await setup([old, edited]);
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }])); route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
socket.drop(); socket.drop();
expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument(); expect(screen.getByText("Disconnected — reconnecting…"))
.toBeInTheDocument();
await act(() => new Promise((r) => setTimeout(r, 600))); await act(() => new Promise((r) => setTimeout(r, 600)));
FakeWebSocket.last.welcome(); FakeWebSocket.last.welcome();
await screen.findByText("after"); await screen.findByText("after");
const resync = calls("GET /api/chat").at(-1)!; const resync = calls("GET /api/chat").at(-1)!;
expect(resync.url.searchParams.get("after")).toBe(old.createdAt.toISOString()); expect(resync.url.searchParams.get("after")).toBe(
old.createdAt.toISOString(),
);
}); });
}); });

View File

@@ -23,7 +23,9 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
token={session?.token ?? null} token={session?.token ?? null}
onCommentCreated={(c) => setComments((prev) => [...prev, c])} onCommentCreated={(c) => setComments((prev) => [...prev, c])}
onCommentDeleted={(id) => onCommentDeleted={(id) =>
setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))} setComments((prev) =>
prev.map((c) => c.id === id ? { ...c, deleted: true } : c)
)}
onCommentUpdated={(c) => onCommentUpdated={(c) =>
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))} setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
/> />
@@ -33,9 +35,16 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
return user; return user;
} }
const created = (over: Partial<Comment>) => const created = (over: Partial<Comment>) => (req: { body: unknown }) =>
(req: { body: unknown }) => wire(
wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over })); makeComment({
dumpId: "d1",
userId: "me",
authorUsername: "me",
body: (req.body as { body: string }).body,
...over,
}),
);
describe("CommentThread", () => { describe("CommentThread", () => {
test("counts visible comments and nests replies", () => { test("counts visible comments and nests replies", () => {
@@ -44,7 +53,8 @@ describe("CommentThread", () => {
makeComment({ id: "b", parentId: "a", body: "reply" }), makeComment({ id: "b", parentId: "a", body: "reply" }),
makeComment({ id: "c", deleted: true, body: "" }), makeComment({ id: "c", deleted: true, body: "" }),
]); ]);
expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "2 comments" }))
.toBeInTheDocument();
const top = screen.getByText("top").closest("li")!; const top = screen.getByText("top").closest("li")!;
expect(within(top).getByText("reply")).toBeInTheDocument(); expect(within(top).getByText("reply")).toBeInTheDocument();
expect(screen.getByText("[deleted]")).toBeInTheDocument(); expect(screen.getByText("[deleted]")).toBeInTheDocument();
@@ -52,8 +62,10 @@ describe("CommentThread", () => {
test("guests read but can't write, reply or like", () => { test("guests read but can't write, reply or like", () => {
setup([makeComment({ body: "hello" })], null); setup([makeComment({ body: "hello" })], null);
expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText("Add a comment…")).not
expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reply" })).not
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
}); });
@@ -66,8 +78,11 @@ describe("CommentThread", () => {
await user.click(screen.getByRole("button", { name: "Post comment" })); await user.click(screen.getByRole("button", { name: "Post comment" }));
await screen.findByText("First!", { selector: ".comment-body *" }); await screen.findByText("First!", { selector: ".comment-body *" });
expect(box).toHaveValue(""); expect(box).toHaveValue("");
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" }); expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument(); body: "First!",
});
expect(screen.getByRole("heading", { name: "1 comment" }))
.toBeInTheDocument();
}); });
test("Ctrl+Enter posts too; blank text doesn't", async () => { test("Ctrl+Enter posts too; blank text doesn't", async () => {
@@ -79,7 +94,9 @@ describe("CommentThread", () => {
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0); expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
await user.type(box, "ok"); await user.type(box, "ok");
await user.keyboard("{Control>}{Enter}{/Control}"); await user.keyboard("{Control>}{Enter}{/Control}");
await waitFor(() => expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)); await waitFor(() =>
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)
);
}); });
test("a failed post keeps the text and says why", async () => { test("a failed post keeps the text and says why", async () => {
@@ -96,7 +113,8 @@ describe("CommentThread", () => {
test("the top-level Cancel only shows with text, and clears it", async () => { test("the top-level Cancel only shows with text, and clears it", async () => {
const user = setup([]); const user = setup([]);
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Cancel" })).not
.toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft"); await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue(""); expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
@@ -110,8 +128,14 @@ describe("CommentThread", () => {
await waitFor(() => expect(box).toHaveFocus()); await waitFor(() => expect(box).toHaveFocus());
await user.type(box, "child"); await user.type(box, "child");
await user.click(screen.getByRole("button", { name: "Post reply" })); await user.click(screen.getByRole("button", { name: "Post reply" }));
await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument()); await waitFor(() =>
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" }); expect(screen.queryByPlaceholderText("Write a reply…")).not
.toBeInTheDocument()
);
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
body: "child",
parentId: "a",
});
const parent = screen.getByText("parent").closest("li")!; const parent = screen.getByText("parent").closest("li")!;
expect(within(parent).getByText("child")).toBeInTheDocument(); expect(within(parent).getByText("child")).toBeInTheDocument();
}); });
@@ -119,14 +143,31 @@ describe("CommentThread", () => {
test("Escape backs out of a reply", async () => { test("Escape backs out of a reply", async () => {
const user = setup([makeComment({ id: "a" })]); const user = setup([makeComment({ id: "a" })]);
await user.click(screen.getByRole("button", { name: "Reply" })); await user.click(screen.getByRole("button", { name: "Reply" }));
await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}"); await user.type(
expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument(); screen.getByPlaceholderText("Write a reply…"),
"nah{Escape}",
);
expect(screen.queryByPlaceholderText("Write a reply…")).not
.toBeInTheDocument();
}); });
test("editing your own comment", async () => { test("editing your own comment", async () => {
route("PATCH /api/comments/:id", (req) => route(
wire(makeComment({ id: "a", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, updatedAt: new Date() }))); "PATCH /api/comments/:id",
const user = setup([makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" })]); (req) =>
wire(
makeComment({
id: "a",
userId: "me",
authorUsername: "me",
body: (req.body as { body: string }).body,
updatedAt: new Date(),
}),
),
);
const user = setup([
makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" }),
]);
await user.click(screen.getByRole("button", { name: "Edit" })); await user.click(screen.getByRole("button", { name: "Edit" }));
const box = screen.getByDisplayValue("old"); const box = screen.getByDisplayValue("old");
await user.clear(box); await user.clear(box);
@@ -140,7 +181,10 @@ describe("CommentThread", () => {
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]); const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
await user.click(screen.getByRole("button", { name: "Edit" })); await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument()); await waitFor(() =>
expect(screen.queryByRole("button", { name: "Save" })).not
.toBeInTheDocument()
);
expect(calls("PATCH /api/comments/:id")).toHaveLength(0); expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
}); });
@@ -151,10 +195,13 @@ describe("CommentThread", () => {
]; ];
setup(comments); setup(comments);
const theirs = screen.getByText("theirs").closest("li")!; const theirs = screen.getByText("theirs").closest("li")!;
expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); expect(within(theirs).queryByRole("button", { name: "Edit" })).not
expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); .toBeInTheDocument();
expect(within(theirs).queryByRole("button", { name: "Delete" })).not
.toBeInTheDocument();
const mine = screen.getByText("mine").closest("li")!; const mine = screen.getByText("mine").closest("li")!;
expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument(); expect(within(mine).getByRole("button", { name: "Delete" }))
.toBeInTheDocument();
}); });
test("a moderator can delete anyone's", () => { test("a moderator can delete anyone's", () => {
@@ -163,7 +210,10 @@ describe("CommentThread", () => {
}); });
test("deleting asks first", async () => { test("deleting asks first", async () => {
route("DELETE /api/comments/:id", () => new Response(null, { status: 204 })); route(
"DELETE /api/comments/:id",
() => new Response(null, { status: 204 }),
);
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]); const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(screen.getByRole("button", { name: "Delete" }));
const dialog = screen.getByRole("alertdialog"); const dialog = screen.getByRole("alertdialog");
@@ -176,8 +226,14 @@ describe("CommentThread", () => {
route("DELETE /api/comments/:id", () => fail("Forbidden", 403)); route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]); const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" })); await user.click(
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i); within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
/couldn't be deleted/i,
);
expect(screen.getByText("stays")).toBeInTheDocument(); expect(screen.getByText("stays")).toBeInTheDocument();
}); });
@@ -194,7 +250,9 @@ describe("CommentThread", () => {
test("the timestamp links to the comment", () => { test("the timestamp links to the comment", () => {
setup([makeComment({ id: "a" })]); setup([makeComment({ id: "a" })]);
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a"); expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute(
"href",
"/dumps/d1#comment-a",
);
}); });
}); });

View File

@@ -1,15 +1,29 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, 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 userEvent from "@testing-library/user-event";
import { DumpCreateModal } from "../../components/DumpCreateModal.tsx"; import { DumpCreateModal } from "../../components/DumpCreateModal.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts"; import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx"; import { currentPath, renderWithContexts } from "../render.tsx";
const session = makeSession({ username: "me" }); const session = makeSession({ username: "me" });
const categories = [makeCategory({ id: "c1", name: "Music" })]; const categories = [makeCategory({ id: "c1", name: "Music" })];
function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}) { function setup(
props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {},
) {
const onClose = vi.fn(); const onClose = vi.fn();
const injectDump = vi.fn(); const injectDump = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
@@ -21,11 +35,18 @@ function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}
return { user, onClose, injectDump }; return { user, onClose, injectDump };
} }
function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) { function previewRoutes(
{ title = "Page title", reached = true, dupes = [] as unknown[] } = {},
) {
route("GET /api/preview", (req) => ({ route("GET /api/preview", (req) => ({
reached, reached,
richContent: reached richContent: reached
? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site" } ? {
type: "link",
url: req.url.searchParams.get("url"),
title,
siteName: "Site",
}
: { type: "link", url: req.url.searchParams.get("url") }, : { type: "link", url: req.url.searchParams.get("url") },
})); }));
route("GET /api/dumps/by-url", () => dupes); route("GET /api/dumps/by-url", () => dupes);
@@ -37,7 +58,8 @@ const linkBox = () => screen.getByPlaceholderText("Paste a link…");
describe("DumpCreateModal: a link", () => { describe("DumpCreateModal: a link", () => {
test("Next stays disabled until there is a link or a file", async () => { test("Next stays disabled until there is a link or a file", async () => {
const { user } = setup(); const { user } = setup();
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "New dump" }))
.toBeInTheDocument();
expect(nextBtn()).toBeDisabled(); expect(nextBtn()).toBeDisabled();
await user.type(linkBox(), "not a url at all"); await user.type(linkBox(), "not a url at all");
expect(nextBtn()).toBeDisabled(); expect(nextBtn()).toBeDisabled();
@@ -61,21 +83,39 @@ describe("DumpCreateModal: a link", () => {
previewRoutes({ reached: false }); previewRoutes({ reached: false });
const { user } = setup(); const { user } = setup();
await user.type(linkBox(), "https://down.test/"); await user.type(linkBox(), "https://down.test/");
expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument(); expect(await screen.findByText(/Couldn't load a preview/))
.toBeInTheDocument();
expect(nextBtn()).toBeEnabled(); expect(nextBtn()).toBeEnabled();
}); });
test("warns about an earlier dump of the same link", async () => { test("warns about an earlier dump of the same link", async () => {
previewRoutes({ previewRoutes({
dupes: [ dupes: [
{ id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0 }, {
{ id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0 }, id: "old",
slug: "old-one",
title: "Old one",
username: "ann",
createdAt: new Date(Date.now() - 3600_000).toISOString(),
voteCount: 1,
commentCount: 0,
},
{
id: "older",
title: "Older",
username: "bob",
createdAt: new Date(0).toISOString(),
voteCount: 0,
commentCount: 0,
},
], ],
}); });
const { user, onClose } = setup(); const { user, onClose } = setup();
await user.type(linkBox(), "https://x.test/"); await user.type(linkBox(), "https://x.test/");
const notice = await screen.findByText(/Already dumped by ann/); const notice = await screen.findByText(/Already dumped by ann/);
expect(notice.closest("[role=status]")).toHaveTextContent("and 1 earlier dump"); expect(notice.closest("[role=status]")).toHaveTextContent(
"and 1 earlier dump",
);
await user.click(screen.getByRole("link", { name: "Old one" })); await user.click(screen.getByRole("link", { name: "Old one" }));
expect(currentPath()).toBe("/dumps/old-one"); expect(currentPath()).toBe("/dumps/old-one");
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
@@ -83,10 +123,18 @@ describe("DumpCreateModal: a link", () => {
test("the whole way through: post, then add to a playlist", async () => { test("the whole way through: post, then add to a playlist", async () => {
previewRoutes(); previewRoutes();
const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id }); const created = makeDump({
id: "new",
slug: "page-title",
title: "Page title",
userId: session.user.id,
});
route("POST /api/dumps", () => wire(created)); route("POST /api/dumps", () => wire(created));
const mix = makePlaylist({ id: "p1", title: "Mix" }); const mix = makePlaylist({ id: "p1", title: "Mix" });
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }]); route(
"GET /api/playlists/by-dump/:id/memberships",
() => [{ playlist: wire(mix), hasDump: false }],
);
route("POST /api/playlists/:pid/dumps/:did", () => ({})); route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user, injectDump, onClose } = setup(); const { user, injectDump, onClose } = setup();
@@ -109,7 +157,9 @@ describe("DumpCreateModal: a link", () => {
isPrivate: true, isPrivate: true,
categoryIds: ["c1"], categoryIds: ["c1"],
}); });
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" })); expect(injectDump).toHaveBeenCalledWith(
expect.objectContaining({ id: "new" }),
);
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
const row = await screen.findByRole("checkbox", { name: /Mix/ }); const row = await screen.findByRole("checkbox", { name: /Mix/ });
@@ -117,7 +167,9 @@ describe("DumpCreateModal: a link", () => {
row.focus(); row.focus();
await user.keyboard(" "); await user.keyboard(" ");
await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true")); await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true"));
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/p1/dumps/new"); expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/p1/dumps/new",
);
await user.click(screen.getByRole("button", { name: "Done" })); await user.click(screen.getByRole("button", { name: "Done" }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
@@ -126,7 +178,13 @@ describe("DumpCreateModal: a link", () => {
test("a refused playlist toggle leaves the box as it was", async () => { test("a refused playlist toggle leaves the box as it was", async () => {
previewRoutes(); previewRoutes();
route("POST /api/dumps", () => wire(makeDump({ id: "new" }))); route("POST /api/dumps", () => wire(makeDump({ id: "new" })));
route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false }]); route(
"GET /api/playlists/by-dump/:id/memberships",
() => [{
playlist: wire(makePlaylist({ title: "Mix" })),
hasDump: false,
}],
);
route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403)); route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
const { user } = setup(); const { user } = setup();
await user.type(linkBox(), "https://a.test/{Enter}"); await user.type(linkBox(), "https://a.test/{Enter}");
@@ -143,14 +201,18 @@ describe("DumpCreateModal: a link", () => {
route("GET /api/playlists/by-dump/:id/memberships", () => []); route("GET /api/playlists/by-dump/:id/memberships", () => []);
const { user } = setup(); const { user } = setup();
await user.type(linkBox(), "https://a.test/"); await user.type(linkBox(), "https://a.test/");
await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title")); await waitFor(() =>
expect(screen.getByLabelText("Title")).toHaveValue("Page title")
);
await user.click(screen.getByRole("button", { name: "Edit title" })); await user.click(screen.getByRole("button", { name: "Edit title" }));
const title = screen.getByLabelText("Title"); const title = screen.getByLabelText("Title");
await waitFor(() => expect(title).toHaveFocus()); await waitFor(() => expect(title).toHaveFocus());
await user.clear(title); await user.clear(title);
await user.type(title, "My title"); await user.type(title, "My title");
// The suggestion can be had back. // The suggestion can be had back.
await user.click(screen.getByRole("button", { name: "Reset to the suggested title" })); await user.click(
screen.getByRole("button", { name: "Reset to the suggested title" }),
);
expect(title).toHaveValue("Page title"); expect(title).toHaveValue("Page title");
await user.click(screen.getByRole("button", { name: "Edit title" })); await user.click(screen.getByRole("button", { name: "Edit title" }));
await user.clear(title); await user.clear(title);
@@ -158,7 +220,9 @@ describe("DumpCreateModal: a link", () => {
await user.click(nextBtn()); await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" })); await user.click(screen.getByRole("button", { name: "Dump it" }));
await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1)); await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1));
expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all" }); expect(calls("POST /api/dumps")[0].body).toMatchObject({
title: "Mine after all",
});
}); });
test("Enter in the link field goes to the next step rather than posting", async () => { test("Enter in the link field goes to the next step rather than posting", async () => {
@@ -180,7 +244,8 @@ describe("DumpCreateModal: a link", () => {
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to post"); expect(alert).toHaveTextContent("Failed to post");
expect(alert).toHaveTextContent("That URL isn't allowed"); expect(alert).toHaveTextContent("That URL isn't allowed");
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "New dump" }))
.toBeInTheDocument();
}); });
test("starts from a shared URL", async () => { test("starts from a shared URL", async () => {
@@ -206,21 +271,28 @@ describe("DumpCreateModal: a link", () => {
describe("DumpCreateModal: drafts", () => { describe("DumpCreateModal: drafts", () => {
test("a dismissed draft comes back, and can be dropped", async () => { test("a dismissed draft comes back, and can be dropped", async () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({
url: "https://draft.test/", url: "https://draft.test/",
title: "", title: "",
titleFromUser: false, titleFromUser: false,
comment: "half-written", comment: "half-written",
isPublic: false, isPublic: false,
categoryIds: ["c1"], categoryIds: ["c1"],
})); }),
);
previewRoutes(); previewRoutes();
const { user } = setup(); const { user } = setup();
expect(screen.getByText("Picked up where you left off.")).toBeInTheDocument(); expect(screen.getByText("Picked up where you left off."))
.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://draft.test/"); expect(linkBox()).toHaveValue("https://draft.test/");
await user.click(nextBtn()); await user.click(nextBtn());
expect(screen.getByLabelText("Why?")).toHaveValue("half-written"); expect(screen.getByLabelText("Why?")).toHaveValue("half-written");
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute(
"aria-pressed",
"true",
);
await user.click(screen.getByRole("button", { name: "← Back" })); await user.click(screen.getByRole("button", { name: "← Back" }));
await user.click(screen.getByRole("button", { name: "Start fresh" })); await user.click(screen.getByRole("button", { name: "Start fresh" }));
expect(linkBox()).toHaveValue(""); expect(linkBox()).toHaveValue("");
@@ -231,14 +303,20 @@ describe("DumpCreateModal: drafts", () => {
previewRoutes(); previewRoutes();
const { user } = setup(); const { user } = setup();
await user.type(linkBox(), "https://keep.test/"); await user.type(linkBox(), "https://keep.test/");
expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("https://keep.test/"); expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe(
"https://keep.test/",
);
}); });
test("a shared URL starts clean rather than merging an old draft", () => { test("a shared URL starts clean rather than merging an old draft", () => {
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" })); localStorage.setItem(
"gerbeur.dumpDraft",
JSON.stringify({ url: "https://old.test/", comment: "old why" }),
);
previewRoutes(); previewRoutes();
setup({ initialUrl: "https://new.test/" }); setup({ initialUrl: "https://new.test/" });
expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument(); expect(screen.queryByText("Picked up where you left off.")).not
.toBeInTheDocument();
expect(linkBox()).toHaveValue("https://new.test/"); expect(linkBox()).toHaveValue("https://new.test/");
}); });
}); });
@@ -252,7 +330,10 @@ describe("DumpCreateModal: a file", () => {
body: FormData | null = null; body: FormData | null = null;
private l: Record<string, Array<(e: unknown) => void>> = {}; private l: Record<string, Array<(e: unknown) => void>> = {};
private u: Record<string, Array<(e: unknown) => void>> = {}; private u: Record<string, Array<(e: unknown) => void>> = {};
upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f) }; upload = {
addEventListener: (t: string, f: (e: unknown) => void) =>
(this.u[t] ??= []).push(f),
};
constructor() { constructor() {
FakeXHR.last = this; FakeXHR.last = this;
} }
@@ -265,7 +346,9 @@ describe("DumpCreateModal: a file", () => {
this.body = body; this.body = body;
} }
progress(f: number) { progress(f: number) {
this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true })); this.u.progress?.forEach((fn) =>
fn({ loaded: f * 100, total: 100, lengthComputable: true })
);
} }
respond(status: number, data: unknown) { respond(status: number, data: unknown) {
this.status = status; this.status = status;
@@ -275,7 +358,8 @@ describe("DumpCreateModal: a file", () => {
} }
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" }); const song = () =>
new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" });
test("a dropped file names the dump and uploads with progress", async () => { test("a dropped file names the dump and uploads with progress", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR); vi.stubGlobal("XMLHttpRequest", FakeXHR);
@@ -286,19 +370,29 @@ describe("DumpCreateModal: a file", () => {
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
dataTransfer: { files: [song()] }, dataTransfer: { files: [song()] },
}); });
expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText("Paste a link…")).not
.toBeInTheDocument();
expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument(); expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument();
expect(screen.getByLabelText("Title")).toHaveValue("my song-final"); expect(screen.getByLabelText("Title")).toHaveValue("my song-final");
await user.click(nextBtn()); await user.click(nextBtn());
await user.click(screen.getByRole("button", { name: "Dump it" })); await user.click(screen.getByRole("button", { name: "Dump it" }));
act(() => FakeXHR.last.progress(0.42)); act(() => FakeXHR.last.progress(0.42));
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42"); expect(screen.getByRole("progressbar")).toHaveAttribute(
expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled(); "aria-valuenow",
"42",
);
expect(screen.getByRole("button", { name: "Uploading 42%" }))
.toBeDisabled();
const fd = FakeXHR.last.body!; const fd = FakeXHR.last.body!;
expect(fd.get("title")).toBe("my song-final"); expect(fd.get("title")).toBe("my song-final");
expect(fd.get("isPrivate")).toBe("false"); expect(fd.get("isPrivate")).toBe("false");
expect(fd.get("categoryIds")).toBe("[]"); expect(fd.get("categoryIds")).toBe("[]");
await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })) })); await act(async () =>
FakeXHR.last.respond(201, {
success: true,
data: wire(makeDump({ id: "f" })),
})
);
await screen.findByRole("dialog", { name: "Add to playlist" }); await screen.findByRole("dialog", { name: "Add to playlist" });
}); });
@@ -310,9 +404,16 @@ describe("DumpCreateModal: a file", () => {
}); });
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!; const confirm = screen.getByText(/The attached file won't be kept/).closest(
await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" })); "[role=alert]",
expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument(); )!;
await user.click(
within(confirm as HTMLElement).getByRole("button", {
name: "Keep editing",
}),
);
expect(screen.queryByText(/The attached file won't be kept/)).not
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Close anyway" })); await user.click(screen.getByRole("button", { name: "Close anyway" }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();

View File

@@ -22,7 +22,11 @@ describe("Modal", () => {
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => { test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose}><button type="button">inside</button></Modal>); wrap(
<Modal title="T" onClose={onClose}>
<button type="button">inside</button>
</Modal>,
);
await user.click(screen.getByText("inside")); await user.click(screen.getByText("inside"));
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
@@ -34,7 +38,9 @@ describe("Modal", () => {
test("onBeforeClose can refuse", async () => { test("onBeforeClose can refuse", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
wrap(<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>); wrap(
<Modal title="T" onClose={onClose} onBeforeClose={() => false}>x</Modal>,
);
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "Close" })); await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
@@ -60,7 +66,11 @@ describe("ConfirmModal", () => {
const onCancel = vi.fn(); const onCancel = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
wrap( wrap(
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />, <ConfirmModal
message="Really?"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
); );
const dialog = screen.getByRole("alertdialog"); const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveAccessibleDescription("Really?"); expect(dialog).toHaveAccessibleDescription("Really?");
@@ -74,7 +84,12 @@ describe("ConfirmModal", () => {
test("takes a custom label", () => { test("takes a custom label", () => {
wrap( wrap(
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />, <ConfirmModal
message="m"
confirmLabel="Discard"
onConfirm={() => {}}
onCancel={() => {}}
/>,
); );
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
}); });

View File

@@ -1,8 +1,17 @@
import { describe, expect, test, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { createRef, useState } from "react"; import { createRef, useState } from "react";
import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx"; import {
TextEditor,
type TextEditorHandle,
} from "../../components/TextEditor.tsx";
import { calls, deferred, fail, route } from "../fetch.ts"; import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts"; import { makeSession } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx"; import { renderWithContexts } from "../render.tsx";
@@ -54,7 +63,9 @@ describe("TextEditor basics", () => {
await user.keyboard("{Meta>}{Enter}{/Meta}"); await user.keyboard("{Meta>}{Enter}{/Meta}");
expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit).toHaveBeenCalledTimes(2);
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({ key: "Enter" }),
);
}); });
test("an Enter confirming an IME candidate does nothing else", () => { test("an Enter confirming an IME candidate does nothing else", () => {
@@ -81,7 +92,9 @@ describe("TextEditor basics", () => {
await user.type(box, "8"); await user.type(box, "8");
expect(screen.getByText("8 / 10")).toBeInTheDocument(); expect(screen.getByText("8 / 10")).toBeInTheDocument();
await user.type(box, "901"); await user.type(box, "901");
expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger"); expect(screen.getByText("11 / 10")).toHaveClass(
"text-editor-count--danger",
);
expect(box).toHaveAttribute("aria-invalid", "true"); expect(box).toHaveAttribute("aria-invalid", "true");
}); });
@@ -92,7 +105,9 @@ describe("TextEditor basics", () => {
test("the handle focuses at the end", () => { test("the handle focuses at the end", () => {
const ref = createRef<TextEditorHandle>(); const ref = createRef<TextEditorHandle>();
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />); renderWithContexts(
<TextEditor ref={ref} value="abc" onChange={() => {}} />,
);
act(() => ref.current!.focus({ atEnd: true })); act(() => ref.current!.focus({ atEnd: true }));
const box = screen.getByRole("textbox") as HTMLTextAreaElement; const box = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(box).toHaveFocus(); expect(box).toHaveFocus();
@@ -152,7 +167,9 @@ describe("mentions", () => {
await user.type(box, "@al"); await user.type(box, "@al");
await screen.findByRole("listbox"); await screen.findByRole("listbox");
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); expect(onKeyDown).not.toHaveBeenCalledWith(
expect.objectContaining({ key: "Enter" }),
);
expect(box).toHaveValue("@alice "); expect(box).toHaveValue("@alice ");
}); });
@@ -172,9 +189,13 @@ describe("image upload", () => {
const { user, box } = setup({}, "look: "); const { user, box } = setup({}, "look: ");
await user.click(box); await user.click(box);
box.setSelectionRange(6, 6); box.setSelectionRange(6, 6);
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); fireEvent.paste(box, {
clipboardData: { types: ["Files"], files: [imageFile()] },
});
await waitFor(() => await waitFor(() =>
expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)") expect(box).toHaveValue(
"look: ![](http://localhost:8000/api/attachments/att1)",
)
); );
const [req] = calls("POST /api/attachments"); const [req] = calls("POST /api/attachments");
expect(req.body).toBeInstanceOf(FormData); expect(req.body).toBeInstanceOf(FormData);
@@ -195,14 +216,20 @@ describe("image upload", () => {
route("POST /api/attachments", () => pending.promise); route("POST /api/attachments", () => pending.promise);
const onKeyDown = vi.fn(); const onKeyDown = vi.fn();
const { box } = setup({ onKeyDown }); const { box } = setup({ onKeyDown });
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); fireEvent.paste(box, {
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…"); clipboardData: { types: ["Files"], files: [imageFile()] },
});
expect(await screen.findByRole("status")).toHaveTextContent(
"Uploading image…",
);
expect(box).toHaveAttribute("readonly"); expect(box).toHaveAttribute("readonly");
expect(box).toHaveAttribute("aria-busy", "true"); expect(box).toHaveAttribute("aria-busy", "true");
fireEvent.keyDown(box, { key: "Enter" }); fireEvent.keyDown(box, { key: "Enter" });
expect(onKeyDown).not.toHaveBeenCalled(); expect(onKeyDown).not.toHaveBeenCalled();
await act(async () => await act(async () =>
pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } }))) pending.resolve(
new Response(JSON.stringify({ success: true, data: { id: "z" } })),
)
); );
await waitFor(() => expect(box).not.toHaveAttribute("readonly")); await waitFor(() => expect(box).not.toHaveAttribute("readonly"));
}); });
@@ -210,29 +237,47 @@ describe("image upload", () => {
test("a failed upload is reported", async () => { test("a failed upload is reported", async () => {
route("POST /api/attachments", () => fail("Too big", 413)); route("POST /api/attachments", () => fail("Too big", 413));
const { box } = setup(); const { box } = setup();
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); fireEvent.paste(box, {
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded."); clipboardData: { types: ["Files"], files: [imageFile()] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"The image couldn't be uploaded.",
);
expect(box).toHaveValue(""); expect(box).toHaveValue("");
expect(box).toHaveAttribute("aria-describedby"); expect(box).toHaveAttribute("aria-describedby");
}); });
test("dropping a non-image is caught and explained", () => { test("dropping a non-image is caught and explained", () => {
const { box } = setup(); const { box } = setup();
const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" }; const dataTransfer = {
types: ["Files"],
files: [imageFile("a.pdf", "application/pdf")],
dropEffect: "",
};
const over = fireEvent.dragOver(box, { dataTransfer }); const over = fireEvent.dragOver(box, { dataTransfer });
expect(over).toBe(false); // prevented: the browser mustn't open the file expect(over).toBe(false); // prevented: the browser mustn't open the file
fireEvent.drop(box, { dataTransfer }); fireEvent.drop(box, { dataTransfer });
expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here."); expect(screen.getByRole("alert")).toHaveTextContent(
"Only images can be dropped here.",
);
expect(calls("POST /api/attachments")).toHaveLength(0); expect(calls("POST /api/attachments")).toHaveLength(0);
}); });
test("dropped images upload; dragged text is left to the browser", async () => { test("dropped images upload; dragged text is left to the browser", async () => {
route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` })); route(
"POST /api/attachments",
(req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }),
);
const { box } = setup(); const { box } = setup();
const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } }); const textDrag = fireEvent.dragOver(box, {
dataTransfer: { types: ["text/plain"], files: [] },
});
expect(textDrag).toBe(true); expect(textDrag).toBe(true);
fireEvent.drop(box, { fireEvent.drop(box, {
dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] }, dataTransfer: {
types: ["Files"],
files: [imageFile("a.png"), imageFile("b.png")],
},
}); });
await waitFor(() => expect(box.value).toContain("attachments/n1")); await waitFor(() => expect(box.value).toContain("attachments/n1"));
expect(box.value.split("\n")).toHaveLength(2); expect(box.value.split("\n")).toHaveLength(2);
@@ -245,11 +290,14 @@ describe("emoji", () => {
const onCancel = vi.fn(); const onCancel = vi.fn();
const { user, box } = setup({ onCancel }); const { user, box } = setup({ onCancel });
await user.type(box, "hi :astro"); await user.type(box, "hi :astro");
const search = await screen.findByRole("searchbox", { name: "Search emoji" }); const search = await screen.findByRole("searchbox", {
name: "Search emoji",
});
expect(search).toHaveValue("astro"); expect(search).toHaveValue("astro");
expect(box).toHaveFocus(); expect(box).toHaveFocus();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
expect(onCancel).not.toHaveBeenCalled(); expect(onCancel).not.toHaveBeenCalled();
expect(box).toHaveValue("hi :astro"); expect(box).toHaveValue("hi :astro");
}); });
@@ -267,7 +315,8 @@ describe("emoji", () => {
serveEmojibase(); serveEmojibase();
const { user, box } = setup(); const { user, box } = setup();
await user.type(box, "ok :D at 12:30"); await user.type(box, "ok :D at 12:30");
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
}); });
test("the button browses and inserts at the caret", async () => { test("the button browses and inserts at the caret", async () => {
@@ -276,11 +325,16 @@ describe("emoji", () => {
await user.click(box); await user.click(box);
box.setSelectionRange(1, 1); box.setSelectionRange(1, 1);
await user.click(screen.getByRole("button", { name: "Insert emoji" })); await user.click(screen.getByRole("button", { name: "Insert emoji" }));
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true"); expect(screen.getByRole("button", { name: "Insert emoji" }))
const search = await screen.findByRole("searchbox", { name: "Search emoji" }); .toHaveAttribute("aria-expanded", "true");
const search = await screen.findByRole("searchbox", {
name: "Search emoji",
});
await waitFor(() => expect(search).toHaveFocus()); await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "astronaut"); await user.type(search, "astronaut");
await user.click(await screen.findByRole("gridcell", { name: "Astronaut" })); await user.click(
await screen.findByRole("gridcell", { name: "Astronaut" }),
);
expect(box).toHaveValue("a🧑‍🚀b"); expect(box).toHaveValue("a🧑‍🚀b");
}); });
@@ -290,6 +344,7 @@ describe("emoji", () => {
await user.click(screen.getByRole("button", { name: "Insert emoji" })); await user.click(screen.getByRole("button", { name: "Insert emoji" }));
await screen.findByRole("searchbox", { name: "Search emoji" }); await screen.findByRole("searchbox", { name: "Search emoji" });
await user.click(document.body); await user.click(document.body);
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
.toBeInTheDocument();
}); });
}); });

View File

@@ -5,7 +5,10 @@ import { useRef } from "react";
import { Avatar } from "../../components/Avatar.tsx"; import { Avatar } from "../../components/Avatar.tsx";
import { VoteButton } from "../../components/VoteButton.tsx"; import { VoteButton } from "../../components/VoteButton.tsx";
import { LikeButton } from "../../components/LikeButton.tsx"; import { LikeButton } from "../../components/LikeButton.tsx";
import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx"; import {
FollowPlaylistButton,
FollowUserButton,
} from "../../components/FollowButton.tsx";
import { UserListPopover } from "../../components/UserListPopover.tsx"; import { UserListPopover } from "../../components/UserListPopover.tsx";
import { NotificationBell } from "../../components/NotificationBell.tsx"; import { NotificationBell } from "../../components/NotificationBell.tsx";
import { ChatButton } from "../../components/ChatButton.tsx"; import { ChatButton } from "../../components/ChatButton.tsx";
@@ -13,12 +16,14 @@ import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts"; import { ChatContext } from "../../contexts/ChatContext.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts"; import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx"; import { currentPath, renderWithContexts } from "../render.tsx";
import { WSContext } from "../../contexts/WSContext.ts"; import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts";
describe("Avatar", () => { describe("Avatar", () => {
test("an image when the user has one, versioned", () => { test("an image when the user has one, versioned", () => {
renderWithContexts(<Avatar userId="u1" username="ann" hasAvatar version={7} />); renderWithContexts(
<Avatar userId="u1" username="ann" hasAvatar version={7} />,
);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
"src", "src",
"http://localhost:8000/api/avatars/u1?v=7", "http://localhost:8000/api/avatars/u1?v=7",
@@ -34,18 +39,25 @@ describe("Avatar", () => {
test("a live override wins over stale props, and retries a failed image", () => { test("a live override wins over stale props, and retries a failed image", () => {
const { rerender } = renderWithContexts( const { rerender } = renderWithContexts(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: false, version: 1 } }}> <AvatarOverrideContext.Provider
value={{ u1: { hasAvatar: false, version: 1 } }}
>
<Avatar userId="u1" username="cy" hasAvatar /> <Avatar userId="u1" username="cy" hasAvatar />
</AvatarOverrideContext.Provider>, </AvatarOverrideContext.Provider>,
); );
expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
rerender( rerender(
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}> <AvatarOverrideContext.Provider
value={{ u1: { hasAvatar: true, version: 2 } }}
>
<Avatar userId="u1" username="cy" hasAvatar={false} /> <Avatar userId="u1" username="cy" hasAvatar={false} />
</AvatarOverrideContext.Provider>, </AvatarOverrideContext.Provider>,
); );
// rerender replaces the tree under the same router wrapper // rerender replaces the tree under the same router wrapper
expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2")); expect(screen.getByRole("img")).toHaveAttribute(
"src",
expect.stringContaining("?v=2"),
);
}); });
}); });
@@ -55,18 +67,39 @@ describe("VoteButton / LikeButton", () => {
const onRemove = vi.fn(); const onRemove = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
const { rerender } = renderWithContexts( const { rerender } = renderWithContexts(
<VoteButton dumpId="d" count={0} voted={false} onCast={onCast} onRemove={onRemove} />, <VoteButton
dumpId="d"
count={0}
voted={false}
onCast={onCast}
onRemove={onRemove}
/>,
); );
await user.click(screen.getByRole("button", { name: "Upvote" })); await user.click(screen.getByRole("button", { name: "Upvote" }));
expect(onCast).toHaveBeenCalledWith("d", 0); expect(onCast).toHaveBeenCalledWith("d", 0);
rerender(<VoteButton dumpId="d" count={3} voted onCast={onCast} onRemove={onRemove} />); rerender(
<VoteButton
dumpId="d"
count={3}
voted
onCast={onCast}
onRemove={onRemove}
/>,
);
await user.click(screen.getByRole("button", { name: "Remove vote" })); await user.click(screen.getByRole("button", { name: "Remove vote" }));
expect(onRemove).toHaveBeenCalledWith("d", 3); expect(onRemove).toHaveBeenCalledWith("d", 3);
}); });
test("disabled for guests, with a hint", () => { test("disabled for guests, with a hint", () => {
renderWithContexts( renderWithContexts(
<VoteButton dumpId="d" count={1} voted={false} disabled onCast={() => {}} onRemove={() => {}} />, <VoteButton
dumpId="d"
count={1}
voted={false}
disabled
onCast={() => {}}
onRemove={() => {}}
/>,
); );
const btn = screen.getByRole("button", { name: "Upvote" }); const btn = screen.getByRole("button", { name: "Upvote" });
expect(btn).toBeDisabled(); expect(btn).toBeDisabled();
@@ -75,14 +108,24 @@ describe("VoteButton / LikeButton", () => {
test("the count opens who voted, without voting", async () => { test("the count opens who voted, without voting", async () => {
const onCast = vi.fn(); const onCast = vi.fn();
route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })])); route(
"GET /api/dumps/:id/voters",
() => page([makeUser({ username: "fan" })]),
);
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts( renderWithContexts(
<VoteButton dumpId="d1" count={1} voted={false} onCast={onCast} onRemove={() => {}} />, <VoteButton
dumpId="d1"
count={1}
voted={false}
onCast={onCast}
onRemove={() => {}}
/>,
); );
await user.click(screen.getByText("1")); await user.click(screen.getByText("1"));
expect(onCast).not.toHaveBeenCalled(); expect(onCast).not.toHaveBeenCalled();
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute( expect(await screen.findByRole("menuitem", { name: /fan/ }))
.toHaveAttribute(
"href", "href",
"/users/fan", "/users/fan",
); );
@@ -94,13 +137,27 @@ describe("VoteButton / LikeButton", () => {
route("GET /api/comments/:id/likers", () => page([])); route("GET /api/comments/:id/likers", () => page([]));
const user = userEvent.setup(); const user = userEvent.setup();
const { rerender } = renderWithContexts( const { rerender } = renderWithContexts(
<LikeButton commentId="c" count={2} liked={false} onLike={onLike} onUnlike={onUnlike} />, <LikeButton
commentId="c"
count={2}
liked={false}
onLike={onLike}
onUnlike={onUnlike}
/>,
); );
await user.click(screen.getByRole("button", { name: "Like" })); await user.click(screen.getByRole("button", { name: "Like" }));
expect(onLike).toHaveBeenCalledWith("c", 2); expect(onLike).toHaveBeenCalledWith("c", 2);
await user.click(screen.getByText("2")); await user.click(screen.getByText("2"));
expect(await screen.findByText("No one yet.")).toBeInTheDocument(); expect(await screen.findByText("No one yet.")).toBeInTheDocument();
rerender(<LikeButton commentId="c" count={2} liked onLike={onLike} onUnlike={onUnlike} />); rerender(
<LikeButton
commentId="c"
count={2}
liked
onLike={onLike}
onUnlike={onUnlike}
/>,
);
await user.click(screen.getByRole("button", { name: "Remove like" })); await user.click(screen.getByRole("button", { name: "Remove like" }));
expect(onUnlike).toHaveBeenCalledWith("c", 2); expect(onUnlike).toHaveBeenCalledWith("c", 2);
}); });
@@ -113,28 +170,40 @@ describe("UserListPopover", () => {
<> <>
<span ref={ref}>anchor</span> <span ref={ref}>anchor</span>
<button type="button">outside</button> <button type="button">outside</button>
<UserListPopover anchorRef={ref} onClose={onClose} fetchUrl="http://localhost:8000/api/x/voters" /> <UserListPopover
anchorRef={ref}
onClose={onClose}
fetchUrl="http://localhost:8000/api/x/voters"
/>
</> </>
); );
} }
test("pages through the list", async () => { test("pages through the list", async () => {
route("GET /api/x/voters", (req) => route(
"GET /api/x/voters",
(req) =>
req.url.searchParams.get("page") === "1" req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "a" })], true) ? page([makeUser({ username: "a" })], true)
: page([makeUser({ username: "b" })], false)); : page([makeUser({ username: "b" })], false),
);
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<Harness />, { session: makeSession() }); renderWithContexts(<Harness />, { session: makeSession() });
expect(screen.getByText("Loading…")).toBeInTheDocument(); expect(screen.getByText("Loading…")).toBeInTheDocument();
await screen.findByText("a"); await screen.findByText("a");
await user.click(screen.getByRole("button", { name: "Load more" })); await user.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("b"); await screen.findByText("b");
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Load more" })).not
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /); .toBeInTheDocument();
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(
/^Bearer /,
);
}); });
test("shows the server's error, and a network one", async () => { test("shows the server's error, and a network one", async () => {
route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true }); route("GET /api/x/voters", () => fail("Dump not found", 404), {
once: true,
});
const first = renderWithContexts(<Harness />); const first = renderWithContexts(<Harness />);
await screen.findByText("Dump not found"); await screen.findByText("Dump not found");
first.unmount(); first.unmount();
@@ -144,9 +213,16 @@ describe("UserListPopover", () => {
}); });
test("a failed next page lets you try again", async () => { test("a failed next page lets you try again", async () => {
route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true }); route(
"GET /api/x/voters",
() => page([makeUser({ username: "a" })], true),
{ once: true },
);
route("GET /api/x/voters", () => new TypeError("offline"), { once: true }); route("GET /api/x/voters", () => new TypeError("offline"), { once: true });
route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false)); route(
"GET /api/x/voters",
() => page([makeUser({ username: "b" })], false),
);
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<Harness />); renderWithContexts(<Harness />);
await screen.findByText("a"); await screen.findByText("a");
@@ -184,7 +260,10 @@ describe("FollowButton", () => {
); );
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
unmount(); unmount();
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me }); renderWithContexts(
<FollowUserButton targetUserId="me" targetUsername="me" />,
{ session: me },
);
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
@@ -206,10 +285,13 @@ describe("FollowButton", () => {
test("shows Following, and unfollows", async () => { test("shows Following, and unfollows", async () => {
const unfollowUser = vi.fn(); const unfollowUser = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, { renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{
session: me, session: me,
follows: { followedUserIds: new Set(["u"]), unfollowUser }, follows: { followedUserIds: new Set(["u"]), unfollowUser },
}); },
);
const btn = screen.getByRole("button", { name: "Unfollow ann" }); const btn = screen.getByRole("button", { name: "Unfollow ann" });
expect(btn).toHaveTextContent("Following"); expect(btn).toHaveTextContent("Following");
await user.click(btn); await user.click(btn);
@@ -217,10 +299,13 @@ describe("FollowButton", () => {
}); });
test("disabled until follows have loaded", () => { test("disabled until follows have loaded", () => {
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, { renderWithContexts(
<FollowUserButton targetUserId="u" targetUsername="ann" />,
{
session: me, session: me,
follows: { isLoaded: false }, follows: { isLoaded: false },
}); },
);
expect(screen.getByRole("button")).toBeDisabled(); expect(screen.getByRole("button")).toBeDisabled();
}); });
@@ -236,7 +321,11 @@ describe("FollowButton", () => {
priv.unmount(); priv.unmount();
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, { renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
session: me, session: me,
follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) }, follows: {
followPlaylist,
unfollowPlaylist,
followedPlaylistIds: new Set(["p"]),
},
}); });
await user.click(screen.getByRole("button", { name: "Unfollow playlist" })); await user.click(screen.getByRole("button", { name: "Unfollow playlist" }));
expect(unfollowPlaylist).toHaveBeenCalledWith("p"); expect(unfollowPlaylist).toHaveBeenCalledWith("p");
@@ -246,8 +335,12 @@ describe("FollowButton", () => {
describe("NotificationBell / ChatButton", () => { describe("NotificationBell / ChatButton", () => {
test("the bell shows the unread count and goes to notifications", async () => { test("the bell shows the unread count and goes to notifications", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } }); renderWithContexts(<NotificationBell />, {
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" }); ws: { unreadNotificationCount: 120 },
});
const bell = screen.getByRole("button", {
name: "Notifications (120 unread)",
});
expect(bell).toHaveTextContent("99+"); expect(bell).toHaveTextContent("99+");
await user.click(bell); await user.click(bell);
expect(currentPath()).toBe("/notifications"); expect(currentPath()).toBe("/notifications");
@@ -258,7 +351,9 @@ describe("NotificationBell / ChatButton", () => {
try { try {
const n1 = makeNotification({ id: "n1" }); const n1 = makeNotification({ id: "n1" });
const withLast = (n: ReturnType<typeof makeNotification>) => ( const withLast = (n: ReturnType<typeof makeNotification>) => (
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}> <WSContext.Provider
value={{ ...defaultWSValue, lastNotification: n }}
>
<NotificationBell /> <NotificationBell />
</WSContext.Provider> </WSContext.Provider>
); );
@@ -280,7 +375,9 @@ describe("NotificationBell / ChatButton", () => {
const openChat = vi.fn(); const openChat = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts( renderWithContexts(
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}> <ChatContext.Provider
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
>
<ChatButton /> <ChatButton />
</ChatContext.Provider>, </ChatContext.Provider>,
{ ws: { unreadChatCount: 4 } }, { ws: { unreadChatCount: 4 } },

View File

@@ -19,7 +19,9 @@ describe("Markdown", () => {
md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done"); md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done");
expect(screen.getByText("bold").tagName).toBe("STRONG"); expect(screen.getByText("bold").tagName).toBe("STRONG");
expect(screen.getByText("gone").tagName).toBe("DEL"); expect(screen.getByText("gone").tagName).toBe("DEL");
expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap"); expect(screen.getByRole("table").parentElement).toHaveClass(
"md-table-wrap",
);
expect(screen.getByRole("checkbox")).toBeChecked(); expect(screen.getByRole("checkbox")).toBeChecked();
}); });
@@ -51,11 +53,25 @@ describe("Markdown", () => {
md( md(
`[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`, `[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`,
); );
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank"); expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer"); "target",
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target"); "_blank",
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y"); );
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank"); expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute(
"rel",
"noopener noreferrer",
);
expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute(
"target",
);
expect(screen.getByRole("link", { name: "same" })).toHaveAttribute(
"href",
"/playlists/p?x=1#y",
);
expect(screen.getByRole("link", { name: "file" })).toHaveAttribute(
"target",
"_blank",
);
}); });
test("a single newline is a line break", () => { test("a single newline is a line break", () => {
@@ -71,7 +87,10 @@ describe("Markdown", () => {
test("images load lazily; inline teasers drop them", () => { test("images load lazily; inline teasers drop them", () => {
const full = md("![cat](https://x.test/cat.png)"); const full = md("![cat](https://x.test/cat.png)");
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy"); expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute(
"loading",
"lazy",
);
full.unmount(); full.unmount();
md("![cat](https://x.test/cat.png) text", true); md("![cat](https://x.test/cat.png) text", true);
expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
@@ -96,13 +115,18 @@ describe("Thumbnail", () => {
test("direct, then proxied, then the placeholder", () => { test("direct, then proxied, then the placeholder", () => {
renderWithContexts( renderWithContexts(
<Thumbnail src="https://img.test/a.png" alt="art" placeholder={placeholder} />, <Thumbnail
src="https://img.test/a.png"
alt="art"
placeholder={placeholder}
/>,
); );
const img = screen.getByRole("img", { name: "art" }); const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png"); expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
fireEvent.error(img); fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain( expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
.toContain(
"/api/proxy-image?url=", "/api/proxy-image?url=",
); );
fireEvent.error(screen.getByRole("img", { name: "art" })); fireEvent.error(screen.getByRole("img", { name: "art" }));
@@ -112,16 +136,28 @@ describe("Thumbnail", () => {
test("our own images skip the proxy retry", () => { test("our own images skip the proxy retry", () => {
renderWithContexts( renderWithContexts(
<Thumbnail src="http://localhost:8000/api/thumbnails/d" alt="t" placeholder={placeholder} />, <Thumbnail
src="http://localhost:8000/api/thumbnails/d"
alt="t"
placeholder={placeholder}
/>,
); );
fireEvent.error(screen.getByRole("img", { name: "t" })); fireEvent.error(screen.getByRole("img", { name: "t" }));
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
}); });
test("no src: straight to the placeholder; a new src tries again", () => { test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(<Thumbnail placeholder={placeholder} alt="t" />); const { rerender } = renderWithContexts(
<Thumbnail placeholder={placeholder} alt="t" />,
);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(<Thumbnail src="https://img.test/b.png" placeholder={placeholder} alt="t" />); rerender(
<Thumbnail
src="https://img.test/b.png"
placeholder={placeholder}
alt="t"
/>,
);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();
}); });
}); });
@@ -129,17 +165,31 @@ describe("Thumbnail", () => {
describe("ThumbnailPlaceholder", () => { describe("ThumbnailPlaceholder", () => {
test("tints by the page colour, or by hostname", () => { test("tints by the page colour, or by hostname", () => {
const { container, rerender } = renderWithContexts( const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="#123456" />, <ThumbnailPlaceholder
url="https://www.site.test/x"
accentColor="#123456"
/>,
); );
const box = () => container.querySelector<HTMLElement>(".thumb-placeholder")!; const box = () =>
container.querySelector<HTMLElement>(".thumb-placeholder")!;
expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456"); expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456");
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />); rerender(
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test")); <ThumbnailPlaceholder
url="https://www.site.test/x"
accentColor="url(evil)"
/>,
);
expect(box().style.getPropertyValue("--thumb-tint")).toBe(
hueFromString("site.test"),
);
}); });
test("favicon, then the initial when it fails; a glyph wins over the initial", () => { test("favicon, then the initial when it fails; a glyph wins over the initial", () => {
const { container, rerender } = renderWithContexts( const { container, rerender } = renderWithContexts(
<ThumbnailPlaceholder url="https://site.test" faviconUrl="https://site.test/f.ico" />, <ThumbnailPlaceholder
url="https://site.test"
faviconUrl="https://site.test/f.ico"
/>,
); );
const icon = container.querySelector("img")!; const icon = container.querySelector("img")!;
fireEvent.error(icon); fireEvent.error(icon);
@@ -163,10 +213,16 @@ describe("RichContentCard", () => {
test("full card: play button, source link and details", async () => { test("full card: play button, source link and details", async () => {
const play = vi.fn(); const play = vi.fn();
renderWithContexts(<RichContentCard richContent={yt} dumpHref="/dumps/d" />, { renderWithContexts(
<RichContentCard richContent={yt} dumpHref="/dumps/d" />,
{
player: { play }, player: { play },
}); },
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url); );
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute(
"href",
yt.url,
);
expect(screen.getByText("About it")).toBeInTheDocument(); expect(screen.getByText("About it")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ expect(play).toHaveBeenCalledWith(expect.objectContaining({
@@ -183,12 +239,21 @@ describe("RichContentCard", () => {
current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" }, current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" },
}, },
}); });
expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing"); expect(screen.getByRole("button", { name: "Pause" })).toHaveClass(
"is-playing",
);
}); });
test("compact and unplayable: a link to the source", () => { test("compact and unplayable: a link to the source", () => {
renderWithContexts( renderWithContexts(
<RichContentCard richContent={{ type: "link", url: "https://blog.test/p", siteName: "Blog" }} compact />, <RichContentCard
richContent={{
type: "link",
url: "https://blog.test/p",
siteName: "Blog",
}}
compact
/>,
); );
expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute(
"href", "href",
@@ -198,7 +263,10 @@ describe("RichContentCard", () => {
test("a dump thumbnail overrides the provider's", () => { test("a dump thumbnail overrides the provider's", () => {
renderWithContexts( renderWithContexts(
<RichContentCard richContent={yt} thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d" />, <RichContentCard
richContent={yt}
thumbnailOverrideUrl="http://localhost:8000/api/thumbnails/d"
/>,
); );
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src", "src",
@@ -209,7 +277,11 @@ describe("RichContentCard", () => {
test("a native stream that is the active item toggles instead of restarting", async () => { test("a native stream that is the active item toggles instead of restarting", async () => {
const togglePlay = vi.fn(); const togglePlay = vi.fn();
const play = vi.fn(); const play = vi.fn();
const bc = { type: "bandcamp", url: "https://a.bandcamp.com/album/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/1" }; const bc = {
type: "bandcamp",
url: "https://a.bandcamp.com/album/x",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/1",
};
renderWithContexts(<RichContentCard richContent={bc} compact />, { renderWithContexts(<RichContentCard richContent={bc} compact />, {
player: { player: {
togglePlay, togglePlay,
@@ -249,32 +321,57 @@ describe("DumpCard", () => {
commentCount: 2, commentCount: 2,
categoryIds: ["c1", "missing"], categoryIds: ["c1", "missing"],
}); });
renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>, { renderWithContexts(
<ul>
<DumpCard dump={dump} {...props} />
</ul>,
{
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })], categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
}); },
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice"); );
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
"href",
"/dumps/nice",
);
expect(screen.getByText("here").tagName).toBe("STRONG"); expect(screen.getByText("here").tagName).toBe("STRONG");
expect(screen.getByText("2 comments")).toBeInTheDocument(); expect(screen.getByText("2 comments")).toBeInTheDocument();
expect(screen.getByText("1 minute ago")).toBeInTheDocument(); expect(screen.getByText("1 minute ago")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music"); expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
"href",
"/music",
);
}); });
test("an unread dot until visited, never on your own", () => { test("an unread dot until visited, never on your own", () => {
const dump = makeDump({ id: "d2" }); const dump = makeDump({ id: "d2" });
const first = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>); const first = renderWithContexts(
<ul>
<DumpCard dump={dump} {...props} />
</ul>,
);
expect(first.container.querySelector(".unread-dot")).not.toBeNull(); expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount(); first.unmount();
const own = renderWithContexts(<ul><DumpCard dump={dump} {...props} isOwner /></ul>); const own = renderWithContexts(
<ul>
<DumpCard dump={dump} {...props} isOwner />
</ul>,
);
expect(own.container.querySelector(".unread-dot")).toBeNull(); expect(own.container.querySelector(".unread-dot")).toBeNull();
own.unmount(); own.unmount();
markDumpVisited("d2"); markDumpVisited("d2");
const seen = renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>); const seen = renderWithContexts(
<ul>
<DumpCard dump={dump} {...props} />
</ul>,
);
expect(seen.container.querySelector(".unread-dot")).toBeNull(); expect(seen.container.querySelector(".unread-dot")).toBeNull();
}); });
test("the private badge shows to the owner", () => { test("the private badge shows to the owner", () => {
renderWithContexts( renderWithContexts(
<ul><DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner /></ul>, <ul>
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
</ul>,
); );
expect(screen.getByText("private")).toBeInTheDocument(); expect(screen.getByText("private")).toBeInTheDocument();
}); });
@@ -283,7 +380,9 @@ describe("DumpCard", () => {
const castVote = vi.fn(); const castVote = vi.fn();
const dump = makeDump({ id: "d3" }); const dump = makeDump({ id: "d3" });
renderWithContexts( renderWithContexts(
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>, <ul>
<DumpCard dump={dump} {...props} castVote={castVote} />
</ul>,
{ session: makeSession() }, { session: makeSession() },
); );
await userEvent.click(screen.getByRole("button", { name: "Upvote" })); await userEvent.click(screen.getByRole("button", { name: "Upvote" }));
@@ -296,11 +395,21 @@ describe("DumpCard", () => {
test("file dumps get a file preview; bare URLs a placeholder", () => { test("file dumps get a file preview; bare URLs a placeholder", () => {
const { container } = renderWithContexts( const { container } = renderWithContexts(
<ul> <ul>
<DumpCard dump={makeDump({ kind: "file", fileName: "a.pdf", fileMime: "application/pdf", fileSize: 10, url: undefined })} {...props} /> <DumpCard
dump={makeDump({
kind: "file",
fileName: "a.pdf",
fileMime: "application/pdf",
fileSize: 10,
url: undefined,
})}
{...props}
/>
<DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} /> <DumpCard dump={makeDump({ url: "https://plain.test" })} {...props} />
</ul>, </ul>,
); );
expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0); expect(container.querySelectorAll(".thumb-placeholder").length)
.toBeGreaterThan(0);
const cards = container.querySelectorAll(".dump-card"); const cards = container.querySelectorAll(".dump-card");
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument(); expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
}); });

View File

@@ -20,10 +20,10 @@ import { CountedInput } from "../../components/CountedInput.tsx";
import { PageError } from "../../components/PageError.tsx"; import { PageError } from "../../components/PageError.tsx";
import { AuthContext } from "../../contexts/AuthContext.ts"; import { AuthContext } from "../../contexts/AuthContext.ts";
import { ChatContext } from "../../contexts/ChatContext.ts"; import { ChatContext } from "../../contexts/ChatContext.ts";
import { WSContext } from "../../contexts/WSContext.ts"; import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts";
import type { AuthResponse, Category } from "../../model.ts"; import type { AuthResponse, Category } from "../../model.ts";
import { makeCategory, makeSession, makeUser } from "../fixtures.ts"; import { makeCategory, makeSession, makeUser } from "../fixtures.ts";
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx"; import { currentPath, renderWithContexts } from "../render.tsx";
const categories: Category[] = [ const categories: Category[] = [
makeCategory({ id: "c1", slug: "music", name: "Music" }), makeCategory({ id: "c1", slug: "music", name: "Music" }),
@@ -67,7 +67,12 @@ describe("SearchBar", () => {
}); });
test("takes focus when it opens, not when it mounts open", () => { test("takes focus when it opens, not when it mounts open", () => {
const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} }; const props = {
value: "",
onValueChange: () => {},
onSubmit: () => {},
onCancel: () => {},
};
const { rerender } = renderWithContexts(<SearchBar expanded {...props} />); const { rerender } = renderWithContexts(<SearchBar expanded {...props} />);
expect(screen.getByRole("searchbox")).not.toHaveFocus(); expect(screen.getByRole("searchbox")).not.toHaveFocus();
rerender(<SearchBar expanded={false} {...props} />); rerender(<SearchBar expanded={false} {...props} />);
@@ -91,7 +96,10 @@ describe("UserMenu", () => {
const trigger = screen.getByRole("button", { name: "User menu" }); const trigger = screen.getByRole("button", { name: "User menu" });
await user.click(trigger); await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true"); expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann"); expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute(
"href",
"/users/ann",
);
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute( expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href", "href",
"/users/ann/playlists", "/users/ann/playlists",
@@ -136,10 +144,13 @@ describe("PresenceRow", () => {
}); });
test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => { test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => {
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100); const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get")
.mockReturnValue(100);
renderWithContexts( renderWithContexts(
<div className="bar"> <div className="bar">
<div className="tabs"><span>a</span></div> <div className="tabs">
<span>a</span>
</div>
<PresenceRow fitWithin=".bar" fitBeside=".tabs" /> <PresenceRow fitWithin=".bar" fitBeside=".tabs" />
</div>, </div>,
{ ws: { onlineUsers: online(9) } }, { ws: { onlineUsers: online(9) } },
@@ -155,7 +166,10 @@ describe("PresenceRow", () => {
describe("CategorySwitcher", () => { describe("CategorySwitcher", () => {
test("hidden with no categories", () => { test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher />, { route: "/~/hot", path: "/:x/:feedTab" }); renderWithContexts(<CategorySwitcher />, {
route: "/~/hot",
path: "/:x/:feedTab",
});
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
@@ -168,7 +182,10 @@ describe("CategorySwitcher", () => {
}); });
const trigger = screen.getByRole("button", { name: "Music" }); const trigger = screen.getByRole("button", { name: "Music" });
await user.click(trigger); await user.click(trigger);
expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute(
"aria-selected",
"true",
);
await user.click(screen.getByRole("option", { name: "Cinema" })); await user.click(screen.getByRole("option", { name: "Cinema" }));
expect(currentPath()).toBe("/cinema/new"); expect(currentPath()).toBe("/cinema/new");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
@@ -188,7 +205,11 @@ describe("CategorySwitcher", () => {
test("Escape closes the menu", async () => { test("Escape closes the menu", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<CategorySwitcher />, { categories, route: "/~/hot", path: "/~/:feedTab" }); renderWithContexts(<CategorySwitcher />, {
categories,
route: "/~/hot",
path: "/~/:feedTab",
});
await user.click(screen.getByRole("button", { name: "All" })); await user.click(screen.getByRole("button", { name: "All" }));
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
@@ -199,17 +220,28 @@ describe("CategorySelect", () => {
test("toggle chips reflect the selection", async () => { test("toggle chips reflect the selection", async () => {
const onToggle = vi.fn(); const onToggle = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, { renderWithContexts(
<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />,
{
categories, categories,
}); },
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true"); );
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false"); expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute(
"aria-pressed",
"false",
);
await user.click(screen.getByRole("button", { name: "Music" })); await user.click(screen.getByRole("button", { name: "Music" }));
expect(onToggle).toHaveBeenCalledWith("c1"); expect(onToggle).toHaveBeenCalledWith("c1");
}); });
test("nothing without categories", () => { test("nothing without categories", () => {
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />); renderWithContexts(
<CategorySelect selected={new Set()} onToggle={() => {}} />,
);
expect(screen.queryByText("Categories")).not.toBeInTheDocument(); expect(screen.queryByText("Categories")).not.toBeInTheDocument();
}); });
}); });
@@ -232,8 +264,13 @@ describe("TabBar / FeedTabBar", () => {
test("guests don't get Followed; tabs navigate within the scope", async () => { test("guests don't get Followed; tabs navigate within the scope", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories }); renderWithContexts(<FeedTabBar />, {
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument(); route: "/music/hot",
path: "/:categorySlug/:feedTab",
categories,
});
expect(screen.queryByRole("button", { name: "Followed" })).not
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active"); expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
await user.click(screen.getByRole("button", { name: "Journal" })); await user.click(screen.getByRole("button", { name: "Journal" }));
expect(currentPath()).toBe("/music/journal"); expect(currentPath()).toBe("/music/journal");
@@ -245,7 +282,9 @@ describe("TabBar / FeedTabBar", () => {
route: "/~/followed", route: "/~/followed",
path: "/~/:feedTab", path: "/~/:feedTab",
}); });
expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active"); expect(screen.getByRole("button", { name: "Followed" })).toHaveClass(
"active",
);
}); });
}); });
@@ -258,10 +297,16 @@ describe("AppHeader", () => {
}); });
test("members get their links, chat, bell and the new-dump button", () => { test("members get their links, chat, bell and the new-dump button", () => {
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) }); renderWithContexts(<AppHeader disableNew />, {
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann"); session: makeSession({ username: "ann" }),
});
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute(
"href",
"/users/ann",
);
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Notifications" }))
.toBeInTheDocument();
const dump = screen.getByRole("button", { name: /Dump/ }); const dump = screen.getByRole("button", { name: /Dump/ });
expect(dump).toBeDisabled(); expect(dump).toBeDisabled();
expect(dump).toHaveAttribute("title", "Server unreachable"); expect(dump).toHaveAttribute("title", "Server unreachable");
@@ -281,7 +326,8 @@ describe("AppHeader", () => {
renderWithContexts(<AppHeader />); renderWithContexts(<AppHeader />);
const toggle = screen.getByRole("button", { name: "Search" }); const toggle = screen.getByRole("button", { name: "Search" });
await user.click(toggle); await user.click(toggle);
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute( expect(screen.getByRole("button", { name: "Cancel search" }))
.toHaveAttribute(
"aria-expanded", "aria-expanded",
"true", "true",
); );
@@ -303,7 +349,7 @@ describe("AppHeader", () => {
return ( return (
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<AuthContext.Provider value={{ authResponse, setAuthResponse }}> <AuthContext.Provider value={{ authResponse, setAuthResponse }}>
<WSContext.Provider value={defaultOf(WSContext)}> <WSContext.Provider value={defaultWSValue}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="*" element={<AppHeader />} /> <Route path="*" element={<AppHeader />} />
@@ -357,15 +403,25 @@ describe("AppHeader", () => {
describe("floating buttons", () => { describe("floating buttons", () => {
function withChat(openChat = vi.fn()) { function withChat(openChat = vi.fn()) {
return (ui: React.ReactNode) => ( return (ui: React.ReactNode) => (
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}> <ChatContext.Provider
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
>
{ui} {ui}
</ChatContext.Provider> </ChatContext.Provider>
); );
} }
test("guests get neither", () => { test("guests get neither", () => {
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>)); renderWithContexts(
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument(); withChat()(
<>
<ChatFab />
<DumpFab />
</>,
),
);
expect(screen.queryByRole("button", { hidden: true })).not
.toBeInTheDocument();
}); });
test("they show once the header scrolls away", () => { test("they show once the header scrolls away", () => {
@@ -400,14 +456,20 @@ describe("floating buttons", () => {
expect(chat).toHaveClass("chat-fab--visible"); expect(chat).toHaveClass("chat-fab--visible");
act(() => chat.click()); act(() => chat.click());
expect(openChat).toHaveBeenCalled(); expect(openChat).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible"); expect(screen.getByRole("button", { name: "New dump" })).toHaveClass(
"dump-fab--visible",
);
}); });
}); });
describe("small pieces", () => { describe("small pieces", () => {
test("Tooltip shows on hover", async () => { test("Tooltip shows on hover", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<Tooltip text="Explained"><span>hover me</span></Tooltip>); renderWithContexts(
<Tooltip text="Explained">
<span>hover me</span>
</Tooltip>,
);
await user.hover(screen.getByText("hover me")); await user.hover(screen.getByText("hover me"));
expect(screen.getByText("Explained")).toBeInTheDocument(); expect(screen.getByText("Explained")).toBeInTheDocument();
await user.unhover(screen.getByText("hover me")); await user.unhover(screen.getByText("hover me"));
@@ -415,7 +477,13 @@ describe("small pieces", () => {
}); });
test("CountedInput counts toward its limit", () => { test("CountedInput counts toward its limit", () => {
renderWithContexts(<CountedInput value={"x".repeat(90)} maxLength={100} onChange={() => {}} />); renderWithContexts(
<CountedInput
value={"x".repeat(90)}
maxLength={100}
onChange={() => {}}
/>,
);
const count = screen.getByText("90 / 100"); const count = screen.getByText("90 / 100");
expect(count).toHaveClass("text-editor-count--warn"); expect(count).toHaveClass("text-editor-count--warn");
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100"); expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");

View File

@@ -10,7 +10,10 @@ function media(container: HTMLElement) {
return container.querySelector<HTMLMediaElement>("audio, video")!; return container.querySelector<HTMLMediaElement>("audio, video")!;
} }
function setTime(el: HTMLMediaElement, currentTime: number, duration: number) { function setTime(el: HTMLMediaElement, currentTime: number, duration: number) {
Object.defineProperty(el, "duration", { configurable: true, value: duration }); Object.defineProperty(el, "duration", {
configurable: true,
value: duration,
});
el.currentTime = currentTime; el.currentTime = currentTime;
fireEvent(el, new Event("durationchange")); fireEvent(el, new Event("durationchange"));
fireEvent(el, new Event("timeupdate")); fireEvent(el, new Event("timeupdate"));
@@ -22,7 +25,12 @@ describe("MediaPlayer", () => {
const play = vi.spyOn(HTMLMediaElement.prototype, "play"); const play = vi.spyOn(HTMLMediaElement.prototype, "play");
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause"); const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
renderWithContexts( renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onPlayStateChange={onPlayStateChange}
/>,
); );
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalled(); expect(play).toHaveBeenCalled();
@@ -35,15 +43,30 @@ describe("MediaPlayer", () => {
test("autoplays when asked", async () => { test("autoplays when asked", async () => {
const onPlayStateChange = vi.fn(); const onPlayStateChange = vi.fn();
renderWithContexts( renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay onPlayStateChange={onPlayStateChange} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
autoplay
onPlayStateChange={onPlayStateChange}
/>,
); );
await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true)); await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true));
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
}); });
test("a play refused by the browser stays paused", async () => { test("a play refused by the browser stays paused", async () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError")); vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(
renderWithContexts(<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay />); new Error("NotAllowedError"),
);
renderWithContexts(
<MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
autoplay
/>,
);
await act(async () => {}); await act(async () => {});
expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument();
}); });
@@ -51,7 +74,12 @@ describe("MediaPlayer", () => {
test("a pause from outside the page (a media key) is reflected", async () => { test("a pause from outside the page (a media key) is reflected", async () => {
const onPlayStateChange = vi.fn(); const onPlayStateChange = vi.fn();
const { container } = renderWithContexts( const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onPlayStateChange={onPlayStateChange} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onPlayStateChange={onPlayStateChange}
/>,
); );
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
fireEvent(media(container), new Event("pause")); fireEvent(media(container), new Event("pause"));
@@ -79,7 +107,9 @@ describe("MediaPlayer", () => {
expect(screen.getByText("1:05")).toBeInTheDocument(); expect(screen.getByText("1:05")).toBeInTheDocument();
expect(screen.getByText("3:20")).toBeInTheDocument(); expect(screen.getByText("3:20")).toBeInTheDocument();
expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200); expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200);
fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { target: { value: "120" } }); fireEvent.change(screen.getByRole("slider", { name: "Seek" }), {
target: { value: "120" },
});
expect(el.currentTime).toBe(120); expect(el.currentTime).toBe(120);
fireEvent(el, new Event("ended")); fireEvent(el, new Event("ended"));
expect(onEnded).toHaveBeenCalled(); expect(onEnded).toHaveBeenCalled();
@@ -104,7 +134,12 @@ describe("MediaPlayer", () => {
test("an error is reported for re-resolving", () => { test("an error is reported for re-resolving", () => {
const onError = vi.fn(); const onError = vi.fn();
const { container } = renderWithContexts( const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" onError={onError} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
onError={onError}
/>,
); );
fireEvent(media(container), new Event("error")); fireEvent(media(container), new Event("error"));
expect(onError).toHaveBeenCalled(); expect(onError).toHaveBeenCalled();
@@ -114,14 +149,21 @@ describe("MediaPlayer", () => {
const { container } = renderWithContexts( const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />, <MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" />,
); );
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } }); fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
target: { value: "0.4" },
});
expect(media(container).volume).toBeCloseTo(0.4); expect(media(container).volume).toBeCloseTo(0.4);
await userEvent.click(screen.getByRole("button", { name: "Mute" })); await userEvent.click(screen.getByRole("button", { name: "Mute" }));
expect(media(container).muted).toBe(true); expect(media(container).muted).toBe(true);
expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ volume: 0.4, muted: true }); expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({
volume: 0.4,
muted: true,
});
expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0"); expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0");
// Raising the volume unmutes. // Raising the volume unmutes.
fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } }); fireEvent.change(screen.getByRole("slider", { name: "Volume" }), {
target: { value: "0.7" },
});
expect(media(container).muted).toBe(false); expect(media(container).muted).toBe(false);
}); });
@@ -129,7 +171,13 @@ describe("MediaPlayer", () => {
const seekRef = { current: null as ((t: number) => void) | null }; const seekRef = { current: null as ((t: number) => void) | null };
const toggleRef = { current: null as (() => void) | null }; const toggleRef = { current: null as (() => void) | null };
const { container, unmount } = renderWithContexts( const { container, unmount } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" seekRef={seekRef} toggleRef={toggleRef} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
seekRef={seekRef}
toggleRef={toggleRef}
/>,
); );
act(() => seekRef.current!(33)); act(() => seekRef.current!(33));
expect(media(container).currentTime).toBe(33); expect(media(container).currentTime).toBe(33);
@@ -141,7 +189,12 @@ describe("MediaPlayer", () => {
test("resumes at a start offset once metadata is in", () => { test("resumes at a start offset once metadata is in", () => {
const { container } = renderWithContexts( const { container } = renderWithContexts(
<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" startTime={42} />, <MediaPlayer
src="https://s/a.mp3"
kind="audio"
trackStyle="progress"
startTime={42}
/>,
); );
fireEvent(media(container), new Event("loadedmetadata")); fireEvent(media(container), new Event("loadedmetadata"));
expect(media(container).currentTime).toBe(42); expect(media(container).currentTime).toBe(42);
@@ -149,14 +202,23 @@ describe("MediaPlayer", () => {
test("video has a fullscreen button", () => { test("video has a fullscreen button", () => {
renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />); renderWithContexts(<MediaPlayer src="https://s/a.mp4" kind="video" />);
expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Fullscreen" }))
.toBeInTheDocument();
}); });
}); });
describe("FilePreview", () => { describe("FilePreview", () => {
const session = makeSession(); const session = makeSession();
const file = (mime: string, over = {}) => const file = (mime: string, over = {}) =>
makeDump({ id: "f1", kind: "file", fileName: "thing", fileMime: mime, fileSize: 2048, url: undefined, ...over }); makeDump({
id: "f1",
kind: "file",
fileName: "thing",
fileMime: mime,
fileSize: 2048,
url: undefined,
...over,
});
test("images show inline, with the media token", () => { test("images show inline, with the media token", () => {
renderWithContexts(<FilePreview dump={file("image/png")} />, { session }); renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
@@ -175,7 +237,9 @@ describe("FilePreview", () => {
test("compact audio plays in the global player", async () => { test("compact audio plays in the global player", async () => {
const play = vi.fn(); const play = vi.fn();
renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, { player: { play } }); renderWithContexts(<FilePreview dump={file("audio/mpeg")} compact />, {
player: { play },
});
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ expect(play).toHaveBeenCalledWith(expect.objectContaining({
kind: "file", kind: "file",
@@ -189,9 +253,13 @@ describe("FilePreview", () => {
test("compact video claims its generated still as artwork", async () => { test("compact video claims its generated still as artwork", async () => {
const play = vi.fn(); const play = vi.fn();
renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, { player: { play } }); renderWithContexts(<FilePreview dump={file("video/mp4")} compact />, {
player: { play },
});
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1"); expect(play.mock.calls[0][0].artworkUrl).toBe(
"http://localhost:8000/api/thumbnails/f1",
);
}); });
test("the global video button toggles when it's the one playing", async () => { test("the global video button toggles when it's the one playing", async () => {
@@ -201,7 +269,11 @@ describe("FilePreview", () => {
player: { player: {
togglePlay, togglePlay,
playing: true, playing: true,
current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" }, current: {
kind: "file",
fileUrl: "http://localhost:8000/api/files/f1?v=2048",
mimeType: "video/mp4",
},
}, },
}); });
await userEvent.click(screen.getByRole("button", { name: "Pause" })); await userEvent.click(screen.getByRole("button", { name: "Pause" }));
@@ -211,13 +283,19 @@ describe("FilePreview", () => {
test("the global audio waveform starts playback", async () => { test("the global audio waveform starts playback", async () => {
const play = vi.fn(); const play = vi.fn();
route("GET /api/files/:id", () => new TypeError("no audio in jsdom")); route("GET /api/files/:id", () => new TypeError("no audio in jsdom"));
renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, { player: { play } }); renderWithContexts(<FilePreview dump={file("audio/ogg")} global />, {
player: { play },
});
await userEvent.click(screen.getByRole("button", { name: "Play" })); await userEvent.click(screen.getByRole("button", { name: "Play" }));
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" })); expect(play).toHaveBeenCalledWith(
expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }),
);
}); });
test("compact PDFs and text get an icon", () => { test("compact PDFs and text get an icon", () => {
const { container } = renderWithContexts(<FilePreview dump={file("application/pdf")} compact />); const { container } = renderWithContexts(
<FilePreview dump={file("application/pdf")} compact />,
);
expect(container).toHaveTextContent("📄"); expect(container).toHaveTextContent("📄");
}); });
}); });

View File

@@ -6,7 +6,10 @@ import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
import { JournalCard } from "../../components/JournalCard.tsx"; import { JournalCard } from "../../components/JournalCard.tsx";
import { PlaylistCard } from "../../components/PlaylistCard.tsx"; import { PlaylistCard } from "../../components/PlaylistCard.tsx";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts"; import {
PlayerContext,
type PlayerItem,
} from "../../contexts/PlayerContext.ts";
import { makeDump, makePlaylist } from "../fixtures.ts"; import { makeDump, makePlaylist } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx"; import { currentPath, renderWithContexts } from "../render.tsx";
import { markPlaylistVisited } from "../../utils/visited.ts"; import { markPlaylistVisited } from "../../utils/visited.ts";
@@ -43,7 +46,14 @@ describe("GlobalPlayer", () => {
test("hidden until something plays; the body makes room for it", () => { test("hidden until something plays; the body makes room for it", () => {
renderPlayer(); renderPlayer();
expect(document.querySelector(".global-player")).toBeNull(); expect(document.querySelector(".global-player")).toBeNull();
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" })); act(() =>
player.play({
kind: "embed",
embedUrl: "https://w.soundcloud.com/player/?url=x",
type: "soundcloud",
title: "Mix",
})
);
expect(document.querySelector(".global-player--soundcloud")).not.toBeNull(); expect(document.querySelector(".global-player--soundcloud")).not.toBeNull();
expect(document.body).toHaveClass("has-player"); expect(document.body).toHaveClass("has-player");
act(() => player.stop()); act(() => player.stop());
@@ -52,27 +62,58 @@ describe("GlobalPlayer", () => {
}); });
test("an embed autoplays on a fresh play only", () => { test("an embed autoplays on a fresh play only", () => {
localStorage.setItem("player", JSON.stringify({ localStorage.setItem(
"player",
JSON.stringify({
v: 2, v: 2,
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }], queue: [{
kind: "embed",
embedUrl: "https://www.youtube.com/embed/abc",
type: "youtube",
}],
index: 0, index: 0,
time: 0, time: 0,
owner: null, owner: null,
})); }),
);
renderPlayer(); renderPlayer();
const iframe = () => document.querySelector("iframe")!; const iframe = () => document.querySelector("iframe")!;
expect(iframe().src).toBe("https://www.youtube.com/embed/abc"); expect(iframe().src).toBe("https://www.youtube.com/embed/abc");
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" })); act(() =>
player.play({
kind: "embed",
embedUrl: "https://www.youtube.com/embed/def",
type: "youtube",
})
);
expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1"); expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1");
act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" })); act(() =>
player.play({
kind: "embed",
embedUrl: "https://w.soundcloud.com/player/?url=y",
type: "soundcloud",
})
);
expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true"); expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true");
act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" })); act(() =>
player.play({
kind: "embed",
embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1",
type: "bandcamp",
})
);
expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1"); expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1");
}); });
test("a YouTube playlist embed gets the JS API parameters", () => { test("a YouTube playlist embed gets the JS API parameters", () => {
renderPlayer(); renderPlayer();
act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" })); act(() =>
player.play({
kind: "embed",
embedUrl: "https://www.youtube.com/embed/videoseries?list=PL",
type: "youtube",
})
);
const src = new URL(document.querySelector("iframe")!.src); const src = new URL(document.querySelector("iframe")!.src);
expect(src.searchParams.get("enablejsapi")).toBe("1"); expect(src.searchParams.get("enablejsapi")).toBe("1");
expect(src.searchParams.get("list")).toBe("PL"); expect(src.searchParams.get("list")).toBe("PL");
@@ -82,10 +123,14 @@ describe("GlobalPlayer", () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderPlayer(); renderPlayer();
act(() => player.playQueue([track(0), track(1), track(2)], 0)); act(() => player.playQueue([track(0), track(1), track(2)], 0));
expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album"); expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute(
"href",
"/dumps/album",
);
expect(screen.getByText("Band")).toBeInTheDocument(); expect(screen.getByText("Band")).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument(); expect(screen.getByText("1 / 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Previous track" }))
.toBeDisabled();
const list = screen.getByRole("list"); const list = screen.getByRole("list");
expect(within(list).getAllByRole("button")).toHaveLength(3); expect(within(list).getAllByRole("button")).toHaveLength(3);
@@ -105,7 +150,8 @@ describe("GlobalPlayer", () => {
act(() => player.playQueue([track(0), track(1)])); act(() => player.playQueue([track(0), track(1)]));
await user.click(screen.getByRole("button", { name: "Collapse player" })); await user.click(screen.getByRole("button", { name: "Collapse player" }));
expect(screen.queryByRole("list")).not.toBeInTheDocument(); expect(screen.queryByRole("list")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Expand player" }))
.toBeInTheDocument();
act(() => player.playQueue([track(2), track(3)])); act(() => player.playQueue([track(2), track(3)]));
expect(screen.getByRole("list")).toBeInTheDocument(); expect(screen.getByRole("list")).toBeInTheDocument();
}); });
@@ -120,8 +166,16 @@ describe("GlobalPlayer", () => {
test("a file plays in the built-in player", () => { test("a file plays in the built-in player", () => {
renderPlayer(); renderPlayer();
act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" })); act(() =>
expect(document.querySelector(".global-player--file-video video")).not.toBeNull(); player.play({
kind: "file",
fileUrl: "http://localhost:8000/api/files/x",
mimeType: "video/mp4",
title: "Clip",
})
);
expect(document.querySelector(".global-player--file-video video")).not
.toBeNull();
expect(screen.getByText("Clip")).toBeInTheDocument(); expect(screen.getByText("Clip")).toBeInTheDocument();
}); });
@@ -138,24 +192,44 @@ describe("GlobalPlayer", () => {
}); });
describe("JournalCard", () => { describe("JournalCard", () => {
const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() }; const base = {
voteCount: 1,
voted: false,
canVote: true,
castVote: vi.fn(),
removeVote: vi.fn(),
};
test("image, quote and text modes", () => { test("image, quote and text modes", () => {
const { container, unmount } = renderWithContexts( const { container, unmount } = renderWithContexts(
<ul> <ul>
<JournalCard <JournalCard
dump={makeDump({ richContent: { type: "link", url: "u", thumbnailUrl: "https://i.test/a.png" } })} dump={makeDump({
richContent: {
type: "link",
url: "u",
thumbnailUrl: "https://i.test/a.png",
},
})}
shape="feature" shape="feature"
{...base} {...base}
/> />
<JournalCard dump={makeDump({ comment: "A thought" })} shape="square" {...base} /> <JournalCard
dump={makeDump({ comment: "A thought" })}
shape="square"
{...base}
/>
<JournalCard dump={makeDump()} shape="square" {...base} /> <JournalCard dump={makeDump()} shape="square" {...base} />
</ul>, </ul>,
); );
const cards = container.querySelectorAll("li"); const cards = container.querySelectorAll("li");
expect(cards[0]).toHaveClass("journal-card--image", "journal-card--feature"); expect(cards[0]).toHaveClass(
"journal-card--image",
"journal-card--feature",
);
expect(cards[1]).toHaveClass("journal-card--quote"); expect(cards[1]).toHaveClass("journal-card--quote");
expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument(); expect(within(cards[1] as HTMLElement).getByText("A thought"))
.toBeInTheDocument();
expect(cards[2]).toHaveClass("journal-card--text"); expect(cards[2]).toHaveClass("journal-card--text");
expect(cards[2]).toHaveTextContent("🔗"); expect(cards[2]).toHaveTextContent("🔗");
unmount(); unmount();
@@ -173,7 +247,13 @@ describe("JournalCard", () => {
embedUrl: "https://www.youtube.com/embed/y", embedUrl: "https://www.youtube.com/embed/y",
}, },
}); });
const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined }); const pic = makeDump({
id: "p",
kind: "file",
fileMime: "image/png",
fileName: "p.png",
url: undefined,
});
const { container } = renderWithContexts( const { container } = renderWithContexts(
<ul> <ul>
<JournalCard dump={yt} shape="wide" {...base} /> <JournalCard dump={yt} shape="wide" {...base} />
@@ -183,7 +263,9 @@ describe("JournalCard", () => {
); );
const cards = container.querySelectorAll("li"); const cards = container.querySelectorAll("li");
await user.click(cards[0].querySelector(".journal-card-image")!); await user.click(cards[0].querySelector(".journal-card-image")!);
expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" })); expect(play).toHaveBeenCalledWith(
expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }),
);
expect(currentPath()).toBe("/"); expect(currentPath()).toBe("/");
await user.click(cards[1].querySelector(".journal-card-image")!); await user.click(cards[1].querySelector(".journal-card-image")!);
expect(currentPath()).toBe("/dumps/p"); expect(currentPath()).toBe("/dumps/p");
@@ -192,21 +274,50 @@ describe("JournalCard", () => {
test("a video file shows its generated still", () => { test("a video file shows its generated still", () => {
const { container } = renderWithContexts( const { container } = renderWithContexts(
<ul> <ul>
<JournalCard dump={makeDump({ id: "v", kind: "file", fileMime: "video/mp4", url: undefined })} shape="tall" {...base} /> <JournalCard
dump={makeDump({
id: "v",
kind: "file",
fileMime: "video/mp4",
url: undefined,
})}
shape="tall"
{...base}
/>
</ul>, </ul>,
); );
expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v"); expect(container.querySelector("img")!.getAttribute("src")).toBe(
"http://localhost:8000/api/thumbnails/v",
);
}); });
}); });
describe("PlaylistCard", () => { describe("PlaylistCard", () => {
test("title, badge, owner, count; clicking opens it and marks it seen", async () => { test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" }); const p = makePlaylist({
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>); id: "p1",
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix"); slug: "mix",
title: "Mix",
ownerUsername: "ann",
dumpCount: 1,
isPublic: false,
description: "Tunes",
});
const { container } = renderWithContexts(
<ul>
<PlaylistCard playlist={p} />
</ul>,
);
expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute(
"href",
"/playlists/mix",
);
expect(screen.getByText("private")).toBeInTheDocument(); expect(screen.getByText("private")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann"); expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute(
"href",
"/users/ann",
);
expect(screen.getByText("1 dump")).toBeInTheDocument(); expect(screen.getByText("1 dump")).toBeInTheDocument();
expect(screen.getByText("Tunes")).toBeInTheDocument(); expect(screen.getByText("Tunes")).toBeInTheDocument();
await user.click(container.querySelector(".playlist-card-inner")!); await user.click(container.querySelector(".playlist-card-inner")!);
@@ -215,28 +326,43 @@ describe("PlaylistCard", () => {
test("the owner doesn't see their own name, or an unread dot", () => { test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(<ul><PlaylistCard playlist={p} isOwner /></ul>); const { container } = renderWithContexts(
<ul>
<PlaylistCard playlist={p} isOwner />
</ul>,
);
expect(screen.queryByText("@me")).not.toBeInTheDocument(); expect(screen.queryByText("@me")).not.toBeInTheDocument();
expect(container.querySelector(".unread-dot")).toBeNull(); expect(container.querySelector(".unread-dot")).toBeNull();
}); });
test("a new playlist has a dot until visited", () => { test("a new playlist has a dot until visited", () => {
const p = makePlaylist({ id: "fresh", createdAt: new Date() }); const p = makePlaylist({ id: "fresh", createdAt: new Date() });
const first = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>); const first = renderWithContexts(
<ul>
<PlaylistCard playlist={p} />
</ul>,
);
expect(first.container.querySelector(".unread-dot")).not.toBeNull(); expect(first.container.querySelector(".unread-dot")).not.toBeNull();
first.unmount(); first.unmount();
markPlaylistVisited("fresh"); markPlaylistVisited("fresh");
const again = renderWithContexts(<ul><PlaylistCard playlist={p} /></ul>); const again = renderWithContexts(
<ul>
<PlaylistCard playlist={p} />
</ul>,
);
expect(again.container.querySelector(".unread-dot")).toBeNull(); expect(again.container.querySelector(".unread-dot")).toBeNull();
}); });
test("delete doesn't open the playlist", async () => { test("delete doesn't open the playlist", async () => {
const onDelete = vi.fn(); const onDelete = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<ul><PlaylistCard playlist={makePlaylist()} onDelete={onDelete} /></ul>); renderWithContexts(
<ul>
<PlaylistCard playlist={makePlaylist()} onDelete={onDelete} />
</ul>,
);
await user.click(screen.getByRole("button", { name: "Delete playlist" })); await user.click(screen.getByRole("button", { name: "Delete playlist" }));
expect(onDelete).toHaveBeenCalled(); expect(onDelete).toHaveBeenCalled();
expect(currentPath()).toBe("/"); expect(currentPath()).toBe("/");
}); });
}); });

View File

@@ -3,7 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, useContext, useState } from "react"; import { type ReactNode, useContext, useState } from "react";
import { AuthContext } from "../../contexts/AuthContext.ts"; import { AuthContext } from "../../contexts/AuthContext.ts";
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts"; import {
PlayerContext,
type PlayerItem,
} from "../../contexts/PlayerContext.ts";
import type { AuthResponse } from "../../model.ts"; import type { AuthResponse } from "../../model.ts";
import { STREAM_STALE_MS } from "../../utils/streamSources.ts"; import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
import { calls, deferred, fail, route } from "../fetch.ts"; import { calls, deferred, fail, route } from "../fetch.ts";
@@ -205,7 +208,13 @@ describe("persistence", () => {
test("a saved session comes back paused at its offset", () => { test("a saved session comes back paused at its offset", () => {
localStorage.setItem( localStorage.setItem(
"player", "player",
JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }), JSON.stringify({
v: 2,
queue: [embed(1), embed(2)],
index: 1,
time: 17,
owner: null,
}),
); );
const { p } = setup(); const { p } = setup();
expect(p().queueIndex).toBe(1); expect(p().queueIndex).toBe(1);
@@ -223,7 +232,13 @@ describe("persistence", () => {
test("another account's queue isn't restored", () => { test("another account's queue isn't restored", () => {
localStorage.setItem( localStorage.setItem(
"player", "player",
JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }), JSON.stringify({
v: 2,
queue: [file(1)],
index: 0,
time: 0,
owner: "someone-else",
}),
); );
const { p } = setup(makeSession({ id: "me" })); const { p } = setup(makeSession({ id: "me" }));
expect(p().current).toBeNull(); expect(p().current).toBeNull();
@@ -232,7 +247,10 @@ describe("persistence", () => {
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => { test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 })); localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
expect(setup().p().current).toBeNull(); expect(setup().p().current).toBeNull();
localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 })); localStorage.setItem(
"player",
JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }),
);
expect(setup().p().current).toEqual(embed(1)); expect(setup().p().current).toEqual(embed(1));
}); });
@@ -241,7 +259,12 @@ describe("persistence", () => {
expect(setup().p().current).toBeNull(); expect(setup().p().current).toBeNull();
localStorage.setItem( localStorage.setItem(
"player", "player",
JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }), JSON.stringify({
v: 2,
queue: [{ kind: "weird" }],
index: 0,
owner: null,
}),
); );
expect(setup().p().current).toBeNull(); expect(setup().p().current).toBeNull();
}); });
@@ -330,7 +353,9 @@ describe("expiring streams", () => {
act(() => p().play(embed(9))); act(() => p().play(embed(9)));
await act(async () => await act(async () =>
pending.resolve( pending.resolve(
new Response(JSON.stringify({ success: true, data: albumResponse([0]) })), new Response(
JSON.stringify({ success: true, data: albumResponse([0]) }),
),
) )
); );
expect(p().current).toEqual(embed(9)); expect(p().current).toEqual(embed(9));
@@ -359,7 +384,9 @@ describe("expiring streams", () => {
}), }),
); );
const { p } = setup(); const { p } = setup();
await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" })); await waitFor(() =>
expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" })
);
expect(p().autoplay).toBe(false); expect(p().autoplay).toBe(false);
expect(p().startTime).toBe(25); expect(p().startTime).toBe(25);
}); });

View File

@@ -46,7 +46,9 @@ function setup({ signedIn = true } = {}) {
return { hook, socket, ws: () => hook.result.current.ws }; return { hook, socket, ws: () => hook.result.current.ws };
} }
beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })); beforeEach(() =>
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })
);
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
describe("connection", () => { describe("connection", () => {
@@ -174,7 +176,12 @@ describe("votes", () => {
expect(ws().myVotes.has("d1")).toBe(true); expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(5); expect(ws().voteCounts.d1).toBe(5);
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7 }); socket.receive({
type: "vote_ack",
dumpId: "d1",
action: "cast",
voteCount: 7,
});
expect(ws().voteCounts.d1).toBe(7); expect(ws().voteCounts.d1).toBe(7);
act(() => vi.advanceTimersByTime(10_000)); act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true); expect(ws().myVotes.has("d1")).toBe(true);
@@ -194,7 +201,12 @@ describe("votes", () => {
socket.welcome({ myVotes: ["d1"] }); socket.welcome({ myVotes: ["d1"] });
act(() => ws().removeVote("d1", 3)); act(() => ws().removeVote("d1", 3));
expect(ws().voteCounts.d1).toBe(2); expect(ws().voteCounts.d1).toBe(2);
socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove" }); socket.receive({
type: "error",
message: "nope",
dumpId: "d1",
action: "remove",
});
expect(ws().myVotes.has("d1")).toBe(true); expect(ws().myVotes.has("d1")).toBe(true);
expect(ws().voteCounts.d1).toBe(3); expect(ws().voteCounts.d1).toBe(3);
}); });
@@ -225,12 +237,34 @@ describe("votes", () => {
test("votes_update from another tab of mine syncs myVotes", () => { test("votes_update from another tab of mine syncs myVotes", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast" }); socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 1,
voterId: "user-me",
action: "cast",
});
expect(ws().myVotes.has("d9")).toBe(true); expect(ws().myVotes.has("d9")).toBe(true);
expect(ws().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" }); expect(ws().lastVoteEvent).toEqual({
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" }); dumpId: "d9",
voterId: "user-me",
action: "cast",
});
socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 2,
voterId: "other",
action: "cast",
});
expect(ws().voteCounts.d9).toBe(2); expect(ws().voteCounts.d9).toBe(2);
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" }); socket.receive({
type: "votes_update",
dumpId: "d9",
voteCount: 1,
voterId: "user-me",
action: "remove",
});
expect(ws().myVotes.has("d9")).toBe(false); expect(ws().myVotes.has("d9")).toBe(false);
}); });
@@ -239,8 +273,12 @@ describe("votes", () => {
socket.welcome(); socket.welcome();
act(() => ws().castVote("d1", 0)); act(() => ws().castVote("d1", 0));
socket.receive({ socket.receive({
type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [], type: "welcome",
unreadNotificationCount: 0, unreadChatCount: 0, users: [],
myVotes: ["d1"],
myCommentLikes: [],
unreadNotificationCount: 0,
unreadChatCount: 0,
}); });
act(() => vi.advanceTimersByTime(10_000)); act(() => vi.advanceTimersByTime(10_000));
expect(ws().myVotes.has("d1")).toBe(true); expect(ws().myVotes.has("d1")).toBe(true);
@@ -253,11 +291,22 @@ describe("comment likes", () => {
socket.welcome(); socket.welcome();
act(() => ws().castCommentLike("c1", 0)); act(() => ws().castCommentLike("c1", 0));
expect(ws().likeCounts.c1).toBe(1); expect(ws().likeCounts.c1).toBe(1);
expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" }); expect(socket.messages).toContainEqual({
socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 }); type: "comment_like_cast",
commentId: "c1",
});
socket.receive({
type: "comment_like_ack",
commentId: "c1",
action: "cast",
likeCount: 1,
});
act(() => ws().removeCommentLike("c1")); act(() => ws().removeCommentLike("c1"));
expect(ws().likeCounts.c1).toBe(0); expect(ws().likeCounts.c1).toBe(0);
expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1" }); expect(socket.messages).toContainEqual({
type: "comment_like_remove",
commentId: "c1",
});
socket.receive({ type: "error", commentId: "c1" }); socket.receive({ type: "error", commentId: "c1" });
expect(ws().myLikes.has("c1")).toBe(true); expect(ws().myLikes.has("c1")).toBe(true);
expect(ws().likeCounts.c1).toBe(1); expect(ws().likeCounts.c1).toBe(1);
@@ -275,7 +324,13 @@ describe("comment likes", () => {
test("comment_likes_update from my other tab", () => { test("comment_likes_update from my other tab", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast" }); socket.receive({
type: "comment_likes_update",
commentId: "c2",
likeCount: 3,
likerId: "user-me",
action: "cast",
});
expect(ws().myLikes.has("c2")).toBe(true); expect(ws().myLikes.has("c2")).toBe(true);
expect(ws().likeCounts.c2).toBe(3); expect(ws().likeCounts.c2).toBe(3);
expect(ws().lastLikeEvent?.commentId).toBe("c2"); expect(ws().lastLikeEvent?.commentId).toBe("c2");
@@ -290,7 +345,10 @@ describe("dumps, playlists, comments, users", () => {
expect(ws().deletedDumpIds.has("a")).toBe(true); expect(ws().deletedDumpIds.has("a")).toBe(true);
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) });
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })) }); socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "a", title: "again" })),
});
expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]); expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]);
expect(ws().recentDumps[0].title).toBe("again"); expect(ws().recentDumps[0].title).toBe("again");
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date); expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
@@ -301,7 +359,10 @@ describe("dumps, playlists, comments, users", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
for (let i = 0; i < 105; i++) { for (let i = 0; i < 105; i++) {
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) }); socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: `d${i}` })),
});
} }
expect(ws().recentDumps).toHaveLength(100); expect(ws().recentDumps).toHaveLength(100);
expect(ws().recentDumps[0].id).toBe("d104"); expect(ws().recentDumps[0].id).toBe("d104");
@@ -310,10 +371,22 @@ describe("dumps, playlists, comments, users", () => {
test("an update refreshes a live entry but doesn't make it an arrival", () => { test("an update refreshes a live entry but doesn't make it an arrival", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) }); socket.receive({
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })) }); type: "dump_created",
socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })) }); dump: wire(makeDump({ id: "a", title: "one" })),
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([["a", "two"]]); });
socket.receive({
type: "dump_updated",
dump: wire(makeDump({ id: "a", title: "two" })),
});
socket.receive({
type: "dump_updated",
dump: wire(makeDump({ id: "old" })),
});
expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([[
"a",
"two",
]]);
expect(ws().lastDumpEvent?.id).toBe("old"); expect(ws().lastDumpEvent?.id).toBe("old");
}); });
@@ -338,14 +411,31 @@ describe("dumps, playlists, comments, users", () => {
socket.welcome(); socket.welcome();
const p = makePlaylist({ id: "p" }); const p = makePlaylist({ id: "p" });
socket.receive({ type: "playlist_created", playlist: wire(p) }); socket.receive({ type: "playlist_created", playlist: wire(p) });
expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" }); expect(ws().lastPlaylistEvent).toMatchObject({
type: "created",
playlistId: "p",
});
socket.receive({ type: "playlist_updated", playlist: wire(p) }); socket.receive({ type: "playlist_updated", playlist: wire(p) });
expect(ws().lastPlaylistEvent?.type).toBe("updated"); expect(ws().lastPlaylistEvent?.type).toBe("updated");
socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 }); socket.receive({
expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 }); type: "playlist_dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
expect(ws().lastPlaylistEvent).toEqual({
type: "dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" }); socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
expect(ws().deletedPlaylistIds.has("p")).toBe(true); expect(ws().deletedPlaylistIds.has("p")).toBe(true);
expect(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" }); expect(ws().lastPlaylistEvent).toEqual({
type: "deleted",
playlistId: "p",
userId: "u",
});
}); });
test("comment events", () => { test("comment events", () => {
@@ -353,27 +443,44 @@ describe("dumps, playlists, comments, users", () => {
socket.welcome(); socket.welcome();
const c = makeComment({ id: "c", dumpId: "d" }); const c = makeComment({ id: "c", dumpId: "d" });
socket.receive({ type: "comment_created", comment: wire(c) }); socket.receive({ type: "comment_created", comment: wire(c) });
expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" }); expect(ws().lastCommentEvent).toMatchObject({
type: "created",
dumpId: "d",
});
expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date); expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date);
socket.receive({ type: "comment_updated", comment: wire(c) }); socket.receive({ type: "comment_updated", comment: wire(c) });
expect(ws().lastCommentEvent?.type).toBe("updated"); expect(ws().lastCommentEvent?.type).toBe("updated");
socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" }); socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" });
expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" }); expect(ws().lastCommentEvent).toEqual({
type: "deleted",
dumpId: "d",
commentId: "c",
});
}); });
test("user_updated overrides that user's avatar everywhere", () => { test("user_updated overrides that user's avatar everywhere", () => {
const { socket, hook } = setup(); const { socket, hook } = setup();
socket.welcome(); socket.welcome();
const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000) }); const u = makeUser({
id: "u1",
avatarMime: "image/png",
updatedAt: new Date(5000),
});
socket.receive({ type: "user_updated", user: wire(u) }); socket.receive({ type: "user_updated", user: wire(u) });
expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000 }); expect(hook.result.current.avatars.u1).toEqual({
hasAvatar: true,
version: 5000,
});
expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1"); expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1");
}); });
test("presence updates", () => { test("presence updates", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }] }); socket.receive({
type: "presence_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]); expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]);
}); });
}); });
@@ -382,7 +489,10 @@ describe("notifications", () => {
test("a new one bumps the count and is remembered", () => { test("a new one bumps the count and is remembered", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome({ unreadNotificationCount: 1 }); socket.welcome({ unreadNotificationCount: 1 });
socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) }); socket.receive({
type: "notification_created",
notification: wire(makeNotification({ id: "n" })),
});
expect(ws().unreadNotificationCount).toBe(2); expect(ws().unreadNotificationCount).toBe(2);
expect(ws().lastNotification?.id).toBe("n"); expect(ws().lastNotification?.id).toBe("n");
act(() => ws().clearUnreadNotifications()); act(() => ws().clearUnreadNotifications());
@@ -397,7 +507,10 @@ describe("chat", () => {
const m = makeChatMessage({ id: "m1", userId: "other" }); const m = makeChatMessage({ id: "m1", userId: "other" });
socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire(m) }); socket.receive({ type: "chat_message_updated", message: wire(m) });
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })) }); socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: "m2", userId: "user-me" })),
});
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]);
expect(ws().unreadChatCount).toBe(1); expect(ws().unreadChatCount).toBe(1);
}); });
@@ -406,7 +519,10 @@ describe("chat", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
for (let i = 0; i < 205; i++) { for (let i = 0; i < 205; i++) {
socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })) }); socket.receive({
type: "chat_message",
message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })),
});
} }
expect(ws().chatMessages).toHaveLength(200); expect(ws().chatMessages).toHaveLength(200);
expect(ws().chatMessages[0].id).toBe("m5"); expect(ws().chatMessages[0].id).toBe("m5");
@@ -437,7 +553,9 @@ describe("chat", () => {
test("sending shows a pending message, retired by its echo", () => { test("sending shows a pending message, retired by its echo", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome(); socket.welcome();
act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" })); act(() =>
ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" })
);
const [pending] = ws().pendingChatMessages; const [pending] = ws().pendingChatMessages;
expect(pending).toMatchObject({ body: "hello", status: "sending" }); expect(pending).toMatchObject({ body: "hello", status: "sending" });
expect(socket.messages).toContainEqual({ expect(socket.messages).toContainEqual({
@@ -482,7 +600,9 @@ describe("chat", () => {
const retry = ws().pendingChatMessages[0]; const retry = ws().pendingChatMessages[0];
expect(retry.status).toBe("sending"); expect(retry.status).toBe("sending");
expect(retry.nonce).not.toBe(first); expect(retry.nonce).not.toBe(first);
expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(2); expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(
2,
);
// The first attempt's late echo doesn't retire the retry. // The first attempt's late echo doesn't retire the retry.
socket.receive({ socket.receive({
type: "chat_message", type: "chat_message",
@@ -497,8 +617,16 @@ describe("chat", () => {
socket.welcome(); socket.welcome();
act(() => ws().sendChatMessage("hi")); act(() => ws().sendChatMessage("hi"));
const { nonce } = ws().pendingChatMessages[0]; const { nonce } = ws().pendingChatMessages[0];
socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" }); socket.receive({
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" }); type: "error",
scope: "chat",
nonce,
message: "Too long",
});
expect(ws().pendingChatMessages[0]).toMatchObject({
status: "failed",
error: "Too long",
});
act(() => ws().discardChatMessage(nonce)); act(() => ws().discardChatMessage(nonce));
expect(ws().pendingChatMessages).toEqual([]); expect(ws().pendingChatMessages).toEqual([]);
}); });
@@ -534,9 +662,15 @@ describe("chat", () => {
]); ]);
const m = makeChatMessage({ id: "m1", body: "old" }); const m = makeChatMessage({ id: "m1", body: "old" });
socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message", message: wire(m) });
socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }) }); socket.receive({
type: "chat_message_updated",
message: wire({ ...m, body: "new" }),
});
expect(ws().chatMessages[0].body).toBe("new"); expect(ws().chatMessages[0].body).toBe("new");
socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })) }); socket.receive({
type: "chat_message_updated",
message: wire(makeChatMessage({ id: "m2" })),
});
expect(ws().chatMessages).toHaveLength(2); expect(ws().chatMessages).toHaveLength(2);
socket.receive({ type: "chat_message_deleted", id: "m1" }); socket.receive({ type: "chat_message_deleted", id: "m1" });
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
@@ -548,7 +682,10 @@ describe("chat", () => {
socket.welcome(); socket.welcome();
act(() => ws().setChatTyping(true)); act(() => ws().setChatTyping(true));
expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]); expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]);
socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }] }); socket.receive({
type: "chat_typing_update",
users: [{ userId: "x", username: "x", hasAvatar: false }],
});
expect(ws().typingUsers).toHaveLength(1); expect(ws().typingUsers).toHaveLength(1);
}); });
@@ -558,11 +695,15 @@ describe("chat", () => {
act(() => ws().markChatRead(new Date(1_000_000))); act(() => ws().markChatRead(new Date(1_000_000)));
expect(ws().unreadChatCount).toBe(0); expect(ws().unreadChatCount).toBe(0);
act(() => vi.advanceTimersByTime(0)); act(() => vi.advanceTimersByTime(0));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
1,
);
act(() => ws().markChatRead(new Date(2_000_000))); act(() => ws().markChatRead(new Date(2_000_000)));
act(() => ws().markChatRead(new Date(3_000_000))); act(() => ws().markChatRead(new Date(3_000_000)));
act(() => vi.advanceTimersByTime(999)); act(() => vi.advanceTimersByTime(999));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
1,
);
act(() => vi.advanceTimersByTime(1)); act(() => vi.advanceTimersByTime(1));
const reads = socket.messages.filter((m) => m.type === "chat_read"); const reads = socket.messages.filter((m) => m.type === "chat_read");
expect(reads).toHaveLength(2); expect(reads).toHaveLength(2);
@@ -570,7 +711,9 @@ describe("chat", () => {
// Older than what's been sent: ignored. // Older than what's been sent: ignored.
act(() => ws().markChatRead(new Date(10))); act(() => ws().markChatRead(new Date(10)));
act(() => vi.advanceTimersByTime(5_000)); act(() => vi.advanceTimersByTime(5_000));
expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(2); expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(
2,
);
}); });
test("a marker set offline goes out with the next welcome", () => { test("a marker set offline goes out with the next welcome", () => {
@@ -587,7 +730,11 @@ describe("chat", () => {
test("chat_read_update from another tab moves the marker", () => { test("chat_read_update from another tab moves the marker", () => {
const { socket, ws } = setup(); const { socket, ws } = setup();
socket.welcome({ unreadChatCount: 5 }); socket.welcome({ unreadChatCount: 5 });
socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1 }); socket.receive({
type: "chat_read_update",
readAt: "2026-05-01T00:00:00.000Z",
unread: 1,
});
expect(ws().unreadChatCount).toBe(1); expect(ws().unreadChatCount).toBe(1);
expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z")); expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
}); });
@@ -598,7 +745,11 @@ describe("chat", () => {
const t0 = Date.now(); const t0 = Date.now();
const at = (s: number) => new Date(t0 + s * 1000); const at = (s: number) => new Date(t0 + s * 1000);
const early = makeChatMessage({ id: "early", createdAt: at(-100) }); const early = makeChatMessage({ id: "early", createdAt: at(-100) });
const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50) }); const edited = makeChatMessage({
id: "edited",
body: "old",
createdAt: at(-50),
});
const gone = makeChatMessage({ id: "gone", createdAt: at(-40) }); const gone = makeChatMessage({ id: "gone", createdAt: at(-40) });
for (const m of [early, edited, gone]) { for (const m of [early, edited, gone]) {
socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message", message: wire(m) });
@@ -611,7 +762,12 @@ describe("chat", () => {
ws().reconcileChat( ws().reconcileChat(
[ [
{ ...edited, body: "new" }, { ...edited, body: "new" },
makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }), makeChatMessage({
id: "echo",
userId: "user-me",
body: "did it land?",
createdAt: at(1),
}),
], ],
at(-60), at(-60),
at(60), at(60),
@@ -643,6 +799,9 @@ describe("chat", () => {
act(() => vi.advanceTimersByTime(500)); act(() => vi.advanceTimersByTime(500));
FakeWebSocket.last.welcome(); FakeWebSocket.last.welcome();
expect(ws().chatMessages).toHaveLength(1); expect(ws().chatMessages).toHaveLength(1);
expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true }); expect(FakeWebSocket.last.messages).toContainEqual({
type: "chat_focus",
open: true,
});
}); });
}); });

View File

@@ -51,7 +51,10 @@ describe("AuthProvider", () => {
test("a role changed on the server is picked up", async () => { test("a role changed on the server is picked up", async () => {
const session = makeSession({ role: "user" }); const session = makeSession({ role: "user" });
storeSession(session); storeSession(session);
route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" })); route(
"GET /api/users/me",
() => wire({ ...session.user, role: "moderator" }),
);
const { result } = renderHook(useAuth, { wrapper: authWrapper }); const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.user?.role).toBe("moderator")); await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe( expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
@@ -99,13 +102,17 @@ describe("AuthProvider", () => {
const session = makeSession({ username: "elsewhere" }); const session = makeSession({ username: "elsewhere" });
storeSession(session); storeSession(session);
act(() => { act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY })); globalThis.dispatchEvent(
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
);
}); });
expect(result.current.user?.username).toBe("elsewhere"); expect(result.current.user?.username).toBe("elsewhere");
localStorage.removeItem(AUTH_STORAGE_KEY); localStorage.removeItem(AUTH_STORAGE_KEY);
act(() => { act(() => {
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" })); globalThis.dispatchEvent(
new StorageEvent("storage", { key: "unrelated" }),
);
}); });
expect(result.current.user).not.toBeNull(); expect(result.current.user).not.toBeNull();
act(() => { act(() => {
@@ -132,7 +139,9 @@ describe("AuthProvider", () => {
}); });
describe("FollowProvider", () => { describe("FollowProvider", () => {
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) { function setup(
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
) {
const session = makeSession(); const session = makeSession();
storeSession(session); storeSession(session);
route("GET /api/users/me", () => wire(session.user)); route("GET /api/users/me", () => wire(session.user));
@@ -260,7 +269,9 @@ describe("ThemeProvider", () => {
expect(result.current.style).toBe("nyt"); expect(result.current.style).toBe("nyt");
expect(result.current.colorScheme).toBe("light"); expect(result.current.colorScheme).toBe("light");
localStorage.setItem("style", "default"); localStorage.setItem("style", "default");
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth"); expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
"smooth",
);
}); });
}); });
@@ -310,4 +321,3 @@ describe("ChatProvider", () => {
expect(chat.isChatOpen).toBe(false); expect(chat.isChatOpen).toBe(false);
}); });
}); });

View File

@@ -4,8 +4,20 @@ import enMessages from "emojibase-data/en/messages.json";
/** Serves the emoji picker's data as the app does, from /emojibase/<ver>/. */ /** Serves the emoji picker's data as the app does, from /emojibase/<ver>/. */
export function serveEmojibase() { export function serveEmojibase() {
route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData))); route(
route("GET /emojibase/:version/en/messages.json", () => new Response(JSON.stringify(enMessages))); "GET /emojibase/:version/en/data.json",
route("HEAD /emojibase/:version/en/data.json", () => new Response(null, { headers: { ETag: "x" } })); () => new Response(JSON.stringify(enData)),
route("HEAD /emojibase/:version/en/messages.json", () => new Response(null, { headers: { ETag: "y" } })); );
route(
"GET /emojibase/:version/en/messages.json",
() => new Response(JSON.stringify(enMessages)),
);
route(
"HEAD /emojibase/:version/en/data.json",
() => new Response(null, { headers: { ETag: "x" } }),
);
route(
"HEAD /emojibase/:version/en/messages.json",
() => new Response(null, { headers: { ETag: "y" } }),
);
} }

View File

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

View File

@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts"; 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"; import { calls, deferred, route } from "../fetch.ts";
function key(k: string, mods: Partial<KeyboardEvent> = {}) { function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
return { return {
key: k, key: k,
ctrlKey: false, ctrlKey: false,
@@ -15,7 +17,7 @@ function key(k: string, mods: Partial<KeyboardEvent> = {}) {
preventDefault: vi.fn(), preventDefault: vi.fn(),
stopPropagation: vi.fn(), stopPropagation: vi.fn(),
...mods, ...mods,
} as unknown as KeyboardEvent<HTMLTextAreaElement>; };
} }
describe("useEmojiTrigger", () => { describe("useEmojiTrigger", () => {
@@ -125,13 +127,20 @@ describe("useMentionAutocomplete", () => {
} }
test("looks up the @query after a pause and opens with results", async () => { test("looks up the @query after a pause and opens with results", async () => {
route("GET /api/users/search", (req) => route(
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!))); "GET /api/users/search",
(req) =>
users.filter((u) =>
u.username.startsWith(req.url.searchParams.get("q")!)
),
);
const { hook, type } = setup(); const { hook, type } = setup();
await type("hi @al"); await type("hi @al");
expect(hook.result.current.mentionOpen).toBe(true); expect(hook.result.current.mentionOpen).toBe(true);
expect(hook.result.current.mentionResults).toHaveLength(3); 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 () => { test("debounces keystrokes into one lookup", async () => {
@@ -168,7 +177,9 @@ describe("useMentionAutocomplete", () => {
const { hook, type } = setup(); const { hook, type } = setup();
await type("@al"); await type("@al");
await type("@alz"); 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); expect(hook.result.current.mentionOpen).toBe(false);
}); });
@@ -231,7 +242,9 @@ describe("useMentionAutocomplete", () => {
test("keys pass through while closed", () => { test("keys pass through while closed", () => {
const { hook } = setup(); 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 () => { 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 { 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 { 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 { useFading } from "../../hooks/useFading.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts"; import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useTabParam } from "../../hooks/useTabParam.ts"; import { useTabParam } from "../../hooks/useTabParam.ts";
@@ -112,7 +124,13 @@ describe("useFeedCache", () => {
test("a fresh (PUSH) visit ignores the cache", () => { test("a fresh (PUSH) visit ignores the cache", () => {
sessionStorage.setItem( sessionStorage.setItem(
"k", "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() { function Probe() {
const { cached } = useFeedCache("k", (x) => x); const { cached } = useFeedCache("k", (x) => x);
@@ -120,7 +138,11 @@ describe("useFeedCache", () => {
} }
function Home() { function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
return <button type="button" onClick={() => navigate("/feed")}>go</button>; return (
<button type="button" onClick={() => navigate("/feed")}>
go
</button>
);
} }
render( render(
<MemoryRouter> <MemoryRouter>
@@ -156,7 +178,13 @@ describe("useTabParam", () => {
return useTabParam(TABS, "dumps"); return useTabParam(TABS, "dumps");
}, { }, {
wrapper: ({ children }) => ( 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} {children}
</MemoryRouter> </MemoryRouter>
), ),
@@ -165,8 +193,12 @@ describe("useTabParam", () => {
} }
test("reads the tab after /~/", () => { 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(
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists"); "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").hook.result.current[0]).toBe("dumps");
expect(setup("/users/ann/~/bogus").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", () => { describe("useDocumentTitle", () => {
test("composes page, site name and unread badge", () => { test("composes page, site name and unread badge", () => {
const { rerender } = renderHook( const { rerender } = renderHook(
({ title }: { title: string | null | undefined }) => useDocumentTitle(title), ({ title }: { title: string | null | undefined }) =>
useDocumentTitle(title),
{ initialProps: { title: "Cats" as string | null | undefined } }, { initialProps: { title: "Cats" as string | null | undefined } },
); );
expect(document.title).toBe(`Cats · ${SITE_NAME}`); expect(document.title).toBe(`Cats · ${SITE_NAME}`);
@@ -226,12 +259,7 @@ describe("useInfiniteScroll", () => {
} }
const intersect = (isIntersecting: boolean) => { const intersect = (isIntersecting: boolean) => {
const obs = FakeIntersectionObserver.instances.at(-1)!; const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => act(() => obs.report(isIntersecting));
obs.callback(
[{ isIntersecting } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
}; };
test("loads more when the sentinel shows, only while enabled", () => { test("loads more when the sentinel shows, only while enabled", () => {
@@ -269,7 +297,16 @@ describe("useInfiniteScroll", () => {
} }
delays.push(waited); 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", () => { test("re-rendering doesn't restart the wait", () => {
@@ -292,18 +329,13 @@ describe("useInfiniteScroll", () => {
render(<Feed onMore={onMore} enabled />); render(<Feed onMore={onMore} enabled />);
const at = (top: number) => { const at = (top: number) => {
const obs = FakeIntersectionObserver.instances.at(-1)!; const obs = FakeIntersectionObserver.instances.at(-1)!;
act(() => act(() => obs.report(true, top));
obs.callback(
[{ isIntersecting: true, boundingClientRect: { top } } as IntersectionObserverEntry],
obs as unknown as IntersectionObserver,
)
);
}; };
at(500); at(500);
at(500); at(500);
expect(onMore).toHaveBeenCalledTimes(1); // waiting expect(onMore).toHaveBeenCalledTimes(1); // waiting
intersect(false); act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
intersect(true); at(500);
expect(onMore).toHaveBeenCalledTimes(2); expect(onMore).toHaveBeenCalledTimes(2);
at(500); at(500);
at(900); // the page grew: that load worked at(900); // the page grew: that load worked
@@ -395,7 +427,7 @@ describe("useDumpCardLink", () => {
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1"); if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
}} }}
> >
play play
@@ -431,14 +463,27 @@ describe("useDumpCardLink", () => {
test("a middle click opens a new tab, except over a link", () => { test("a middle click opens a new tab, except over a link", () => {
const { open } = setup(); 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); screen.getByTestId("card").dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true); 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); 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); 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); expect(open).toHaveBeenCalledTimes(1);
}); });
@@ -469,7 +514,14 @@ describe("useAnchoredFloat", () => {
ref.current = el; ref.current = el;
if (el) { if (el) {
el.getBoundingClientRect = () => 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; height = 250;
rerender(<Box open />); rerender(<Box open />);
expect(animate).toHaveBeenCalledTimes(1); expect(animate).toHaveBeenCalledTimes(1);
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]]; expect(animate.mock.calls[0][0]).toEqual([
expect(frames[0].height).toBe("100px"); expect.objectContaining({ height: "100px" }),
expect(frames[1].height).toBe("250px"); expect.objectContaining({ height: "250px" }),
]);
// No change in height, no animation. // No change in height, no animation.
rerender(<Box open={false} />); rerender(<Box open={false} />);

View File

@@ -1,24 +1,30 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "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 type { Dump, Playlist } from "../../model.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts"; import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
import { makeDump, makePlaylist } from "../fixtures.ts"; import { makeDump, makePlaylist } from "../fixtures.ts";
import { defaultOf } from "../render.tsx";
/** A WS context whose value a test can push new events into. */ /** A WS context whose value a test can push new events into. */
function liveWS() { function liveWS() {
let push: (patch: Partial<WSContextValue>) => void = () => {}; let push: (patch: Partial<WSContextValue>) => void = () => {};
const base = defaultOf(WSContext); const base = defaultWSValue;
function Wrapper({ children }: { children: ReactNode }) { function Wrapper({ children }: { children: ReactNode }) {
const [value, setValue] = useState(base); const [value, setValue] = useState(base);
push = (patch) => setValue((v) => ({ ...v, ...patch })); push = (patch) => setValue((v) => ({ ...v, ...patch }));
return <WSContext.Provider value={value}>{children}</WSContext.Provider>; 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[]) { function useList<T>(initial: T[]) {
@@ -29,7 +35,10 @@ function useList<T>(initial: T[]) {
const ids = (xs: { id: string }[]) => xs.map((x) => x.id); const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
describe("useDumpListSync", () => { describe("useDumpListSync", () => {
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) { function setup(
initial: Dump[],
opts?: Parameters<typeof useDumpListSync>[1],
) {
const ws = liveWS(); const ws = liveWS();
const hook = renderHook(() => { const hook = renderHook(() => {
const list = useList(initial); const list = useList(initial);
@@ -40,7 +49,10 @@ describe("useDumpListSync", () => {
} }
test("drops deleted dumps", () => { 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"]) }); emit({ deletedDumpIds: new Set(["a"]) });
expect(ids(hook.result.current.items)).toEqual(["b"]); expect(ids(hook.result.current.items)).toEqual(["b"]);
}); });
@@ -116,15 +128,25 @@ describe("usePlaylistListSync", () => {
test("prepends a created playlist once", () => { test("prepends a created playlist once", () => {
const { hook, emit } = setup([makePlaylist({ id: "a" })]); const { hook, emit } = setup([makePlaylist({ id: "a" })]);
const p = makePlaylist({ id: "b" }); const p = makePlaylist({ id: "b" });
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } }); emit({
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } }); lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
});
emit({
lastPlaylistEvent: {
type: "created",
playlistId: "b",
playlist: { ...p },
},
});
expect(ids(hook.result.current.items)).toEqual(["b", "a"]); expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
}); });
test("created: filtered by owner, privacy and noNewEntries", () => { test("created: filtered by owner, privacy and noNewEntries", () => {
const p = makePlaylist({ id: "p", userId: "them" }); const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" }); 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([]); expect(byOwner.hook.result.current.items).toEqual([]);
const priv = setup([]); const priv = setup([]);
@@ -138,7 +160,9 @@ describe("usePlaylistListSync", () => {
expect(priv.hook.result.current.items).toEqual([]); expect(priv.hook.result.current.items).toEqual([]);
const followed = setup([], { noNewEntries: true }); 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([]); expect(followed.hook.result.current.items).toEqual([]);
}); });
@@ -146,15 +170,27 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p", title: "one" }); const p = makePlaylist({ id: "p", title: "one" });
const { hook, emit } = setup([p]); const { hook, emit } = setup([p]);
emit({ 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"); expect(hook.result.current.items[0].title).toBe("two");
emit({ 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([]); expect(hook.result.current.items).toEqual([]);
emit({ 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"); expect(hook.result.current.items[0].title).toBe("back");
}); });
@@ -163,7 +199,9 @@ describe("usePlaylistListSync", () => {
const p = makePlaylist({ id: "p" }); const p = makePlaylist({ id: "p" });
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) { for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
const { hook, emit } = setup([], opts); 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([]); expect(hook.result.current.items).toEqual([]);
} }
}); });
@@ -182,7 +220,11 @@ describe("usePlaylistListSync", () => {
test("dumps_updated sets the count", () => { test("dumps_updated sets the count", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]); const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit({ 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); 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", () => { test("dumps_updated counts the members this viewer can't see too", () => {
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]); const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit({ 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); expect(hook.result.current.items[0].dumpCount).toBe(4);
}); });
@@ -230,7 +277,13 @@ describe("usePositionAwareSync", () => {
const hook = renderHook(() => { const hook = renderHook(() => {
const { items, setItems } = useList(initial); const { items, setItems } = useList(initial);
const [event, setEvent] = useState<Playlist | null>(null); 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 }; return { items, setItems, setEvent };
}); });
const c = initial[2]; const c = initial[2];
@@ -240,7 +293,9 @@ describe("usePositionAwareSync", () => {
expect(ids(hook.result.current.items)).toEqual(["a", "c"]); expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
// Nothing saved any more: a second public event is left alone. // 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.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"]); 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", () => { describe("useAuth", () => {
test("exposes the session", () => { test("exposes the session", () => {
@@ -98,7 +99,9 @@ describe("authFetch", () => {
method: "POST", method: "POST",
body: new FormData(), 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 () => { test("no Authorization header when signed out", async () => {
@@ -135,7 +138,12 @@ describe("authFetch", () => {
describe("authUpload", () => { describe("authUpload", () => {
class FakeXHR { 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 = ""; method = "";
url = ""; url = "";
headers: Record<string, string> = {}; headers: Record<string, string> = {};
@@ -196,7 +204,7 @@ describe("authUpload", () => {
body, body,
(f) => progress.push(f), (f) => progress.push(f),
); );
const xhr = FakeXHR.last; const xhr = FakeXHR.current;
expect(xhr.method).toBe("POST"); expect(xhr.method).toBe("POST");
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`); expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
expect(xhr.body).toBe(body); expect(xhr.body).toBe(body);
@@ -215,9 +223,14 @@ describe("authUpload", () => {
const { result } = renderHook(useAuth, { const { result } = renderHook(useAuth, {
wrapper: wrapperFor(makeSession()), wrapper: wrapperFor(makeSession()),
}); });
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT"); const promise = result.current.authUpload(
expect(FakeXHR.last.method).toBe("PUT"); "u",
FakeXHR.last.error(); new FormData(),
() => {},
"PUT",
);
expect(FakeXHR.current.method).toBe("PUT");
FakeXHR.current.error();
await expect(promise).rejects.toBeInstanceOf(TypeError); await expect(promise).rejects.toBeInstanceOf(TypeError);
}); });
@@ -227,14 +240,14 @@ describe("authUpload", () => {
wrapper: wrapperFor(makeSession()), wrapper: wrapperFor(makeSession()),
}); });
const promise = result.current.authUpload("u", new FormData(), () => {}); const promise = result.current.authUpload("u", new FormData(), () => {});
act(() => FakeXHR.last.respond(401, "")); act(() => FakeXHR.current.respond(401, ""));
await promise; await promise;
await waitFor(() => expect(result.current.user).toBeNull()); await waitFor(() => expect(result.current.user).toBeNull());
}); });
test("an expired token short-circuits", async () => { test("an expired token short-circuits", async () => {
vi.stubGlobal("XMLHttpRequest", FakeXHR); vi.stubGlobal("XMLHttpRequest", FakeXHR);
FakeXHR.last = undefined as unknown as FakeXHR; FakeXHR.last = undefined;
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) }; const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
const res = await act(() => const res = await act(() =>
@@ -258,4 +271,3 @@ describe("useRequiredAuth", () => {
expect(result.current.user.username).toBe("z"); expect(result.current.user.username).toBe("z");
}); });
}); });

View File

@@ -22,23 +22,40 @@ const owner = makeSession({ id: "owner", username: "owner" });
const other = makeSession({ id: "other", username: "other" }); const other = makeSession({ id: "other", username: "other" });
const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); 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/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/comments", () => []);
route("GET /api/dumps/:id/related", () => []); route("GET /api/dumps/:id/related", () => []);
route("GET /api/dumps/:id/playlists", () => []); route("GET /api/dumps/:id/playlists", () => []);
return dump; 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(); const user = userEvent.setup();
renderApp(<Dump />, { renderApp(<Dump />, {
route: url, route: url,
path: "/dumps/:selectedDump", path: "/dumps/:selectedDump",
session, session,
categories: [music], 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; const socket = FakeWebSocket.last;
socket.welcome(); socket.welcome();
@@ -49,9 +66,16 @@ describe("Dump page", () => {
test("shows the dump, its author, categories and note", async () => { test("shows the dump, its author, categories and note", async () => {
dumpRoutes(); dumpRoutes();
openDump(); openDump();
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "A find" }))
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner"); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music"); 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"); expect(screen.getByText("reasons").tagName).toBe("STRONG");
await waitFor(() => expect(document.title).toMatch(/^A find · /)); await waitFor(() => expect(document.title).toMatch(/^A find · /));
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("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/related", () => []);
route("GET /api/dumps/:id/playlists", () => []); route("GET /api/dumps/:id/playlists", () => []);
openDump(); openDump();
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found"); expect(await screen.findByRole("alert")).toHaveTextContent(
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument(); "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 () => { test("the owner can rename it in place, following the new slug", async () => {
dumpRoutes(); 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); const { user } = openDump(owner);
await user.click(await screen.findByRole("heading", { name: "A find" })); await user.click(await screen.findByRole("heading", { name: "A find" }));
const input = screen.getByDisplayValue("A find"); const input = screen.getByDisplayValue("A find");
await user.clear(input); await user.clear(input);
await user.type(input, "A better find{Enter}"); 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")); 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 () => { test("a refused rename says why; Escape backs out", async () => {
@@ -96,7 +137,8 @@ describe("Dump page", () => {
const { user } = openDump(other); const { user } = openDump(other);
await user.click(await screen.findByRole("heading", { name: "A find" })); await user.click(await screen.findByRole("heading", { name: "A find" }));
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument(); 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 () => { test("the owner gets an Edit link", async () => {
@@ -116,33 +158,54 @@ describe("Dump page", () => {
}); });
test("votes go through the socket", async () => { 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); const { user, socket } = openDump(other);
await user.click(await screen.findByRole("button", { name: "Upvote" })); await user.click(await screen.findByRole("button", { name: "Upvote" }));
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); 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 () => { test("comments load, and live ones arrive", async () => {
dumpRoutes(); 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); const { socket } = openDump(other);
await screen.findByText("first"); 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(); expect(screen.getByText("second")).toBeInTheDocument();
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" }); socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
expect(screen.getByText("[deleted]")).toBeInTheDocument(); expect(screen.getByText("[deleted]")).toBeInTheDocument();
// Another dump's comment isn't shown here. // 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(); expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
}); });
test("related dumps and the playlists it's in", async () => { test("related dumps and the playlists it's in", async () => {
dumpRoutes(); dumpRoutes();
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })])); route(
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })])); "GET /api/dumps/:id/related",
() => wire([makeDump({ title: "Links here" })]),
);
route(
"GET /api/dumps/:id/playlists",
() => wire([makePlaylist({ title: "Best of" })]),
);
openDump(); openDump();
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Related" }))
.toBeInTheDocument();
expect(screen.getByText("Links here")).toBeInTheDocument(); expect(screen.getByText("Links here")).toBeInTheDocument();
expect(await screen.findByText("Best of")).toBeInTheDocument(); expect(await screen.findByText("Best of")).toBeInTheDocument();
}); });
@@ -151,16 +214,26 @@ describe("Dump page", () => {
const d = dumpRoutes(); const d = dumpRoutes();
const { socket } = openDump(); const { socket } = openDump();
await screen.findByRole("heading", { name: "A find" }); await screen.findByRole("heading", { name: "A find" });
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) }); socket.receive({
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument(); type: "dump_updated",
dump: wire({ ...d, title: "Retitled" }),
});
expect(screen.getByRole("heading", { name: "Retitled" }))
.toBeInTheDocument();
}); });
test("adding to a playlist", async () => { test("adding to a playlist", async () => {
dumpRoutes(); dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => [ 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); const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" })); await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
const row = await screen.findByRole("checkbox", { name: /Mine/ }); 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 () => { test("a new playlist can be made from there, with the dump in it", async () => {
dumpRoutes(); dumpRoutes();
route("GET /api/playlists/by-dump/:id/memberships", () => []); 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", () => ({})); route("POST /api/playlists/:pid/dumps/:did", () => ({}));
const { user } = openDump(owner); const { user } = openDump(owner);
await user.click(await screen.findByRole("button", { name: "+ Playlist" })); 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}"); await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
const row = await screen.findByRole("checkbox", { name: /Fresh/ }); const row = await screen.findByRole("checkbox", { name: /Fresh/ });
expect(row).toHaveAttribute("aria-checked", "true"); 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 () => { test("scrolls to a linked comment", async () => {
dumpRoutes(); 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"); const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
openDump(null, "/dumps/a-find#comment-c9"); openDump(null, "/dumps/a-find#comment-c9");
await screen.findByText("linked"); 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(); expect(scroll).toHaveBeenCalled();
}); });
}); });
@@ -199,7 +292,9 @@ describe("DumpEdit page", () => {
route("GET /api/dumps/:id", () => wire(dump)); route("GET /api/dumps/:id", () => wire(dump));
const user = userEvent.setup(); const user = userEvent.setup();
renderApp( renderApp(
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, <RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{ {
route: `/dumps/${dump.slug ?? dump.id}/edit`, route: `/dumps/${dump.slug ?? dump.id}/edit`,
path: "/dumps/:selectedDump/edit", path: "/dumps/:selectedDump/edit",
@@ -226,11 +321,18 @@ describe("DumpEdit page", () => {
url: "https://a.test/", url: "https://a.test/",
comment: "old why", comment: "old why",
categoryIds: [], 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 () => { 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 user = openEdit(urlDump());
const title = await screen.findByLabelText("Title"); const title = await screen.findByLabelText("Title");
expect(document.title).toMatch(/^Editing A find/); expect(document.title).toMatch(/^Editing A find/);
@@ -264,7 +366,8 @@ describe("DumpEdit page", () => {
const title = await screen.findByLabelText("Title"); const title = await screen.findByLabelText("Title");
await user.click(screen.getByRole("button", { name: "Reset to default" })); await user.click(screen.getByRole("button", { name: "Reset to default" }));
expect(title).toHaveValue("Page's own title"); 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 () => { test("a refused save stays on the form", async () => {
@@ -277,20 +380,51 @@ describe("DumpEdit page", () => {
}); });
test("refreshing the metadata", async () => { test("refreshing the metadata", async () => {
route("POST /api/dumps/:id/refresh-metadata", () => route(
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } })); "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()); 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(); expect(await screen.findByText("New blurb")).toBeInTheDocument();
}); });
test("replacing a file sends a visibility change in the same request", async () => { 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 }); const fileDump = makeDump({
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true })); 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); const user = openEdit(fileDump);
await screen.findByText("song.mp3"); await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!; const input = document.querySelector<HTMLInputElement>(
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" })); '.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: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page"); 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 () => { 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)")); route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)"));
const user = openEdit(fileDump); const user = openEdit(fileDump);
await screen.findByText("song.mp3"); await screen.findByText("song.mp3");
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!; const input = document.querySelector<HTMLInputElement>(
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" })); '.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: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" })); 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(screen.queryByText("dump page")).not.toBeInTheDocument();
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1); expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
expect(calls("PUT /api/dumps/:id")).toHaveLength(0); expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
@@ -319,36 +469,66 @@ describe("DumpEdit page", () => {
test("deleting asks, then goes home", async () => { test("deleting asks, then goes home", async () => {
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 })); route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
const user = openEdit(urlDump()); const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" })); await user.click(
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" })); await screen.findByRole("button", { name: "Delete dump" }),
);
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete dump",
}),
);
await screen.findByText("home"); 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" }); .toEqual({ deletedDumpId: "d1" });
}); });
test("a delete that can't reach the server says so", async () => { test("a delete that can't reach the server says so", async () => {
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch")); route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
const user = openEdit(urlDump()); const user = openEdit(urlDump());
await user.click(await screen.findByRole("button", { name: "Delete dump" })); await user.click(
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" })); await screen.findByRole("button", { name: "Delete dump" }),
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/); );
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 () => { test("a thumbnail that won't upload says so", async () => {
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400)); route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
const user = openEdit(urlDump()); const user = openEdit(urlDump());
await screen.findByLabelText("Title"); await screen.findByLabelText("Title");
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!; const picker = document.querySelector<HTMLInputElement>(
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" })); '.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(); expect(await screen.findByText("Not an image")).toBeInTheDocument();
}); });
test("guests are sent to log in", () => { test("guests are sent to log in", () => {
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, { renderApp(
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: "/dumps/x/edit", route: "/dumps/x/edit",
path: "/dumps/:selectedDump/edit", path: "/dumps/:selectedDump/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />, extraRoutes: <Route path="/login" element={<p>login page</p>} />,
}); },
);
expect(screen.getByText("login page")).toBeInTheDocument(); expect(screen.getByText("login page")).toBeInTheDocument();
}); });
}); });

View File

@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { Index } from "../../pages/Index.tsx"; import { Index } from "../../pages/Index.tsx";
import { calls, fail, route } from "../fetch.ts"; 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 { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
@@ -39,8 +45,18 @@ const titles = () =>
.map((a) => a.textContent); .map((a) => a.textContent);
describe("Index feeds", () => { describe("Index feeds", () => {
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) }); const older = makeDump({
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) }); 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 () => { test("New sorts by date, Hot by score", async () => {
route("GET /api/dumps/", () => page([older, newer])); 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 () => { test("a failed load shows an error and disables posting", async () => {
route("GET /api/dumps/", () => fail("boom", 500)); route("GET /api/dumps/", () => fail("boom", 500));
setup("/~/new", { session: makeSession() }); 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(); expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
}); });
@@ -81,18 +99,25 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([])); route("GET /api/dumps/", () => page([]));
setup("/~/new", { session }); setup("/~/new", { session });
await screen.findByText("No dumps yet. Be the first!"); 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 () => { test("scrolling to the end loads the next page", async () => {
route("GET /api/dumps/", (req) => route(
"GET /api/dumps/",
(req) =>
req.url.searchParams.get("page") === "1" req.url.searchParams.get("page") === "1"
? page([newer], true) ? page([newer], true)
: page([older], false)); : page([older], false),
);
setup("/~/new"); setup("/~/new");
await screen.findByText("Brand new"); await screen.findByText("Brand new");
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!; const observer = FakeIntersectionObserver.instances.findLast((o) =>
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never)); o.elements.size > 0
)!;
act(() => observer.report(true));
await screen.findByText("Old but loved"); await screen.findByText("Old but loved");
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2"); expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
}); });
@@ -101,7 +126,12 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([older])); route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new"); const { socket } = setup("/~/new");
await screen.findByText("Old but loved"); 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"); expect(titles()[0]).toBe("Just now");
socket.receive({ type: "dump_deleted", dumpId: "old" }); socket.receive({ type: "dump_deleted", dumpId: "old" });
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument(); expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
@@ -111,8 +141,16 @@ describe("Index feeds", () => {
route("GET /api/dumps/", () => page([older])); route("GET /api/dumps/", () => page([older]));
const { socket } = setup("/~/new"); const { socket } = setup("/~/new");
await screen.findByText("Old but loved"); await screen.findByText("Old but loved");
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" }); socket.receive({
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31"); 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 () => { 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 () => { 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"); const { socket } = setup("/music/new");
await screen.findByText("A song"); 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 · /)); await waitFor(() => expect(document.title).toMatch(/^Music · /));
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) }); socket.receive({
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) }); 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.queryByText("Elsewhere")).not.toBeInTheDocument();
expect(screen.getByText("Another song")).toBeInTheDocument(); expect(screen.getByText("Another song")).toBeInTheDocument();
}); });
@@ -148,7 +203,8 @@ describe("Index feeds", () => {
test("an unknown category is a 404", async () => { test("an unknown category is a 404", async () => {
route("GET /api/categories/:slug/dumps", () => fail("nope", 404)); route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
setup("/nonsense/new"); 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 () => { 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/dumps/", () => page([]));
route("GET /api/preview", () => ({ reached: false, richContent: null })); route("GET /api/preview", () => ({ reached: false, richContent: null }));
route("GET /api/dumps/by-url", () => []); 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" }); 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", () => { describe("Followed feed", () => {
test("from people and from playlists", async () => { test("from people and from playlists", async () => {
const session = makeSession(); 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([])); route("GET /api/follows/feed/playlists", () => page([]));
const { user } = setup("/~/followed", { session }); const { user } = setup("/~/followed", { session });
await screen.findByText("By a friend"); await screen.findByText("By a friend");
await user.click(screen.getByRole("button", { name: "From playlists" })); await user.click(screen.getByRole("button", { name: "From playlists" }));
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument(); expect(
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`); 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 () => { test("a category narrows it", async () => {
@@ -184,7 +250,9 @@ describe("Followed feed", () => {
route("GET /api/follows/feed/playlists", () => page([])); route("GET /api/follows/feed/playlists", () => page([]));
setup("/music/followed", { session: makeSession() }); setup("/music/followed", { session: makeSession() });
await screen.findByText("Follow some users to see their dumps here."); 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 () => { test("a server error reads as one, not as a lost connection", async () => {

View File

@@ -3,7 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx"; import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../model.ts"; 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 { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
@@ -12,33 +12,122 @@ const me = makeSession({ id: "me" });
const DAY = 86_400_000; const DAY = 86_400_000;
function open(items: Notification[], { hasMore = false } = {}) { function open(items: Notification[], { hasMore = false } = {}) {
route("GET /api/notifications", (req) => route(
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([])); "GET /api/notifications",
(req) =>
req.url.searchParams.get("page") === "1"
? page(items, hasMore)
: page([]),
);
route("POST /api/notifications/read-all", () => ({})); route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Notifications />, { session: me, route: "/notifications" }); renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last; 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 }; return { user, socket };
} }
const n = { const n = {
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }), follow: makeNotification({
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }), type: "user_followed",
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }), data: { followerId: "f", followerUsername: "fan" },
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" } }), playlistFollow: makeNotification({
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }), type: "playlist_followed",
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }), data: {
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }), followerId: "f",
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }), followerUsername: "fan",
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }), 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", () => { describe("Notifications", () => {
test("every kind reads right and links to its subject", async () => { test("every kind reads right and links to its subject", async () => {
open(Object.values(n)); 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(/started following you/)).toBe("/users/fan");
expect(await link(/followed your playlist/)).toBe("/playlists/p1"); expect(await link(/followed your playlist/)).toBe("/playlists/p1");
expect(await link(/^posted$/)).toBe("/dumps/d1"); expect(await link(/^posted$/)).toBe("/dumps/d1");
@@ -46,8 +135,14 @@ describe("Notifications", () => {
expect(await link(/^upvoted$/)).toBe("/dumps/d3"); expect(await link(/^upvoted$/)).toBe("/dumps/d3");
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1"); expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2"); expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href")); const mentions = screen.getAllByText(/mentioned you in/).map((el) =>
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]); 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("a comment")).toBeInTheDocument();
expect(screen.getByText("the chat")).toBeInTheDocument(); expect(screen.getByText("the chat")).toBeInTheDocument();
expect(screen.getByText("Faves")).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() - DAY - 60_000) }),
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }), makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
]); ]);
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Today" }))
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); .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 () => { test("opening them marks them read and clears the bell, but still shows which were new", async () => {
open([n.follow, { ...n.upvoted, read: true }]); open([n.follow, { ...n.upvoted, read: true }]);
await screen.findByText(/started following you/); await screen.findByText(/started following you/);
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
await waitFor(() => 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(document.querySelectorAll(".notif-dot")).toHaveLength(1);
expect(screen.getByText(/started following you/).closest("li")) expect(screen.getByText(/started following you/).closest("li"))
.toHaveClass("notification-item--unread"); .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 () => { test("a list that loaded stays shown if marking it read fails", async () => {
route("POST /api/notifications/read-all", () => new TypeError("offline")); route("POST /api/notifications/read-all", () => new TypeError("offline"));
open([n.follow]); open([n.follow]);
expect(await screen.findByText(/started following you/)).toBeInTheDocument(); expect(await screen.findByText(/started following you/))
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1)); .toBeInTheDocument();
await waitFor(() =>
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText(/started following you/)).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 () => { test("a new one arrives at the top while the page is open", async () => {
const { socket } = open([n.follow]); const { socket } = open([n.follow]);
await screen.findByText(/started following you/); 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"); const items = document.querySelectorAll(".notification-item");
expect(items[0]).toHaveTextContent("bob upvoted Pic"); expect(items[0]).toHaveTextContent("bob upvoted Pic");
expect(items[0]).toHaveClass("notification-item--unread"); expect(items[0]).toHaveClass("notification-item--unread");
// Seen as it arrives: marked read, so the bell doesn't count it. // 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(() => 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 () => { test("one that arrives while the list is loading isn't lost", async () => {
let answer!: (r: Response) => void; const list = deferred<unknown>();
route("GET /api/notifications", () => new Promise<Response>((r) => (answer = r))); route("GET /api/notifications", () => list.promise);
route("POST /api/notifications/read-all", () => ({})); route("POST /api/notifications/read-all", () => ({}));
renderApp(<Notifications />, { session: me, route: "/notifications" }); renderApp(<Notifications />, { session: me, route: "/notifications" });
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: 1 }); 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 // Created after the list was read, so the list won't have it — but the
// read-all after the load will mark it read. // 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. // And one the list does have, sent while it was on its way: shown once.
socket.receive({ type: "notification_created", notification: wire(n.follow) }); socket.receive({
answer(page([n.follow])); type: "notification_created",
notification: wire(n.follow),
});
list.resolve(page([n.follow]));
await screen.findByText(/started following you/); await screen.findByText(/started following you/);
const items = document.querySelectorAll(".notification-item"); const items = document.querySelectorAll(".notification-item");
expect(items).toHaveLength(2); expect(items).toHaveLength(2);
@@ -135,15 +253,19 @@ describe("Notifications", () => {
}); });
test("load more", async () => { test("load more", async () => {
route("GET /api/notifications", (req) => route(
"GET /api/notifications",
(req) =>
req.url.searchParams.get("page") === "1" req.url.searchParams.get("page") === "1"
? page([n.follow], true) ? page([n.follow], true)
: page([n.upvoted], false)); : page([n.upvoted], false),
);
route("POST /api/notifications/read-all", () => ({})); route("POST /api/notifications/read-all", () => ({}));
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Notifications />, { session: me }); renderApp(<Notifications />, { session: me });
await user.click(await screen.findByRole("button", { name: "Load more" })); await user.click(await screen.findByRole("button", { name: "Load more" }));
await screen.findByText(/^upvoted$/); await screen.findByText(/^upvoted$/);
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Load more" })).not
.toBeInTheDocument();
}); });
}); });

View File

@@ -1,5 +1,11 @@
import { describe, expect, test, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { Route } from "react-router"; import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx"; import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
@@ -19,7 +25,11 @@ const dumps = () => [
]; ];
function open( 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({ const pl = makePlaylist({
id: "p1", id: "p1",
@@ -44,30 +54,44 @@ function open(
} }
const titles = () => 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", () => { describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => { test("shows the playlist and its dumps in order", async () => {
open({ session: null }); 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.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"]); expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix · /)); 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 () => { test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404)); route("GET /api/playlists/:id", () => fail("nope", 404));
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" }); renderApp(<PlaylistDetail />, {
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found"); 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 () => { test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor }); open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" }); await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Follow playlist" }))
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
.toBeInTheDocument();
}); });
test("the cover is versioned on the last edit", async () => { 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 () => { test("editing the description shows at once, without waiting for the socket", async () => {
route("PUT /api/playlists/:id", () => route(
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" }))); "PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
description: "Better tunes",
}),
),
);
const { user } = open(); const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" })); await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByPlaceholderText("Description (optional)"); const box = screen.getByPlaceholderText("Description (optional)");
@@ -89,13 +124,26 @@ describe("PlaylistDetail", () => {
await user.type(box, "Better tunes"); await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Better tunes")).toBeInTheDocument(); 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"); expect(currentPath()).toBe("/playlists/mix");
}); });
test("a new title moves to the new slug", async () => { test("a new title moves to the new slug", async () => {
route("PUT /api/playlists/:id", () => route(
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" }))); "PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "remix",
title: "Remix",
userId: "owner",
}),
),
);
const { user } = open(); const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" })); await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByPlaceholderText("Playlist title"); 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 () => { test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview"); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" }))); route(
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true }); "PUT /api/playlists/:id",
route("POST /api/playlists/:id/image", () => () =>
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) }))); 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(); const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" })); await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!; const picker = document.querySelector<HTMLInputElement>(
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" })); '.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" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image"); expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" })); 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 () => { test("removing a dump fades it out, with an undo", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
try { 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("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({})); route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open(); const { user } = open();
await screen.findByText("Bravo"); await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement; const bravo = screen.getByText("Bravo").closest(
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" })); ".playlist-dump-item",
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b"); ) as HTMLElement;
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" })); await user.click(
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)); within(bravo).getByRole("button", { name: "Remove from playlist" }),
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] }); );
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)); act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument(); expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
} finally { } finally {
@@ -157,20 +252,31 @@ describe("PlaylistDetail", () => {
}); });
test("a refused removal puts the dump back and says so", async () => { 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(); const { user } = open();
await screen.findByText("Bravo"); await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement; const bravo = screen.getByText("Bravo").closest(
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" })); ".playlist-dump-item",
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed"); ) as HTMLElement;
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument(); 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 () => { test("dragging reorders, and a refused reorder reloads the real order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403)); route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open(); open();
await screen.findByText("Charlie"); 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()) { for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect; el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
} }
@@ -179,18 +285,33 @@ describe("PlaylistDetail", () => {
fireEvent.dragOver(c, { clientY: 90 }); fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]); expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]); fireEvent.dragEnd(items()[2]);
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)); await waitFor(() =>
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] }); expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2)); );
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])); 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 () => { 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(); const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" })); await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" })); 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(); expect(await screen.findByText("home")).toBeInTheDocument();
}); });
@@ -199,22 +320,44 @@ describe("PlaylistDetail", () => {
const { user } = open(); const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" })); await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete" })); 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(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/playlists/mix"); expect(currentPath()).toBe("/playlists/mix");
}); });
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => { 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 }); const { socket } = open({ session: visitor });
await screen.findByText("Alpha"); 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(); expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) }); socket.receive({
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument(); 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" }); socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument(); 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(); expect(await screen.findByText("home")).toBeInTheDocument();
}); });
}); });

View File

@@ -3,21 +3,39 @@ import { act, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx"; import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts"; 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 { currentPath, renderApp } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
function results( 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 { 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), users: wire(users),
playlists: wire(playlists), 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(); const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session }); renderApp(<Search />, { route: url, path: "/search/*", session });
return user; return user;
@@ -44,7 +62,10 @@ describe("Search", () => {
expect(req.headers.get("Authorization")).toMatch(/^Bearer /); expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
await user.click(screen.getByRole("button", { name: "Users (1)" })); await user.click(screen.getByRole("button", { name: "Users (1)" }));
expect(currentPath()).toBe("/search/~/users?q=cat"); expect(currentPath()).toBe("/search/~/users?q=cat");
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover"); expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute(
"href",
"/users/catlover",
);
await user.click(screen.getByRole("button", { name: "Playlists (1)" })); await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
expect(screen.getByText("Cats")).toBeInTheDocument(); expect(screen.getByText("Cats")).toBeInTheDocument();
}); });
@@ -52,7 +73,8 @@ describe("Search", () => {
test("nothing found", async () => { test("nothing found", async () => {
route("GET /api/search", () => results()); route("GET /api/search", () => results());
const user = open("/search?q=zzz"); 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)" })); await user.click(screen.getByRole("button", { name: "Users (0)" }));
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument(); 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 () => { 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", () => 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"); open("/search?q=x");
await screen.findByText("First"); await screen.findByText("First");
const scroll = () => { const scroll = () => {
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!; const obs = FakeIntersectionObserver.instances.findLast((o) =>
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never)); o.elements.size > 0
)!;
act(() => obs.report(true));
}; };
scroll(); scroll();
await act(() => new Promise((r) => setTimeout(r, 50))); await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.getByText("First")).toBeInTheDocument(); expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument();
// Scrolling away and back tries again at once. // Scrolling away and back tries again at once.
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!; const obs = FakeIntersectionObserver.instances.findLast((o) =>
act(() => obs.callback([{ isIntersecting: false } as IntersectionObserverEntry], obs as never)); o.elements.size > 0
)!;
act(() => obs.report(false));
scroll(); scroll();
await screen.findByText("Second"); 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 () => { 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)); route("GET /api/search", () => fail("down", 500));
// A short page: the sentinel is on screen and stays there. // A short page: the sentinel is on screen and stays there.
FakeIntersectionObserver.inView = true; FakeIntersectionObserver.inView = true;
open("/search?q=x"); open("/search?q=x");
await screen.findByText("First"); await screen.findByText("First");
// In short steps: React holds what an act() schedules until it ends. // 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))); for (let i = 0; i < 20; i++) {
const pages = calls("GET /api/search").filter((c) => c.url.searchParams.get("page") !== "1"); 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. // Once at once, once more a second later; the next is 2s after that.
expect(pages).toHaveLength(2); expect(pages).toHaveLength(2);
expect(screen.getByText("First")).toBeInTheDocument(); expect(screen.getByText("First")).toBeInTheDocument();
}); });
test("a new query replaces the results", async () => { test("a new query replaces the results", async () => {
route("GET /api/search", (req) => route(
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] })); "GET /api/search",
(req) =>
results({
dumps: [
makeDump({ title: `About ${req.url.searchParams.get("q")}` }),
],
}),
);
const user = open("/search?q=one"); const user = open("/search?q=one");
await screen.findByText("About one"); await screen.findByText("About one");
const [box] = screen.getAllByRole("searchbox"); const [box] = screen.getAllByRole("searchbox");

View File

@@ -3,7 +3,13 @@ import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { UserPlaylists } from "../../pages/UserPlaylists.tsx"; import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
import { calls, fail, route } from "../fetch.ts"; 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 { renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
@@ -12,12 +18,29 @@ const annSession = makeSession({ id: "ann", username: "ann" });
function open(session: ReturnType<typeof makeSession> | null = null) { function open(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann)); route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/playlists", () => route(
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true)); "GET /api/users/:u/playlists",
route("GET /api/users/:u/followed-playlists", () => () =>
page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })])); 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(); 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 }; return { user, socket: FakeWebSocket.last };
} }
@@ -25,15 +48,27 @@ describe("UserPlaylists", () => {
test("created and followed playlists", async () => { test("created and followed playlists", async () => {
open(); open();
expect(await screen.findByText("Her mix")).toBeInTheDocument(); expect(await screen.findByText("Her mix")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Created (1+)" }))
expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Followed (1)" }))
.toBeInTheDocument();
expect(screen.getByText("Someone's mix")).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 () => { test("the owner creates one and it goes on top", async () => {
route("POST /api/playlists", (req) => route(
wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" }))); "POST /api/playlists",
(req) =>
wire(
makePlaylist({
id: "new",
title: (req.body as { title: string }).title,
userId: "ann",
}),
),
);
const { user } = open(annSession); const { user } = open(annSession);
await screen.findByText("Her mix"); await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: /New playlist/ })); 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: "Private" }));
await user.click(within(dialog).getByRole("button", { name: "Create" })); await user.click(within(dialog).getByRole("button", { name: "Create" }));
expect(await screen.findByText("Road trip")).toBeInTheDocument(); 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(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
}); });
test("the owner deletes one, after confirming", async () => { 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); const { user } = open(annSession);
await screen.findByText("Her mix"); await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" })); 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."); await screen.findByText("No playlists yet.");
}); });
@@ -60,7 +105,11 @@ describe("UserPlaylists", () => {
const { user } = open(annSession); const { user } = open(annSession);
await screen.findByText("Her mix"); await screen.findByText("Her mix");
await user.click(screen.getByRole("button", { name: "Delete playlist" })); 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(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(screen.getByText("Her mix")).toBeInTheDocument(); expect(screen.getByText("Her mix")).toBeInTheDocument();
}); });
@@ -69,9 +118,24 @@ describe("UserPlaylists", () => {
const { socket } = open(); const { socket } = open();
await screen.findByText("Her mix"); await screen.findByText("Her mix");
socket.welcome(); 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(); 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(); expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
}); });
}); });

View File

@@ -17,8 +17,17 @@ import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" }); const ann = makeUser({
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" }); id: "ann",
username: "ann",
description: "I **dump** things",
invitedByUsername: "root",
});
const annSession = makeSession({
id: "ann",
username: "ann",
email: "ann@x.test",
});
function open( function open(
{ {
@@ -33,7 +42,10 @@ function open(
route("GET /api/users/:u", () => wire(user)); route("GET /api/users/:u", () => wire(user));
route("GET /api/users/:u/dumps", () => page(dumps)); route("GET /api/users/:u/dumps", () => page(dumps));
route("GET /api/users/:u/votes", () => page(votes)); 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(); const u = userEvent.setup();
renderApp(<UserPublicProfile />, { renderApp(<UserPublicProfile />, {
route: url, route: url,
@@ -47,14 +59,20 @@ function open(
describe("UserPublicProfile", () => { describe("UserPublicProfile", () => {
test("header, description, and the dumps tab", async () => { test("header, description, and the dumps tab", async () => {
open(); open();
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "ann" }))
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root"); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
"href",
"/users/root",
);
expect(screen.getByText("dump").tagName).toBe("STRONG"); expect(screen.getByText("dump").tagName).toBe("STRONG");
expect(screen.getByText("Her dump")).toBeInTheDocument(); expect(screen.getByText("Her dump")).toBeInTheDocument();
expect(screen.getByText("She liked this")).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 · /)); 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 () => { test("an unknown user", async () => {
@@ -62,8 +80,13 @@ describe("UserPublicProfile", () => {
route("GET /api/users/:u/dumps", () => page([])); route("GET /api/users/:u/dumps", () => page([]));
route("GET /api/users/:u/votes", () => page([])); route("GET /api/users/:u/votes", () => page([]));
route("GET /api/users/:u/playlists", () => page([])); route("GET /api/users/:u/playlists", () => page([]));
renderApp(<UserPublicProfile />, { route: "/users/nobody", path: "/users/:username/*" }); renderApp(<UserPublicProfile />, {
expect(await screen.findByRole("alert")).toHaveTextContent("User not found"); 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 () => { 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 () => { test("followed: people and playlists, with more people on demand", async () => {
route("GET /api/users/:u/followed-users", (req) => route(
"GET /api/users/:u/followed-users",
(req) =>
req.url.searchParams.get("page") === "1" req.url.searchParams.get("page") === "1"
? page([makeUser({ username: "bob" })], true) ? page([makeUser({ username: "bob" })], true)
: page([makeUser({ username: "cy" })])); : page([makeUser({ username: "cy" })]),
route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })])); );
route(
"GET /api/users/:u/followed-playlists",
() => page([makePlaylist({ title: "Their mix" })]),
);
const { user } = open({ url: "/users/ann/~/followed" }); 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(); expect(screen.getByText("Their mix")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Load more" })); await user.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument(); expect(await screen.findByRole("link", { name: /@cy/ }))
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Load more" })).not
.toBeInTheDocument();
}); });
test("invitees as a tree", async () => { test("invitees as a tree", async () => {
route("GET /api/users/:u/invitees", () => wire([ route("GET /api/users/:u/invitees", () =>
wire([
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() }, { id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() }, { id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
])); ]));
open({ url: "/users/ann/~/invitees" }); 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(); expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
}); });
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => { test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
open({ session: makeSession({ id: "bob" }) }); open({ session: makeSession({ id: "bob" }) });
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "Follow ann" }))
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Settings" })).not
.toBeInTheDocument();
}); });
describe("your own profile", () => { describe("your own profile", () => {
test("edit the description; clearing it really clears it", async () => { 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 { 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(); view.focus();
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?"); const box = screen.getByPlaceholderText("Who am I?");
@@ -124,32 +168,53 @@ describe("UserPublicProfile", () => {
}); });
test("edit the email", async () => { 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 }); 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"); const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input); await user.clear(input);
await user.type(input, "new@x.test{Enter}"); await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: /new@x\.test/ }))
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("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 () => { test("a refused email says why", async () => {
route("PATCH /api/users/me", () => fail("Email already in use", 409)); route("PATCH /api/users/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession }); 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}"); 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 () => { 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 }); const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" }); await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!; const input = document.querySelector<HTMLInputElement>(
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" })); ".avatar-change-overlay input",
)!;
await user.upload(
input,
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
);
await waitFor(() => await waitFor(() =>
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute( expect(document.querySelector(".profile-avatar-wrapper img"))
.toHaveAttribute(
"src", "src",
"http://localhost:8000/api/avatars/ann?v=7000", "http://localhost:8000/api/avatars/ann?v=7000",
) )
@@ -160,21 +225,33 @@ describe("UserPublicProfile", () => {
route("POST /api/avatars/me", () => fail("Not an image", 400)); route("POST /api/avatars/me", () => fail("Not an image", 400));
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
await screen.findByRole("heading", { name: "ann" }); await screen.findByRole("heading", { name: "ann" });
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!; const input = document.querySelector<HTMLInputElement>(
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" })); ".avatar-change-overlay input",
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image"); )!;
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 () => { test("invite someone: a link to copy", async () => {
route("POST /api/invites", () => ({ token: "tok123" })); route("POST /api/invites", () => ({ token: "tok123" }));
const { user } = open({ session: annSession }); 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" }); const link = await screen.findByRole("textbox", { name: "Invite link" });
expect(link).toHaveValue(`${location.origin}/register?token=tok123`); expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
await user.click(screen.getByRole("button", { name: "Copy" })); await user.click(screen.getByRole("button", { name: "Copy" }));
// user-event stands in for the clipboard. // user-event stands in for the clipboard.
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`); expect(await navigator.clipboard.readText()).toBe(
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); `${location.origin}/register?token=tok123`,
);
expect(screen.getByRole("button", { name: "Copied!" }))
.toBeInTheDocument();
}); });
test("your upvotes show before the live connection welcomes you", async () => { test("your upvotes show before the live connection welcomes you", async () => {
@@ -189,19 +266,34 @@ describe("UserPublicProfile", () => {
const { socket } = open({ session: annSession }); const { socket } = open({ session: annSession });
await screen.findByText("She liked this"); await screen.findByText("She liked this");
socket.welcome({ myVotes: ["v1"] }); socket.welcome({ myVotes: ["v1"] });
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 }); socket.receive({
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading"); type: "vote_ack",
dumpId: "v1",
action: "remove",
voteCount: 0,
});
expect(screen.getByText("She liked this").closest(".dump-card"))
.toHaveClass("dump-card--fading");
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
test("settings: theme, colour scheme, default tab and sounds", async () => { test("settings: theme, colour scheme, default tab and sounds", async () => {
const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); const { user } = open({
await user.click(await screen.findByRole("button", { name: "Brutalist" })); session: annSession,
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist"); 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" })); 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" })); await user.click(screen.getByRole("button", { name: "New" }));
expect(localStorage.getItem("default-feed-tab")).toBe("new"); expect(localStorage.getItem("default-feed-tab")).toBe("new");
await user.click(screen.getByRole("button", { name: "Off" })); 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 () => { 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" })); route(
const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); "POST /api/users/me/change-password",
await user.click(await screen.findByRole("button", { name: "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" }); const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "oldpassword"); await user.type(
await user.type(within(dialog).getByLabelText("New password"), "newpassword"); within(dialog).getByLabelText("Current password"),
await user.type(within(dialog).getByLabelText("Confirm new password"), "different"); "oldpassword",
expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument(); );
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.clear(within(dialog).getByLabelText("Confirm new password"));
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword"); await user.type(
await user.click(within(dialog).getByRole("button", { name: "Change password" })); within(dialog).getByLabelText("Confirm new password"),
expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument(); "newpassword",
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token"); );
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({ expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
currentPassword: "oldpassword", currentPassword: "oldpassword",
newPassword: "newpassword", newPassword: "newpassword",
@@ -231,14 +349,33 @@ describe("UserPublicProfile", () => {
}); });
test("a wrong current password is reported", async () => { test("a wrong current password is reported", async () => {
route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400)); route(
const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); "POST /api/users/me/change-password",
await user.click(await screen.findByRole("button", { name: "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" }); const dialog = screen.getByRole("dialog", { name: "Change password" });
await user.type(within(dialog).getByLabelText("Current password"), "wrong"); await user.type(
await user.type(within(dialog).getByLabelText("New password"), "newpassword"); within(dialog).getByLabelText("Current password"),
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}"); "wrong",
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect"); );
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 () => { test("log out", async () => {
@@ -250,25 +387,53 @@ describe("UserPublicProfile", () => {
describe("manage", () => { describe("manage", () => {
test("an admin can change someone's role", async () => { 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 })); route(
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" }); "PATCH /api/users/:id/role",
await user.click(await screen.findByRole("button", { name: "Moderator" })); (req) => wire({ ...ann, role: (req.body as { role: string }).role }),
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active")); );
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role"); const { user } = open({
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator"); 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 () => { test("a refused role change says why", async () => {
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400)); route(
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" }); "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" })); 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 () => { 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" }); 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(); expect(screen.getByText("Her dump")).toBeInTheDocument();
}); });
@@ -286,31 +451,66 @@ describe("UserPublicProfile", () => {
return null; return null;
}); });
const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); const { user } = open({
await user.click(await screen.findByRole("button", { name: "Add category" })); 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: "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" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "Cinema" }))
expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "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: "Music" }));
await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" })); await user.click(
await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument()); 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 () => { test("a refused category is reported, and Escape closes the editor", async () => {
route("GET /api/categories", () => []); route("GET /api/categories", () => []);
route("POST /api/categories", () => fail("Slug already taken", 409)); route("POST /api/categories", () => fail("Slug already taken", 409));
const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); const { user } = open({
await user.click(await screen.findByRole("button", { name: "Add category" })); 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: "Name" }), "X");
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}"); await user.type(
expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken"); screen.getByRole("textbox", { name: "URL slug" }),
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}"); "x{Enter}",
expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument(); );
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();
}); });
}); });
}); });

View File

@@ -13,19 +13,30 @@ const annSession = makeSession({ id: "ann", username: "ann" });
function openDumps(session: ReturnType<typeof makeSession> | null = null) { function openDumps(session: ReturnType<typeof makeSession> | null = null) {
const user = userEvent.setup(); 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 }; return { user, socket: FakeWebSocket.last };
} }
describe("UserDumps", () => { describe("UserDumps", () => {
test("lists a user's dumps under a header back to the profile", async () => { 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", () => 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(); openDumps();
expect(await screen.findByText("Hers")).toBeInTheDocument(); expect(await screen.findByText("Hers")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann"); expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute(
expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument(); "href",
"/users/ann",
);
expect(screen.queryByRole("button", { name: /New dump/ })).not
.toBeInTheDocument();
}); });
test("an unknown user", async () => { test("an unknown user", async () => {
@@ -33,7 +44,8 @@ describe("UserDumps", () => {
route("GET /api/users/:u/dumps", () => page([])); route("GET /api/users/:u/dumps", () => page([]));
openDumps(); openDumps();
expect(await screen.findByRole("alert")).toBeInTheDocument(); 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 () => { 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); const { user, socket } = openDumps(annSession);
await screen.findByText("Mine"); await screen.findByText("Mine");
socket.welcome(); 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(); expect(screen.getByText("Mine")).toBeInTheDocument();
route("GET /api/preview", () => ({ reached: false, richContent: null })); route("GET /api/preview", () => ({ reached: false, richContent: null }));
await user.click(screen.getByRole("button", { name: /New dump/ })); 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 () => { test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
route("GET /api/users/:u", () => wire(ann)); 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)); route("GET /api/users/:u/dumps", () => page(list));
const { socket } = openDumps(); const { socket } = openDumps();
await screen.findByText("T-z"); await screen.findByText("T-z");
socket.welcome(); 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(); expect(screen.queryByText("T-y")).not.toBeInTheDocument();
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) }); socket.receive({
const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent); 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"]); expect(titles).toEqual(["T-x", "T-y", "T-z"]);
}); });
}); });
describe("UserUpvoted", () => { 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) { function openVotes(session: ReturnType<typeof makeSession> | null = null) {
route("GET /api/users/:u", () => wire(ann)); route("GET /api/users/:u", () => wire(ann));
route("GET /api/users/:u/votes", () => page(voted())); route("GET /api/users/:u/votes", () => page(voted()));
const user = userEvent.setup(); 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 }; return { user, socket: FakeWebSocket.last };
} }
test("lists what they upvoted", async () => { test("lists what they upvoted", async () => {
openVotes(); openVotes();
expect(await screen.findByText("Liked one")).toBeInTheDocument(); 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 () => { 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); const { socket } = openVotes(annSession);
await screen.findByText("Liked one"); await screen.findByText("Liked one");
socket.welcome({ myVotes: ["v1", "v2"] }); socket.welcome({ myVotes: ["v1", "v2"] });
socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" }); socket.receive({
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading"); 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)); act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Liked one")).not.toBeInTheDocument(); expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
} finally { } finally {
@@ -105,13 +146,24 @@ describe("UserUpvoted", () => {
}); });
test("their new upvote appears at the top", async () => { 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(); const { socket } = openVotes();
await screen.findByText("Liked one"); await screen.findByText("Liked one");
socket.welcome(); 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(() => await waitFor(() =>
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like") expect(document.querySelector(".dump-card-title")).toHaveTextContent(
"Fresh like",
)
); );
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More