v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,11 @@ function parseIPv6(s: string): number[] | null {
|
||||
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||
const fill = 8 - head.length - rest.length;
|
||||
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;
|
||||
return groups.flatMap((g) => {
|
||||
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
|
||||
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.slice(4, 12).every((x) => x === 0)
|
||||
) return isPrivateIPv4(b.slice(12));
|
||||
|
||||
@@ -27,7 +27,9 @@ export function emojiToCodepoint(emoji: string): string {
|
||||
let wasmReady: Promise<unknown> | undefined;
|
||||
function ensureWasm(): Promise<unknown> {
|
||||
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));
|
||||
}
|
||||
return wasmReady;
|
||||
@@ -62,7 +64,10 @@ async function fetchGlyph(emoji: string): Promise<string> {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -152,10 +157,16 @@ export async function generateSiteIcons(
|
||||
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
|
||||
}
|
||||
// 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(
|
||||
out("favicon.ico"),
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Dump,
|
||||
isRole,
|
||||
type Notification,
|
||||
type NotificationData,
|
||||
type NotificationType,
|
||||
type Playlist,
|
||||
type RichContent,
|
||||
@@ -494,7 +495,7 @@ export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type as NotificationType,
|
||||
data: JSON.parse(row.data),
|
||||
data: JSON.parse(row.data) as NotificationData,
|
||||
read: Boolean(row.read),
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
|
||||
@@ -118,6 +118,38 @@ export interface User {
|
||||
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 {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -144,6 +176,13 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
|
||||
"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(
|
||||
obj: unknown,
|
||||
): obj is RegisterUserRequest {
|
||||
@@ -210,9 +249,24 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
/** The session token, plus the media-only one for file and thumbnail URLs. */
|
||||
export interface AuthTokens {
|
||||
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 {
|
||||
@@ -288,6 +342,22 @@ export interface PaginatedData<T> {
|
||||
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 {
|
||||
readonly code: APIErrorCode;
|
||||
readonly status: number;
|
||||
@@ -747,7 +817,7 @@ export interface PlaylistDumpsUpdatedMessage {
|
||||
|
||||
export interface UserUpdatedMessage {
|
||||
type: "user_updated";
|
||||
user: Omit<User, "passwordHash" | "email">;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface CommentCreatedMessage {
|
||||
@@ -822,6 +892,7 @@ export interface ChatReadUpdateMessage {
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| WelcomeMessage
|
||||
| PresenceUpdateMessage
|
||||
| VotesUpdateMessage
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
createAttachment,
|
||||
getAttachment,
|
||||
} from "../services/attachment-service.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type AttachmentResponse,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
ATTACHMENTS_DIR,
|
||||
serveUploadedFile,
|
||||
@@ -43,8 +48,12 @@ router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const responseBody: APIResponse<AttachmentResponse> = {
|
||||
success: true,
|
||||
data: { id: attachmentId },
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: { id: attachmentId } };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/attachments/:attachmentId", async (ctx) => {
|
||||
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
broadcastUserUpdated,
|
||||
updateClientAvatar,
|
||||
} 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 {
|
||||
AVATARS_DIR,
|
||||
serveUploadedFile,
|
||||
@@ -47,14 +54,16 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
}
|
||||
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,
|
||||
// comments, OP, profiles) — not just the presence row. updated_at, bumped on
|
||||
// the avatar write, doubles as the cache-busting version.
|
||||
const { email: _email, ...broadcastUser } = publicUser;
|
||||
broadcastUserUpdated(broadcastUser);
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
broadcastUserUpdated(toPublicUser(user));
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(user),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/avatars/:userId", async (ctx) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../model/interfaces.ts";
|
||||
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
|
||||
import type { Tralbum } from "../services/bandcamp-tralbum.ts";
|
||||
|
||||
const bandcampRouter = new Router();
|
||||
|
||||
@@ -15,12 +17,15 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
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
|
||||
// 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.body = { success: true, data: album };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default bandcampRouter;
|
||||
|
||||
@@ -63,7 +63,7 @@ router.post(
|
||||
authMiddleware,
|
||||
requirePermission("category:manage"),
|
||||
async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -86,7 +86,7 @@ router.put(
|
||||
authMiddleware,
|
||||
requirePermission("category:manage"),
|
||||
async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type Comment,
|
||||
isCreateCommentRequest,
|
||||
isUpdateCommentRequest,
|
||||
type PaginatedData,
|
||||
type PublicUser,
|
||||
toPublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.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) => {
|
||||
const userId = ctx.state.user.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)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -68,7 +71,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
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)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -98,14 +101,15 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/comments/:commentId — auth required
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isUpdateDumpRequest,
|
||||
type PaginatedData,
|
||||
type Playlist,
|
||||
type PublicUser,
|
||||
toPublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
@@ -45,7 +47,7 @@ const router = new Router({ prefix: "/api/dumps" });
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
@@ -87,7 +89,7 @@ router.post(
|
||||
parseCategoryIds(formData.get("categoryIds")),
|
||||
);
|
||||
} else {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isCreateUrlDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
@@ -129,14 +131,15 @@ router.get("/:dumpId/voters", async (ctx) => {
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getDumpVoters(dump.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:dumpId/related", async (ctx) => {
|
||||
@@ -215,7 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
@@ -10,16 +15,20 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
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.body = { success: true, data: { token } };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Validate an invite token (used by the register page before showing the form)
|
||||
router.get("/:token", (ctx) => {
|
||||
try {
|
||||
validateInvite(ctx.params.token);
|
||||
ctx.response.body = { success: true, data: null };
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type Notification,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
@@ -25,14 +27,11 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Notification>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all
|
||||
|
||||
@@ -3,9 +3,13 @@ import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
isCreatePlaylistRequest,
|
||||
isReorderPlaylistRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
type Playlist,
|
||||
type PlaylistMembership,
|
||||
type PlaylistWithDumps,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.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) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const userId = ctx.state.user.userId;
|
||||
const memberships = getPlaylistMembershipsForDump(dumpId, userId);
|
||||
ctx.response.body = { success: true, data: memberships };
|
||||
const responseBody: APIResponse<PlaylistMembership[]> = {
|
||||
success: true,
|
||||
data: getPlaylistMembershipsForDump(dumpId, userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/playlists — create
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -48,21 +55,27 @@ router.post("/", authMiddleware, async (ctx) => {
|
||||
"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.body = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/playlists/:playlistId — optional auth
|
||||
router.get("/:playlistId", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId);
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
const responseBody: APIResponse<PlaylistWithDumps> = {
|
||||
success: true,
|
||||
data: getPlaylist(ctx.params.playlistId, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// PUT /api/playlists/:playlistId — update metadata
|
||||
router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -76,7 +89,8 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
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
|
||||
@@ -158,7 +172,8 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
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
|
||||
@@ -177,7 +192,7 @@ router.get("/:playlistId/image", async (ctx) => {
|
||||
|
||||
// PUT /api/playlists/:playlistId/order — reorder
|
||||
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)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -191,7 +206,8 @@ router.put("/:playlistId/order", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
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;
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
isValidHttpUrl,
|
||||
tryFetchRichContent,
|
||||
} 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";
|
||||
|
||||
const previewRouter = new Router();
|
||||
@@ -25,11 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIFailure = {
|
||||
success: false,
|
||||
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
|
||||
};
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
// `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
|
||||
// bare card for both.
|
||||
const { ok, content } = await tryFetchRichContent(url);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<LinkPreview> = {
|
||||
success: true,
|
||||
data: { reached: ok, richContent: content ?? null },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parsePagination } from "../lib/pagination.ts";
|
||||
import { searchDumps } from "../services/dump-service.ts";
|
||||
import { searchUsersGlobal } from "../services/user-service.ts";
|
||||
import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import type { APIResponse, SearchResults } from "../model/interfaces.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/search" });
|
||||
|
||||
@@ -14,7 +15,7 @@ router.get("/", async (ctx) => {
|
||||
const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined;
|
||||
|
||||
if (!q) {
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SearchResults> = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: { items: [], total: 0, hasMore: false },
|
||||
@@ -22,6 +23,7 @@ router.get("/", async (ctx) => {
|
||||
playlists: [],
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ router.get("/", async (ctx) => {
|
||||
const users = searchUsersGlobal(q, 20);
|
||||
const playlists = searchPlaylists(q, 20, requestingUserId);
|
||||
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SearchResults> = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: {
|
||||
@@ -46,6 +48,7 @@ router.get("/", async (ctx) => {
|
||||
playlists,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,11 +3,25 @@ import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type AuthResponse,
|
||||
type AuthTokens,
|
||||
type Dump,
|
||||
fieldOf,
|
||||
type Invitee,
|
||||
isLoginUserRequest,
|
||||
isRegisterUserRequest,
|
||||
isRole,
|
||||
isUpdateUserRequest,
|
||||
type MediaTokenResponse,
|
||||
type PaginatedData,
|
||||
type Playlist,
|
||||
type PublicUser,
|
||||
type SelfUser,
|
||||
toPublicUser,
|
||||
toSelfUser,
|
||||
type User,
|
||||
type UserSuggestion,
|
||||
validateRegisterUserRequest,
|
||||
} 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
|
||||
// thumbnail URLs (see createMediaToken).
|
||||
async function issueTokens(user: User) {
|
||||
async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
userId: user.id,
|
||||
@@ -73,11 +87,14 @@ async function issueTokens(user: User) {
|
||||
|
||||
// Register a new user (requires a valid invite token)
|
||||
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 (registerError) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
validateRegisterUserRequest(body) ?? "Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
@@ -157,11 +174,11 @@ router.post("/login", async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
@@ -175,14 +192,11 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = getUserById(
|
||||
ctx.state.user.userId,
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
} catch (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).
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||||
success: true,
|
||||
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Request a password reset email (unauthenticated; always returns 200)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
||||
if (email) {
|
||||
await requestPasswordReset(email).catch((err) =>
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
const trimmed = typeof email === "string" ? email.trim() : "";
|
||||
if (trimmed) {
|
||||
await requestPasswordReset(trimmed).catch((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)
|
||||
router.post("/reset-password", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const token = fieldOf(body, "token");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
if (typeof token !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -226,16 +244,15 @@ router.post("/reset-password", async (ctx) => {
|
||||
);
|
||||
}
|
||||
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)
|
||||
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { currentPassword, newPassword } = (body ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const currentPassword = fieldOf(body, "currentPassword");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
|
||||
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
@@ -271,12 +288,16 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
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.)
|
||||
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)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -292,9 +313,13 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
});
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
const publicUser = toPublicUser(updated);
|
||||
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.
|
||||
@@ -303,10 +328,14 @@ router.patch(
|
||||
authMiddleware,
|
||||
requirePermission("user:manage"),
|
||||
async (ctx: AuthContext<"/:userId/role">) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const role: unknown = body?.role;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const role = fieldOf(body, "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
|
||||
// locking themselves out, and self-promotion is meaningless anyway.
|
||||
@@ -318,24 +347,33 @@ router.patch(
|
||||
);
|
||||
}
|
||||
const updated = await updateUser(ctx.params.userId, { role });
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
const publicUser = toPublicUser(updated);
|
||||
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
|
||||
router.get("/search", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
const results = searchUsers(q, 8);
|
||||
ctx.response.body = { success: true, data: results };
|
||||
const responseBody: APIResponse<UserSuggestion[]> = {
|
||||
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)
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
const user = getUserById(ctx.params.userId);
|
||||
const { passwordHash: _, email: _email, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserById(ctx.params.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed users for a user (public)
|
||||
@@ -343,14 +381,15 @@ router.get("/:username/followed-users", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getFollowedUsersByUser(user.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed playlists for a user (public only)
|
||||
@@ -358,14 +397,11 @@ router.get("/:username/followed-playlists", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Playlists by user (optional auth: include private only if requester === owner)
|
||||
@@ -379,28 +415,30 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Invite tree for a user (public)
|
||||
router.get("/:username/invitees", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const tree = getInviteTree(user.id);
|
||||
ctx.response.body = { success: true, data: tree };
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
success: true,
|
||||
data: getInviteTree(user.id),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
router.get("/:username", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { passwordHash: _, email: _email, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserByUsername(ctx.params.username)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// 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 includePrivate = requestingUserId === user.id;
|
||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||
@@ -431,14 +466,11 @@ router.get("/:username/votes", async (ctx) => {
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
|
||||
import {
|
||||
APIException,
|
||||
type ClientToServerMessage,
|
||||
type ServerToClientMessage,
|
||||
type User,
|
||||
} 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.
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -219,7 +220,6 @@ function handleVote(
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action,
|
||||
success: true,
|
||||
voteCount,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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();
|
||||
|
||||
@@ -11,10 +15,13 @@ const youtubeRouter = new Router();
|
||||
*/
|
||||
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||
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.body = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default youtubeRouter;
|
||||
|
||||
@@ -147,7 +147,9 @@ export function updateCategory(
|
||||
if (req.slug !== undefined && req.slug !== existing.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 now = new Date();
|
||||
|
||||
|
||||
@@ -115,7 +115,9 @@ export async function resetPassword(
|
||||
try {
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
} 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);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type Invitee,
|
||||
type PublicUser,
|
||||
type RegisterUserRequest,
|
||||
toPublicUser,
|
||||
type UpdateUserRequest,
|
||||
type User,
|
||||
type UserSuggestion,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
||||
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
||||
@@ -129,7 +133,7 @@ export function getUserByEmail(email: string): User | null {
|
||||
export function searchUsers(
|
||||
query: string,
|
||||
limit: number,
|
||||
): { id: string; username: string; avatarMime: string | null }[] {
|
||||
): UserSuggestion[] {
|
||||
if (!query) return [];
|
||||
const rows = db.prepare(
|
||||
`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 [];
|
||||
const pattern = `%${query}%`;
|
||||
const rows = db.prepare(
|
||||
`${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`,
|
||||
).all(pattern, pattern, limit);
|
||||
return rows.filter(isUserRow).map(userRowToApi);
|
||||
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
@@ -231,15 +236,7 @@ export function updateUserAvatar(userId: string, mime: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getInviteTree(
|
||||
userId: string,
|
||||
): {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime?: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
}[] {
|
||||
export function getInviteTree(userId: string): Invitee[] {
|
||||
const rows = db.prepare(`
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
Dump,
|
||||
OnlineUser,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
Role,
|
||||
ServerToClientMessage,
|
||||
User,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
isMemberVisibleTo,
|
||||
@@ -223,9 +223,7 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastUserUpdated(
|
||||
user: Omit<User, "passwordHash" | "email">,
|
||||
): void {
|
||||
export function broadcastUserUpdated(user: PublicUser): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "user_updated", user });
|
||||
}
|
||||
|
||||
@@ -72,29 +72,43 @@ function firstString(node: unknown, key: string): string | undefined {
|
||||
return found.find((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type Json = any;
|
||||
/** Follows `path` into parsed JSON; undefined wherever a step is missing. */
|
||||
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 {
|
||||
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||
const videoId = lockup.contentId;
|
||||
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content;
|
||||
function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
|
||||
if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||
const videoId = at(lockup, "contentId");
|
||||
const title = at(
|
||||
lockup,
|
||||
"metadata",
|
||||
"lockupMetadataViewModel",
|
||||
"title",
|
||||
"content",
|
||||
);
|
||||
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||
const badges: unknown[] = [];
|
||||
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges);
|
||||
collect(at(lockup, "contentImage"), "thumbnailBadgeViewModel", badges);
|
||||
const duration = badges
|
||||
.map((b) => (b as Json)?.text)
|
||||
.map((b) => at(b, "text"))
|
||||
.filter((t): t is string => typeof t === "string")
|
||||
.map(parseClock)
|
||||
.find((d) => d !== undefined);
|
||||
return { videoId, title, duration };
|
||||
}
|
||||
|
||||
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null {
|
||||
const videoId = r?.videoId;
|
||||
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText;
|
||||
function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
|
||||
const videoId = at(r, "videoId");
|
||||
const title = at(r, "title", "runs", 0, "text") ??
|
||||
at(r, "title", "simpleText");
|
||||
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||
const secs = Number(r.lengthSeconds);
|
||||
const secs = Number(at(r, "lengthSeconds"));
|
||||
return {
|
||||
videoId,
|
||||
title,
|
||||
@@ -111,7 +125,7 @@ export function parsePlaylistPage(
|
||||
const end = html.indexOf(";</script>", start);
|
||||
if (end < 0) return null;
|
||||
|
||||
let data: Json;
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
|
||||
} catch {
|
||||
@@ -119,9 +133,9 @@ export function parsePlaylistPage(
|
||||
}
|
||||
|
||||
const lockups: unknown[] = [];
|
||||
collect(data?.contents, "lockupViewModel", lockups);
|
||||
collect(at(data, "contents"), "lockupViewModel", lockups);
|
||||
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
|
||||
// stays positional and matches what the embed plays.
|
||||
@@ -131,8 +145,10 @@ export function parsePlaylistPage(
|
||||
].filter((t): t is YouTubePlaylistTrack => t !== null);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const title = data?.metadata?.playlistMetadataRenderer?.title ??
|
||||
firstString(data?.header, "content");
|
||||
const metaTitle = at(data, "metadata", "playlistMetadataRenderer", "title");
|
||||
const title = typeof metaTitle === "string"
|
||||
? metaTitle
|
||||
: firstString(at(data, "header"), "content");
|
||||
return { listId, title, tracks };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Tokens, passwords and the checks that turn a token into a user.
|
||||
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
|
||||
import type {
|
||||
AuthResponse,
|
||||
AuthTokens,
|
||||
MediaTokenResponse,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
assert,
|
||||
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" } });
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
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 },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals(ok.body.data.user.id, user.id);
|
||||
assertEquals(ok.body.data.user.passwordHash, undefined);
|
||||
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id);
|
||||
assertEquals(ok.data.user.id, user.id);
|
||||
assertFalse("passwordHash" in ok.data.user);
|
||||
assertEquals((await verifyJWT(ok.data.token))?.userId, user.id);
|
||||
assertEquals(
|
||||
(await verifyMediaToken(ok.body.data.mediaToken))?.userId,
|
||||
(await verifyMediaToken(ok.data.mediaToken))?.userId,
|
||||
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(unknown.status, 401);
|
||||
assertEquals(wrong.body.error.message, unknown.body.error.message);
|
||||
assertEquals(wrong.error.message, unknown.error.message);
|
||||
|
||||
assertEquals(
|
||||
(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 () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const res = await post("/api/users/register", {
|
||||
const res = await post<AuthResponse>("/api/users/register", {
|
||||
json: {
|
||||
username: "newbie_reg",
|
||||
password: "longenough",
|
||||
@@ -231,10 +236,10 @@ test("POST /api/users/register: an invite opens one account", async () => {
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.user.username, "newbie_reg");
|
||||
assertEquals(res.body.data.user.role, "user");
|
||||
assertEquals(res.body.data.user.passwordHash, undefined);
|
||||
assert(res.body.data.token);
|
||||
assertEquals(res.data.user.username, "newbie_reg");
|
||||
assertEquals(res.data.user.role, "user");
|
||||
assertFalse("passwordHash" in res.data.user);
|
||||
assert(res.data.token);
|
||||
const row = db.prepare(
|
||||
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
|
||||
).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) {
|
||||
const res = await post("/api/users/register", { 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", {
|
||||
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
|
||||
// second than the change.
|
||||
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,
|
||||
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
||||
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 authenticateMedia(ok.body.data.mediaToken), user.id);
|
||||
assertEquals(await authenticateMedia(ok.data.mediaToken), user.id);
|
||||
|
||||
const login = await post("/api/users/login", {
|
||||
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 () => {
|
||||
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", {
|
||||
auth: user.auth,
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id);
|
||||
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
|
||||
});
|
||||
|
||||
@@ -10,19 +10,28 @@ import {
|
||||
put,
|
||||
test,
|
||||
} 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 () => {
|
||||
insertCategory({ slug: "zeta", position: 0 });
|
||||
insertCategory({ slug: "alpha", position: 1 });
|
||||
insertCategory({ slug: "beta", position: 0 });
|
||||
const list = await get("/api/categories");
|
||||
assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [
|
||||
const list = await get<Category[]>("/api/categories");
|
||||
assertEquals(list.data.map((c) => c.slug), [
|
||||
"beta",
|
||||
"zeta",
|
||||
"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);
|
||||
});
|
||||
|
||||
@@ -35,9 +44,12 @@ test("categories: only category:manage may create, update or delete", async () =
|
||||
(await post("/api/categories", { auth: mod.auth, json })).status,
|
||||
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);
|
||||
const id = created.body.data.id;
|
||||
const id = created.data.id;
|
||||
assertEquals(
|
||||
(await put(`/api/categories/${id}`, {
|
||||
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 () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const create = (json: unknown) =>
|
||||
post("/api/categories", { auth: admin.auth, json });
|
||||
post<Category>("/api/categories", { auth: admin.auth, json });
|
||||
for (
|
||||
const [json, message] of [
|
||||
[{ 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);
|
||||
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);
|
||||
|
||||
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" });
|
||||
assertEquals(dupSlug.status, 409);
|
||||
assertStringIncludes(dupSlug.body.error.message, "Slug");
|
||||
assertStringIncludes(dupSlug.error.message, "Slug");
|
||||
const dupName = await create({ slug: "other", name: "Music" });
|
||||
assertEquals(dupName.status, 409);
|
||||
assertStringIncludes(dupName.body.error.message, "Name");
|
||||
assertStringIncludes(dupName.error.message, "Name");
|
||||
|
||||
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,
|
||||
json: { slug: "music" },
|
||||
});
|
||||
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,
|
||||
json: { name: "Movies", position: 3 },
|
||||
});
|
||||
assertEquals(ok.body.data.slug, "films");
|
||||
assertEquals(ok.body.data.name, "Movies");
|
||||
assertEquals(ok.body.data.position, 3);
|
||||
assertEquals(ok.data.slug, "films");
|
||||
assertEquals(ok.data.name, "Movies");
|
||||
assertEquals(ok.data.position, 3);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
const anon = await get("/api/categories/music/dumps");
|
||||
assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [
|
||||
const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps");
|
||||
assertEquals(anon.data.items.map((d) => d.title), [
|
||||
"in",
|
||||
]);
|
||||
assertEquals(anon.body.data.items[0].categoryIds, [cat]);
|
||||
assertEquals(anon.data.items[0].categoryIds, [cat]);
|
||||
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,
|
||||
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.
|
||||
const admin = await createUser({ role: "admin" });
|
||||
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 () => {
|
||||
@@ -162,23 +179,28 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () =
|
||||
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.users.map((u: { id: string }) => u.id), [other.id]);
|
||||
assertEquals(anon.playlists.map((p: { title: string }) => p.title), [
|
||||
assertEquals(anon.users.map((u) => u.id), [other.id]);
|
||||
// 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",
|
||||
]);
|
||||
|
||||
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.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.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 },
|
||||
users: [],
|
||||
playlists: [],
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
markChatRead,
|
||||
updateChatMessage,
|
||||
} from "../services/chat-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
import { APIException, type ChatMessage } from "../model/interfaces.ts";
|
||||
|
||||
function insertMessage(
|
||||
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)));
|
||||
assertEquals((await get("/api/chat")).status, 401);
|
||||
const bodies = async (q: string) =>
|
||||
(await get(`/api/chat${q}`, { auth: user.auth })).body.data.map(
|
||||
(m: { body: string }) => m.body,
|
||||
(await get<ChatMessage[]>(`/api/chat${q}`, { auth: user.auth })).data.map(
|
||||
(m) => m.body,
|
||||
);
|
||||
assertEquals(await bodies(""), ["m1", "m2", "m3"]);
|
||||
assertEquals(await bodies("?limit=1"), ["m3"]);
|
||||
|
||||
@@ -10,35 +10,41 @@ import {
|
||||
post,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertExists } from "@std/assert";
|
||||
import { assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||
import {
|
||||
castCommentLike,
|
||||
removeCommentLike,
|
||||
} 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 () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
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,
|
||||
json: { body: " first " },
|
||||
});
|
||||
assertEquals(first.status, 201);
|
||||
assertEquals(first.body.data.body, "first");
|
||||
assertEquals(first.body.data.authorUsername, other.username);
|
||||
const reply = await post(`/api/dumps/${id}/comments`, {
|
||||
assertEquals(first.data.body, "first");
|
||||
assertEquals(first.data.authorUsername, other.username);
|
||||
const reply = await post<Comment>(`/api/dumps/${id}/comments`, {
|
||||
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);
|
||||
const list = await get(`/api/dumps/${id}/comments`);
|
||||
assertEquals(list.body.data.map((c: { body: string }) => c.body), [
|
||||
assertEquals(reply.data.parentId, first.data.id);
|
||||
const list = await get<Comment[]>(`/api/dumps/${id}/comments`);
|
||||
assertEquals(list.data.map((c) => c.body), [
|
||||
"first",
|
||||
"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 () => {
|
||||
@@ -103,7 +109,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () =>
|
||||
json: { body: "self" },
|
||||
});
|
||||
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,
|
||||
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.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");
|
||||
});
|
||||
|
||||
@@ -133,13 +139,13 @@ test("PATCH /api/comments/:id: author and moderator edit, others can't", async (
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
const edited = await patch(`/api/comments/${commentId}`, {
|
||||
const edited = await patch<Comment>(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
json: { body: " edited " },
|
||||
});
|
||||
assertEquals(edited.status, 200);
|
||||
assertEquals(edited.body.data.body, "edited");
|
||||
assertExists(edited.body.data.updatedAt);
|
||||
assertEquals(edited.data.body, "edited");
|
||||
assertExists(edited.data.updatedAt);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
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,
|
||||
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.body, "");
|
||||
assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${dumpId}`)).data.commentCount, 0);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
@@ -218,9 +224,11 @@ test("comment likes: count, conflicts, likers list and notifications", async ()
|
||||
1,
|
||||
);
|
||||
|
||||
const likers = await get(`/api/comments/${commentId}/likers`);
|
||||
assertEquals(likers.body.data.total, 2);
|
||||
assertEquals(likers.body.data.items[0].email, undefined);
|
||||
const likers = await get<PaginatedData<PublicUser>>(
|
||||
`/api/comments/${commentId}/likers`,
|
||||
);
|
||||
assertEquals(likers.data.total, 2);
|
||||
assertFalse("email" in likers.data.items[0]);
|
||||
|
||||
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
|
||||
try {
|
||||
|
||||
@@ -17,10 +17,18 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import type {
|
||||
Dump,
|
||||
DumpUrlMatch,
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertExists,
|
||||
assertFalse,
|
||||
assertMatch,
|
||||
assertNotEquals,
|
||||
} 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 () => {
|
||||
const user = await createUser();
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/a?utm_source=x", comment: "look" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
const dump = res.data;
|
||||
assertEquals(dump.kind, "url");
|
||||
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.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 = ?;`)
|
||||
.get(dump.id) as { url: string; url_canonical: string };
|
||||
// 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();
|
||||
{
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/b", title: " My title " },
|
||||
});
|
||||
assertEquals(res.body.data.title, "My title");
|
||||
assertEquals(res.data.title, "My title");
|
||||
}
|
||||
{
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("offline");
|
||||
});
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://www.offline.example/c" },
|
||||
});
|
||||
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 music = insertCategory({ slug: "music" });
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/d", categoryIds: [music, music] },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.categoryIds, [music]);
|
||||
assertEquals(res.data.categoryIds, [music]);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds,
|
||||
(await get<Dump>(`/api/dumps/${res.data.id}`)).data.categoryIds,
|
||||
[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 () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
const dump = res.data;
|
||||
assertEquals(dump.kind, "file");
|
||||
assertEquals(dump.title, "pixel.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 },
|
||||
});
|
||||
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,
|
||||
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[0].user_id, follower.id);
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -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 () => {
|
||||
const user = await createUser();
|
||||
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id);
|
||||
assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).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/${crypto.randomUUID()}`)).status, 404);
|
||||
});
|
||||
@@ -265,22 +273,25 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only",
|
||||
isPrivate: true,
|
||||
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
|
||||
});
|
||||
const page1 = await get("/api/dumps?limit=2");
|
||||
assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [
|
||||
const page1 = await get<PaginatedData<Dump>>("/api/dumps?limit=2");
|
||||
assertEquals(page1.data.items.map((d) => d.title), [
|
||||
"d4",
|
||||
"d3",
|
||||
]);
|
||||
assertEquals(page1.body.data.total, 5);
|
||||
assertEquals(page1.body.data.hasMore, true);
|
||||
const page3 = await get("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.body.data.items.length, 1);
|
||||
assertEquals(page3.body.data.hasMore, false);
|
||||
assertEquals(page1.data.total, 5);
|
||||
assertEquals(page1.data.hasMore, true);
|
||||
const page3 = await get<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.data.items.length, 1);
|
||||
assertEquals(page3.data.hasMore, false);
|
||||
|
||||
const mine = await get("/api/dumps?limit=1", { auth: owner.auth });
|
||||
assertEquals(mine.body.data.items[0].title, "secret");
|
||||
assertEquals(mine.body.data.total, 6);
|
||||
const mine = await get<PaginatedData<Dump>>("/api/dumps?limit=1", {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(mine.data.items[0].title, "secret");
|
||||
assertEquals(mine.data.total, 6);
|
||||
assertEquals(
|
||||
(await get("/api/dumps", { auth: other.auth })).body.data.total,
|
||||
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
|
||||
.total,
|
||||
5,
|
||||
);
|
||||
});
|
||||
@@ -300,19 +311,26 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
|
||||
isPrivate: true,
|
||||
});
|
||||
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
|
||||
const anon = await get(`/api/dumps/by-url?url=${q}`);
|
||||
assertEquals(anon.body.data.map((d: { title: string }) => d.title), [
|
||||
const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`);
|
||||
assertEquals(anon.data.map((d) => d.title), [
|
||||
"public",
|
||||
]);
|
||||
assertEquals(anon.body.data[0].username, owner.username);
|
||||
const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth });
|
||||
assertEquals(mine.body.data.length, 2);
|
||||
assertEquals(anon.data[0].username, owner.username);
|
||||
const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(mine.data.length, 2);
|
||||
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,
|
||||
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 () => {
|
||||
@@ -322,11 +340,11 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () =
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(id, voter.id, new Date().toISOString());
|
||||
const res = await get(`/api/dumps/${id}/voters`);
|
||||
assertEquals(res.body.data.total, 1);
|
||||
assertEquals(res.body.data.items[0].username, voter.username);
|
||||
assertEquals(res.body.data.items[0].email, undefined);
|
||||
assertEquals(res.body.data.items[0].passwordHash, undefined);
|
||||
const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
|
||||
assertEquals(res.data.total, 1);
|
||||
assertEquals(res.data.items[0].username, voter.username);
|
||||
assertFalse("email" in res.data.items[0]);
|
||||
assertFalse("passwordHash" in res.data.items[0]);
|
||||
});
|
||||
|
||||
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());
|
||||
// A new dump whose comment links the target by its URL and the secret dump by
|
||||
// its page — and itself, which is ignored.
|
||||
const src = await post("/api/dumps", {
|
||||
const src = await post<Dump>("/api/dumps", {
|
||||
auth: other.auth,
|
||||
json: {
|
||||
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`,
|
||||
},
|
||||
});
|
||||
const srcId = src.body.data.id;
|
||||
const fromSrc = await get(`/api/dumps/${srcId}/related`);
|
||||
assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [
|
||||
const srcId = src.data.id;
|
||||
const fromSrc = await get<Dump[]>(`/api/dumps/${srcId}/related`);
|
||||
assertEquals(fromSrc.data.map((d) => d.title), [
|
||||
"target",
|
||||
]);
|
||||
const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, {
|
||||
const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
|
||||
auth: other.auth,
|
||||
});
|
||||
assertEquals(fromSrcAsOwner.body.data.length, 2);
|
||||
const toTarget = await get(`/api/dumps/${target.id}/related`);
|
||||
assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]);
|
||||
assertEquals(fromSrcAsOwner.data.length, 2);
|
||||
const toTarget = await get<Dump[]>(`/api/dumps/${target.id}/related`);
|
||||
assertEquals(toTarget.data.map((d) => d.id), [srcId]);
|
||||
|
||||
// Editing the comment away removes the links.
|
||||
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 () => {
|
||||
@@ -386,12 +404,14 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
|
||||
).run(p.id, id, new Date().toISOString());
|
||||
}
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data
|
||||
.map((p: { title: string }) => p.title),
|
||||
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
|
||||
.data
|
||||
.map((p) => p.title),
|
||||
["Public"],
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data
|
||||
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: user.auth }))
|
||||
.data
|
||||
.length,
|
||||
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,
|
||||
401,
|
||||
);
|
||||
const res = await put(`/api/dumps/${slug}`, {
|
||||
const res = await put<Dump>(`/api/dumps/${slug}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New title", comment: "hello" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "New title");
|
||||
assertMatch(res.body.data.slug, /^new-title-/);
|
||||
assertNotEquals(res.body.data.slug, slug);
|
||||
assertExists(res.body.data.updatedAt);
|
||||
assertEquals(res.data.title, "New title");
|
||||
assertMatch(res.data.slug ?? "", /^new-title-/);
|
||||
assertNotEquals(res.data.slug, slug);
|
||||
assertExists(res.data.updatedAt);
|
||||
const byMod = await put(`/api/dumps/${id}`, {
|
||||
auth: mod.auth,
|
||||
json: { title: "Moderated" },
|
||||
});
|
||||
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 () => {
|
||||
@@ -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.
|
||||
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } });
|
||||
assertEquals(net.calls.length, 0);
|
||||
const moved = await put(`/api/dumps/${id}`, {
|
||||
const moved = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
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);
|
||||
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
|
||||
id,
|
||||
@@ -476,21 +496,21 @@ test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [a] },
|
||||
});
|
||||
const kept = await put(`/api/dumps/${id}`, {
|
||||
const kept = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "t" },
|
||||
});
|
||||
assertEquals(kept.body.data.categoryIds, [a]);
|
||||
const replaced = await put(`/api/dumps/${id}`, {
|
||||
assertEquals(kept.data.categoryIds, [a]);
|
||||
const replaced = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [b] },
|
||||
});
|
||||
assertEquals(replaced.body.data.categoryIds, [b]);
|
||||
const cleared = await put(`/api/dumps/${id}`, {
|
||||
assertEquals(replaced.data.categoryIds, [b]);
|
||||
const cleared = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [] },
|
||||
});
|
||||
assertEquals(cleared.body.data.categoryIds, []);
|
||||
assertEquals(cleared.data.categoryIds, []);
|
||||
});
|
||||
|
||||
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"] },
|
||||
});
|
||||
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.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(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 () => {
|
||||
@@ -553,7 +573,7 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
|
||||
title: "file",
|
||||
fileName: "a.txt",
|
||||
});
|
||||
const res = await put(`/api/dumps/${id}`, {
|
||||
const res = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: {
|
||||
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.body.data.title, "Renamed");
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.body.data.url, undefined);
|
||||
assertEquals(res.data.title, "Renamed");
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(res.data.url, undefined);
|
||||
});
|
||||
|
||||
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,
|
||||
403,
|
||||
);
|
||||
const res = await put(`/api/dumps/${id}/file`, {
|
||||
const res = await put<Dump>(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: form(),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.fileName, "b.png");
|
||||
assertEquals(res.body.data.title, "Replaced");
|
||||
assertEquals(res.data.fileName, "b.png");
|
||||
assertEquals(res.data.title, "Replaced");
|
||||
assertEquals(readFile(`dumps/${id}`), PNG);
|
||||
|
||||
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 { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
|
||||
const replace = (fields: Record<string, string>) =>
|
||||
put(`/api/dumps/${id}/file`, {
|
||||
put<Dump>(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
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;
|
||||
|
||||
let res = await replace({ isPrivate: "true" });
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(await visible(), false);
|
||||
assertEquals((await get(`/api/files/${id}`)).status, 404);
|
||||
// Left out, it stays private.
|
||||
res = await replace({});
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(await visible(), false);
|
||||
res = await replace({ isPrivate: "false" });
|
||||
assertEquals(res.body.data.isPrivate, false);
|
||||
assertEquals(res.data.isPrivate, false);
|
||||
assertEquals(await visible(), true);
|
||||
// A refused replacement changes neither.
|
||||
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
|
||||
@@ -654,19 +674,21 @@ test("custom thumbnails: set, served, removed", async () => {
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
const set = await put(`/api/dumps/${id}/thumbnail`, {
|
||||
const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(set.status, 200);
|
||||
assertEquals(set.body.data.thumbnailMime, "image/png");
|
||||
assertEquals(set.data.thumbnailMime, "image/png");
|
||||
const thumb = await get(`/api/thumbnails/${id}`);
|
||||
assertEquals(thumb.status, 200);
|
||||
assertEquals(thumb.headers.get("content-type"), "image/png");
|
||||
assertEquals(thumb.bytes, PNG);
|
||||
|
||||
const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth });
|
||||
assertEquals(removed.body.data.thumbnailMime, undefined);
|
||||
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(removed.data.thumbnailMime, undefined);
|
||||
assertEquals(readFile(`thumbnails/${id}-custom`), null);
|
||||
// A URL dump without its own thumbnail has none to serve.
|
||||
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(() => {
|
||||
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,
|
||||
});
|
||||
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());
|
||||
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
|
||||
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
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" });
|
||||
assertEquals(
|
||||
@@ -760,14 +782,12 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
|
||||
}
|
||||
for (const sub of ["dumps", "votes"]) {
|
||||
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(
|
||||
(await get(path, { auth: other.auth })).body.data.total,
|
||||
1,
|
||||
sub,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(path, { auth: owner.auth })).body.data.total,
|
||||
await total({ auth: owner.auth }),
|
||||
2,
|
||||
sub,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { db } from "../model/db.ts";
|
||||
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
||||
import { makeSlug } from "../lib/slugify.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 };
|
||||
|
||||
@@ -245,28 +245,35 @@ export function count(sql: string, ...params: (string | number)[]): number {
|
||||
|
||||
export const BASE_URL = "http://gerbeur.test";
|
||||
|
||||
export interface ApiResponse {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
body: any;
|
||||
/** The parsed body when it's JSON, null otherwise. */
|
||||
body: APIResponse<T> | null;
|
||||
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 = {
|
||||
auth?: { Authorization: string };
|
||||
json?: unknown;
|
||||
form?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a request through the full middleware stack in-process — no socket.
|
||||
* `json` sends a JSON body, `form` a multipart one.
|
||||
* `json` sends a JSON body, `form` a multipart one. `T` is what the endpoint
|
||||
* answers with in `data`.
|
||||
*/
|
||||
export async function request(
|
||||
export async function request<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: {
|
||||
auth?: { Authorization: string };
|
||||
json?: unknown;
|
||||
form?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<ApiResponse> {
|
||||
opts: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = new Headers({ ...opts.auth, ...opts.headers });
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.json !== undefined) {
|
||||
@@ -281,23 +288,41 @@ export async function request(
|
||||
if (!res) throw new Error(`No response for ${method} ${path}`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
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) {
|
||||
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]) =>
|
||||
request("GET", path, opts);
|
||||
export const post = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("POST", path, opts);
|
||||
export const put = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PUT", path, opts);
|
||||
export const patch = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PATCH", path, opts);
|
||||
export const del = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("DELETE", path, opts);
|
||||
export const get = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("GET", path, opts);
|
||||
export const post = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("POST", path, opts);
|
||||
export const put = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("PUT", path, opts);
|
||||
export const patch = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("PATCH", path, opts);
|
||||
export const del = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("DELETE", path, opts);
|
||||
|
||||
// ── Files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
// their parsers and their routes. All network access is stubbed.
|
||||
import { get, html, stubNetwork } from "./helpers.ts";
|
||||
import { assert, assertEquals, assertFalse } from "@std/assert";
|
||||
import { parseTralbum } from "../services/bandcamp-tralbum.ts";
|
||||
import { parsePlaylistPage } from "../services/youtube-playlist-service.ts";
|
||||
import { parseTralbum, type Tralbum } from "../services/bandcamp-tralbum.ts";
|
||||
import {
|
||||
parsePlaylistPage,
|
||||
type YouTubePlaylist,
|
||||
} from "../services/youtube-playlist-service.ts";
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replaceAll("&", "&").replaceAll('"', """);
|
||||
@@ -147,10 +150,12 @@ Deno.test("parsePlaylistPage: current and legacy rows", () => {
|
||||
Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => {
|
||||
using net = stubNetwork(() => html(tralbumPage(ALBUM)));
|
||||
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.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.
|
||||
await get(
|
||||
`/api/bandcamp/tracks?url=${
|
||||
@@ -188,9 +193,9 @@ Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () =
|
||||
? new Response("", { status: 404 })
|
||||
: 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.body.data.tracks.length, 2);
|
||||
assertEquals(ok.data.tracks.length, 2);
|
||||
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
|
||||
// The pre-answered consent cookie keeps EU requests off the consent page.
|
||||
assertEquals(cookie, "SOCS=CAI");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ApiResponse,
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
@@ -13,29 +14,35 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import type {
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assert, assertEquals, assertMatch } from "@std/assert";
|
||||
|
||||
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] {
|
||||
return res.body.data.dumps.map((d) => d.id);
|
||||
function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
|
||||
return res.data.dumps.map((d) => d.id);
|
||||
}
|
||||
|
||||
test("POST /api/playlists creates; GET finds it by id or slug", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/playlists", {
|
||||
const res = await post<Playlist>("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Road Trip", description: "songs", isPublic: true },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const { id, slug } = res.body.data;
|
||||
assertMatch(slug, /^road-trip-/);
|
||||
const { id, slug } = res.data;
|
||||
assertMatch(slug ?? "", /^road-trip-/);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`)).body.data.title,
|
||||
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
|
||||
"Road Trip",
|
||||
);
|
||||
const bySlug = await get(`/api/playlists/${slug}`);
|
||||
assertEquals(bySlug.body.data.id, id);
|
||||
assertEquals(bySlug.body.data.ownerUsername, user.username);
|
||||
assertEquals(bySlug.body.data.dumps, []);
|
||||
const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`);
|
||||
assertEquals(bySlug.data.id, id);
|
||||
assertEquals(bySlug.data.ownerUsername, user.username);
|
||||
assertEquals(bySlug.data.dumps, []);
|
||||
assertEquals(
|
||||
(await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
|
||||
.status,
|
||||
@@ -63,8 +70,11 @@ test("private playlists are the owner's alone", async () => {
|
||||
);
|
||||
insertPlaylist({ userId: owner.id });
|
||||
const path = `/api/users/${owner.username}/playlists`;
|
||||
assertEquals((await get(path)).body.data.total, 1);
|
||||
assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2);
|
||||
assertEquals((await get<PaginatedData<Playlist>>(path)).data.total, 1);
|
||||
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 () => {
|
||||
@@ -109,7 +119,10 @@ test("adding dumps: newest on top, no duplicates, only dumps you can see", async
|
||||
.status,
|
||||
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 () => {
|
||||
@@ -201,19 +214,19 @@ test("removing a dump; updating; deleting", async () => {
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
const updated = await put(`/api/playlists/${id}`, {
|
||||
const updated = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New", description: "d", isPublic: false },
|
||||
});
|
||||
assertEquals(updated.body.data.title, "New");
|
||||
assertMatch(updated.body.data.slug, /^new-/);
|
||||
assertEquals(updated.body.data.isPublic, false);
|
||||
const cleared = await put(`/api/playlists/${id}`, {
|
||||
assertEquals(updated.data.title, "New");
|
||||
assertMatch(updated.data.slug ?? "", /^new-/);
|
||||
assertEquals(updated.data.isPublic, false);
|
||||
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { description: null },
|
||||
});
|
||||
assertEquals(cleared.body.data.description, undefined);
|
||||
assertEquals(cleared.body.data.title, "New");
|
||||
assertEquals(cleared.data.description, undefined);
|
||||
assertEquals(cleared.data.title, "New");
|
||||
|
||||
assertEquals(
|
||||
(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" });
|
||||
const d = insertDump({ userId: owner.id });
|
||||
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
const res = await get<PlaylistMembership[]>(
|
||||
`/api/playlists/by-dump/${d.id}/memberships`,
|
||||
{
|
||||
auth: owner.auth,
|
||||
},
|
||||
);
|
||||
const byTitle = Object.fromEntries(
|
||||
res.body.data.map((
|
||||
res.data.map((
|
||||
m: { playlist: { title: string }; hasDump: boolean },
|
||||
) => [
|
||||
m.playlist.title,
|
||||
@@ -292,14 +308,14 @@ test("playlist images: upload and serve", async () => {
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const res = await post(`/api/playlists/${slug}/image`, {
|
||||
const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
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.
|
||||
assert(res.body.data.updatedAt);
|
||||
assert(res.data.updatedAt);
|
||||
const served = await get(`/api/playlists/${slug}/image`);
|
||||
assertEquals(served.status, 200);
|
||||
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 () => {
|
||||
const owner = await createUser();
|
||||
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,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
|
||||
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,
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Follows, feeds, notifications and invites.
|
||||
import {
|
||||
type ApiResponse,
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
@@ -12,10 +13,20 @@ import {
|
||||
post,
|
||||
test,
|
||||
} 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 }[] } } }) {
|
||||
return res.body.data.items.map((d) => d.title);
|
||||
function titles(res: ApiResponse<PaginatedData<Dump>>): string[] {
|
||||
return res.data.items.map((d) => d.title);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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: [] },
|
||||
);
|
||||
// Unfollow and follow again: still just the one notification.
|
||||
await del(`/api/follows/users/${them.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
|
||||
.followedUserIds,
|
||||
[],
|
||||
);
|
||||
@@ -52,11 +63,13 @@ test("following users: status, idempotence, no self-follow, one notification", a
|
||||
),
|
||||
1,
|
||||
);
|
||||
const list = await get(`/api/users/${me.username}/followed-users`);
|
||||
assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [
|
||||
const list = await get<PaginatedData<PublicUser>>(
|
||||
`/api/users/${me.username}/followed-users`,
|
||||
);
|
||||
assertEquals(list.data.items.map((u) => u.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 () => {
|
||||
@@ -86,13 +99,15 @@ test("following playlists: public ones only, and not your own notification", asy
|
||||
1,
|
||||
);
|
||||
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,
|
||||
pub.id,
|
||||
);
|
||||
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
|
||||
.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" });
|
||||
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(feed.body.data.items[0].categoryIds, [music]);
|
||||
assertEquals(feed.data.items[0].categoryIds, [music]);
|
||||
assertEquals(
|
||||
titles(
|
||||
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.
|
||||
assertEquals(
|
||||
(await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body
|
||||
.data,
|
||||
(await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", {
|
||||
auth: me.auth,
|
||||
})).data,
|
||||
{ items: [], total: 0, hasMore: false },
|
||||
);
|
||||
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/${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(feed.body.data.total, 1);
|
||||
assertEquals(feed.data.total, 1);
|
||||
|
||||
// A playlist gone private drops out of the feed.
|
||||
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
||||
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,
|
||||
0,
|
||||
);
|
||||
@@ -180,36 +202,42 @@ test("notifications: listed newest first, marked read one or all, never someone
|
||||
});
|
||||
await post(`/api/follows/users/${other.id}`, { auth: me.auth });
|
||||
|
||||
const list = await get("/api/notifications", { auth: me.auth });
|
||||
assertEquals(list.body.data.total, 2);
|
||||
const list = await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: me.auth,
|
||||
});
|
||||
assertEquals(list.data.total, 2);
|
||||
assertEquals(
|
||||
list.body.data.items.map((n: { type: string }) => n.type),
|
||||
list.data.items.map((n) => n.type),
|
||||
["dump_commented", "user_followed"],
|
||||
);
|
||||
assertEquals(
|
||||
list.body.data.items.every((n: { read: boolean }) => !n.read),
|
||||
list.data.items.every((n) => !n.read),
|
||||
true,
|
||||
);
|
||||
|
||||
const theirs =
|
||||
(await get("/api/notifications", { auth: other.auth })).body.data
|
||||
.items[0].id;
|
||||
const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: other.auth,
|
||||
})).data
|
||||
.items[0].id;
|
||||
assertEquals(
|
||||
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
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,
|
||||
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 });
|
||||
const after =
|
||||
(await get("/api/notifications", { auth: me.auth })).body.data.items;
|
||||
assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]);
|
||||
const after = (await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: me.auth,
|
||||
})).data.items;
|
||||
assertEquals(after.map((n) => n.read), [true, false]);
|
||||
|
||||
assertEquals(
|
||||
(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}, ` +
|
||||
`me @${author.username}, mail x@${named.username}.com, ` +
|
||||
`[@${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,
|
||||
json: { body },
|
||||
});
|
||||
@@ -245,12 +273,12 @@ test("mentions: notify the named, once per context, never the author or an email
|
||||
mentionerId: author.id,
|
||||
mentionerUsername: author.username,
|
||||
contextType: "comment",
|
||||
contextId: c.body.data.id,
|
||||
contextId: c.data.id,
|
||||
contextTitle: "Topic",
|
||||
dumpId,
|
||||
});
|
||||
// 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,
|
||||
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 () => {
|
||||
const user = await createUser();
|
||||
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);
|
||||
const token = res.body.data.token;
|
||||
const token = res.data.token;
|
||||
assertMatch(token, /^[A-Za-z0-9_-]{22}$/);
|
||||
assertEquals((await get(`/api/invites/${token}`)).status, 200);
|
||||
assertEquals((await get(`/api/invites/unknown`)).status, 404);
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} 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");
|
||||
|
||||
@@ -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 () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/attachments", {
|
||||
const res = await post<AttachmentResponse>("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const id = res.body.data.id;
|
||||
const id = res.data.id;
|
||||
const served = await get(`/api/attachments/${id}`);
|
||||
assertEquals(served.status, 200);
|
||||
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 () => {
|
||||
const user = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: user.id });
|
||||
const up = await post("/api/attachments", {
|
||||
const up = await post<AttachmentResponse>("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
const attachmentId = up.body.data.id;
|
||||
const attachmentId = up.data.id;
|
||||
const link = ``;
|
||||
const comment = await post(`/api/dumps/${dumpId}/comments`, {
|
||||
const comment = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: user.auth,
|
||||
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(
|
||||
attachmentId,
|
||||
) 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.
|
||||
await post(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: user.auth,
|
||||
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 () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await get(`/api/avatars/${user.id}`)).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,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.avatarMime, "image/png");
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
assertEquals(res.data.avatarMime, "image/png");
|
||||
assertFalse("passwordHash" in res.data);
|
||||
const served = await get(`/api/avatars/${user.id}`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
|
||||
@@ -7,37 +7,43 @@ import {
|
||||
patch,
|
||||
test,
|
||||
} 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 () => {
|
||||
const user = await createUser();
|
||||
for (
|
||||
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.body.data.username, user.username);
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
assertEquals(res.data.username, user.username);
|
||||
assertFalse("email" in res.data);
|
||||
assertFalse("passwordHash" in res.data);
|
||||
}
|
||||
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
|
||||
const me = await get("/api/users/me", { auth: user.auth });
|
||||
assertEquals(me.body.data.email, user.email);
|
||||
assertEquals(me.body.data.passwordHash, undefined);
|
||||
const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
|
||||
assertEquals(me.data.email, user.email);
|
||||
assertFalse("passwordHash" in me.data);
|
||||
});
|
||||
|
||||
test("PATCH /api/users/me: description and email only", async () => {
|
||||
const user = await createUser();
|
||||
const taken = await createUser();
|
||||
const res = await patch("/api/users/me", {
|
||||
const res = await patch<PublicUser>("/api/users/me", {
|
||||
auth: user.auth,
|
||||
json: { description: "about me", email: "new@example.test" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.description, "about me");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.data.description, "about me");
|
||||
assertFalse("email" in res.data);
|
||||
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",
|
||||
);
|
||||
// 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,
|
||||
400,
|
||||
);
|
||||
const res = await patch(`/api/users/${user.id}/role`, {
|
||||
const res = await patch<PublicUser>(`/api/users/${user.id}/role`, {
|
||||
auth: admin.auth,
|
||||
json: { role: "moderator" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.role, "moderator");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.data.role, "moderator");
|
||||
assertFalse("email" in res.data);
|
||||
// The promoted user's existing token now carries moderator powers.
|
||||
const { id: dumpId } = insertDump({ userId: admin.id });
|
||||
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 () => {
|
||||
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
|
||||
await createUser({ username: "other_zed" });
|
||||
const res = await get("/api/users/search?q=zed");
|
||||
assertEquals(res.body.data.length, 8);
|
||||
assertEquals(res.body.data[0], {
|
||||
id: res.body.data[0].id,
|
||||
const res = await get<UserSuggestion[]>("/api/users/search?q=zed");
|
||||
assertEquals(res.data.length, 8);
|
||||
assertEquals(res.data[0], {
|
||||
id: res.data[0].id,
|
||||
username: "zed_0",
|
||||
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 () => {
|
||||
@@ -147,13 +153,14 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async ()
|
||||
invitedBy: child.id,
|
||||
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(
|
||||
tree.map((
|
||||
n: { username: string; invitedById: string },
|
||||
) => [n.username, n.invitedById]),
|
||||
[[child.username, root.id], [grandchild.username, child.id]],
|
||||
);
|
||||
const profile = await get(`/api/users/${child.username}`);
|
||||
assertEquals(profile.body.data.invitedByUsername, root.username);
|
||||
const profile = await get<PublicUser>(`/api/users/${child.username}`);
|
||||
assertEquals(profile.data.invitedByUsername, root.username);
|
||||
});
|
||||
|
||||
@@ -18,17 +18,32 @@ import {
|
||||
type TestUser,
|
||||
} from "./helpers.ts";
|
||||
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||
import type {
|
||||
AuthTokens,
|
||||
Dump,
|
||||
ServerToClientMessage,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type Msg = Record<string, any>;
|
||||
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
|
||||
// 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 {
|
||||
ws: WebSocket;
|
||||
messages: Msg[];
|
||||
/** 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. */
|
||||
all(type: string): Msg[];
|
||||
all<T extends Msg["type"]>(type: T): MsgOf<T>[];
|
||||
send(data: unknown): void;
|
||||
/** Round-trips a ping, so anything the server sent before it has arrived. */
|
||||
sync(): Promise<void>;
|
||||
@@ -53,9 +68,9 @@ async function withServer(
|
||||
|
||||
const connect = (user?: TestUser, origin = "http://allowed.test") => {
|
||||
const query = user ? `?token=${user.token}` : "";
|
||||
const ws = new WebSocket(
|
||||
const ws = new DenoWebSocket(
|
||||
`ws://127.0.0.1:${port}/ws${query}`,
|
||||
{ headers: { origin } } as unknown as string[],
|
||||
{ headers: { origin } },
|
||||
);
|
||||
const messages: Msg[] = [];
|
||||
const read = new Set<Msg>();
|
||||
@@ -71,11 +86,14 @@ async function withServer(
|
||||
ws,
|
||||
messages,
|
||||
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;
|
||||
while (true) {
|
||||
const hit = messages.find((m) =>
|
||||
!read.has(m) && m.type === type && where(m)
|
||||
const hit = messages.find((m): m is MsgOf<T> =>
|
||||
!read.has(m) && m.type === type && where(m as MsgOf<T>)
|
||||
);
|
||||
if (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) =>
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data)),
|
||||
async sync() {
|
||||
@@ -157,7 +176,7 @@ wsTest("welcome: anonymous and signed-in state", async (connect) => {
|
||||
const me = await connect(user);
|
||||
const [mine] = me.all("welcome");
|
||||
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");
|
||||
// The anonymous client hears about the arrival.
|
||||
const presence = await anon.next(
|
||||
@@ -217,7 +236,6 @@ wsTest(
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action: "cast",
|
||||
success: true,
|
||||
voteCount: 1,
|
||||
});
|
||||
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);
|
||||
|
||||
// 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,
|
||||
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", {
|
||||
auth: owner.auth,
|
||||
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);
|
||||
|
||||
// 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,
|
||||
json: { isPrivate: true },
|
||||
});
|
||||
assertEquals((await x.next("dump_deleted")).dumpId, pub.body.data.id);
|
||||
await put(`/api/dumps/${pub.body.data.id}`, {
|
||||
assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id);
|
||||
await put(`/api/dumps/${pub.data.id}`, {
|
||||
auth: owner.auth,
|
||||
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.
|
||||
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,
|
||||
json: { body: "shh" },
|
||||
});
|
||||
await post(`/api/dumps/${pub.body.data.id}/comments`, {
|
||||
await post(`/api/dumps/${pub.data.id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "hi" },
|
||||
});
|
||||
@@ -531,7 +549,7 @@ wsTest("a password change closes the user's sockets", async (connect) => {
|
||||
const user = await createUser();
|
||||
const c = await connect(user);
|
||||
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,
|
||||
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.
|
||||
const again = await connect(user);
|
||||
assertEquals(again.all("welcome")[0].users, []);
|
||||
assertExists(res.body.data.token);
|
||||
assertFalse(res.body.data.token === user.token);
|
||||
assertExists(res.data.token);
|
||||
assertFalse(res.data.token === user.token);
|
||||
});
|
||||
|
||||
wsTest(
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
"@std/assert": "jsr:@std/assert@^1.0.19",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.21",
|
||||
"@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",
|
||||
"nodemailer": "npm:nodemailer@^9.0.1",
|
||||
"marked": "npm:marked@^18.0.5",
|
||||
"nodemailer": "npm:nodemailer@^10.0.10",
|
||||
"marked": "npm:marked@^18.0.14",
|
||||
"@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"]
|
||||
}
|
||||
|
||||
42
package.json
42
package.json
@@ -15,41 +15,41 @@
|
||||
"@fontsource-variable/saira": "5.3.0",
|
||||
"@fontsource-variable/space-grotesk": "5.3.0",
|
||||
"@fontsource/instrument-serif": "5.3.0",
|
||||
"@lingui/cli": "6.4.0",
|
||||
"@lingui/conf": "6.4.0",
|
||||
"@lingui/core": "6.4.0",
|
||||
"@lingui/format-po": "6.4.0",
|
||||
"@lingui/react": "6.4.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@lingui/swc-plugin": "6.5.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||
"@lingui/cli": "6.8.0",
|
||||
"@lingui/conf": "6.8.0",
|
||||
"@lingui/core": "6.8.0",
|
||||
"@lingui/format-po": "6.8.0",
|
||||
"@lingui/react": "6.8.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@lingui/swc-plugin": "6.7.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||
"emojibase-data": "17.0.0",
|
||||
"frimousse": "^0.3.0",
|
||||
"frimousse": "^0.4.0",
|
||||
"jiti": "^2.7.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-hook-form": "^7.89.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-router": "^8.4.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/vite-plugin": "6.4.0",
|
||||
"@lingui/vite-plugin": "6.8.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^10.6.0",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.0",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^8.3.1",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
100
src/App.css
100
src/App.css
@@ -432,7 +432,8 @@
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -710,13 +711,25 @@
|
||||
}
|
||||
|
||||
@keyframes dump-panel-in {
|
||||
from { opacity: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dump-panel-in-back {
|
||||
from { opacity: 0; transform: translateX(-12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -912,8 +925,12 @@
|
||||
}
|
||||
|
||||
@keyframes dump-upload-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(250%); }
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1593,7 +1610,8 @@ body.has-player {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem) scale(0.9);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1670,7 +1688,8 @@ body.has-fab .page-content {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem) scale(0.9);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1889,7 +1908,8 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -2371,7 +2391,8 @@ body.has-player .chat-fab {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
@@ -2579,7 +2600,8 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
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 {
|
||||
@@ -3598,7 +3620,8 @@ body.has-player .chat-fab {
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tab-tracking);
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -3837,10 +3860,10 @@ body.has-player .chat-fab {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)),
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
|
||||
var(--color-surface)
|
||||
);
|
||||
in srgb,
|
||||
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
|
||||
var(--color-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.thumb-placeholder-icon {
|
||||
@@ -3956,8 +3979,7 @@ body.has-player .chat-fab {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible)
|
||||
.rich-content-play-overlay,
|
||||
.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay,
|
||||
.rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay {
|
||||
transform: scale(1.2);
|
||||
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 ⏸. */
|
||||
.dump-card-preview .rich-content-thumbnail-btn.is-playing
|
||||
.rich-content-play-overlay {
|
||||
.dump-card-preview .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
@@ -5100,7 +5121,8 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-sm);
|
||||
padding: 0.25rem 0.7rem;
|
||||
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,
|
||||
@@ -5188,7 +5210,8 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-xs);
|
||||
padding: 0.2rem 0.5rem;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -5496,7 +5519,8 @@ body.has-player .chat-fab {
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
@@ -5920,7 +5944,8 @@ body.has-player .chat-fab {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
@@ -6007,8 +6032,7 @@ body.has-player .chat-fab {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover)
|
||||
.rich-content-play-overlay {
|
||||
.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) .rich-content-play-overlay {
|
||||
transform: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
@@ -6801,8 +6825,16 @@ body.has-player .chat-fab {
|
||||
}
|
||||
|
||||
@keyframes tooltip-in {
|
||||
from { opacity: 0; translate: 0 4px; scale: 0.96; }
|
||||
to { opacity: 1; translate: 0 0; scale: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 4px;
|
||||
scale: 0.96;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -6810,7 +6842,9 @@ body.has-player .chat-fab {
|
||||
animation-name: tooltip-fade;
|
||||
}
|
||||
@keyframes tooltip-fade {
|
||||
from { opacity: 0; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7172,7 +7206,8 @@ body.has-player .chat-fab {
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
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) {
|
||||
@@ -7282,7 +7317,8 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1;
|
||||
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) {
|
||||
|
||||
@@ -100,7 +100,10 @@ function CategoryRedirect() {
|
||||
const { categorySlug } = useParams();
|
||||
const { search } = useLocation();
|
||||
return (
|
||||
<Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`} replace />
|
||||
<Navigate
|
||||
to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,8 @@ export function AppHeader(
|
||||
<>
|
||||
<header className="app-header app-header--has-center">
|
||||
<Link to="/" className="app-header-brand">
|
||||
{SITE_EMOJI}<span className="app-header-brand-name">
|
||||
{SITE_EMOJI}
|
||||
<span className="app-header-brand-name">
|
||||
{" "}
|
||||
{SITE_NAME}
|
||||
</span>
|
||||
@@ -179,8 +180,10 @@ export function AppHeader(
|
||||
</nav>
|
||||
</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
|
||||
className={`header-search-reveal${
|
||||
searchExpanded ? " header-search-reveal--open" : ""
|
||||
|
||||
Binary file not shown.
@@ -29,7 +29,11 @@ export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
const [done, setDone] = useState(false);
|
||||
const form = useApiForm<Values>({
|
||||
mode: "onChange",
|
||||
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
|
||||
|
||||
@@ -410,7 +410,11 @@ export function DumpCreateModal(
|
||||
})
|
||||
.catch(() => {
|
||||
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
|
||||
@@ -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()) && (
|
||||
<FileField<CreateValues>
|
||||
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}>
|
||||
<span aria-hidden="true">
|
||||
{mode === "url" ? "🔗" : "📎"}
|
||||
|
||||
@@ -68,8 +68,10 @@ export function DumpFab() {
|
||||
tabIndex={headerHidden ? 0 : -1}
|
||||
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
|
||||
className="dump-fab-icon"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
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 { TabBar } from "./TabBar.tsx";
|
||||
import { CategorySwitcher } from "./CategorySwitcher.tsx";
|
||||
@@ -20,10 +24,11 @@ export function FeedTabBar() {
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: 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 as FeedTab)
|
||||
: defaultTab;
|
||||
? (feedTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
const scope = categorySlug ?? "~";
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useDumpCardLink } from "../hooks/useDumpCardLink.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 { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
@@ -123,7 +127,10 @@ export function JournalCard(
|
||||
);
|
||||
|
||||
const vote = (
|
||||
<div className="journal-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="journal-card-vote"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VoteButton
|
||||
dumpId={dump.id}
|
||||
count={voteCount}
|
||||
@@ -142,8 +149,7 @@ export function JournalCard(
|
||||
</div>
|
||||
);
|
||||
|
||||
const className =
|
||||
`journal-card journal-card--${shape} journal-card--${mode}`;
|
||||
const className = `journal-card journal-card--${shape} journal-card--${mode}`;
|
||||
|
||||
if (mode === "image") {
|
||||
// feature shows its note; tall/wide/square keep the image as the subject.
|
||||
|
||||
@@ -146,9 +146,7 @@ export function Markdown(
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={inline
|
||||
? INLINE_MARKDOWN_COMPONENTS
|
||||
: MARKDOWN_COMPONENTS}
|
||||
components={inline ? INLINE_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -172,7 +172,8 @@ function SeekBar(
|
||||
step={0.01}
|
||||
value={current}
|
||||
// Pointer events, so a finger holds the thumb as a mouse does.
|
||||
onPointerDown={() => onDragChange(true)}
|
||||
onPointerDown={() =>
|
||||
onDragChange(true)}
|
||||
onPointerUp={() => onDragChange(false)}
|
||||
onPointerCancel={() => onDragChange(false)}
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
|
||||
@@ -29,8 +29,14 @@ interface ThumbnailProps {
|
||||
* an empty framed box and no signal anywhere that a load had failed.
|
||||
*/
|
||||
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">(
|
||||
"direct",
|
||||
|
||||
@@ -40,8 +40,9 @@ export default function ThumbnailPlaceholder(
|
||||
setIconFailed(false);
|
||||
}
|
||||
|
||||
const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as
|
||||
React.CSSProperties;
|
||||
const style = {
|
||||
"--thumb-tint": tintFor({ accentColor, url, seed }),
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -12,5 +12,7 @@ export function FormError({ title }: { title?: string }) {
|
||||
const { errors } = useFormState();
|
||||
const message = errors.root?.message;
|
||||
if (!message) return null;
|
||||
return <ErrorCard title={title ?? t`Something went wrong`} message={message} />;
|
||||
return (
|
||||
<ErrorCard title={title ?? t`Something went wrong`} message={message} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { friendlyFetchError } from "../../utils/apiError.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
|
||||
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
CategoriesContext,
|
||||
type CategoriesContextValue,
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface FollowContextValue {
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
export const FollowContext = createContext<FollowContextValue>({
|
||||
/** What a consumer outside any provider gets. */
|
||||
export const defaultFollowValue: FollowContextValue = {
|
||||
followedUserIds: new Set(),
|
||||
followedPlaylistIds: new Set(),
|
||||
followUser: async () => {},
|
||||
@@ -18,4 +19,8 @@ export const FollowContext = createContext<FollowContextValue>({
|
||||
followPlaylist: async () => {},
|
||||
unfollowPlaylist: async () => {},
|
||||
isLoaded: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const FollowContext = createContext<FollowContextValue>(
|
||||
defaultFollowValue,
|
||||
);
|
||||
|
||||
@@ -111,7 +111,8 @@ export interface PlayerContextValue {
|
||||
onError(): void;
|
||||
}
|
||||
|
||||
export const PlayerContext = createContext<PlayerContextValue>({
|
||||
/** What a consumer outside any provider gets. */
|
||||
export const defaultPlayerValue: PlayerContextValue = {
|
||||
current: null,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
@@ -137,4 +138,8 @@ export const PlayerContext = createContext<PlayerContextValue>({
|
||||
onTimeUpdate: () => {},
|
||||
onEnded: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
export const PlayerContext = createContext<PlayerContextValue>(
|
||||
defaultPlayerValue,
|
||||
);
|
||||
|
||||
@@ -129,7 +129,8 @@ export interface WSContextValue {
|
||||
setChatOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const WSContext = createContext<WSContextValue>({
|
||||
/** What a consumer outside any provider gets. */
|
||||
export const defaultWSValue: WSContextValue = {
|
||||
wsStatus: "connecting",
|
||||
wsErrorMessage: null,
|
||||
onlineUsers: [],
|
||||
@@ -172,4 +173,6 @@ export const WSContext = createContext<WSContextValue>({
|
||||
reconcileChat: () => {},
|
||||
setChatTyping: () => {},
|
||||
setChatOpen: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
export const WSContext = createContext<WSContextValue>(defaultWSValue);
|
||||
|
||||
@@ -822,79 +822,88 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const castCommentLike = useCallback((commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (prevLiked) return; // already liked
|
||||
const castCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (prevLiked) return; // already liked
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "comment_like_cast", commentId } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
}, [schedulePendingLike]);
|
||||
|
||||
const removeCommentLike = useCallback((commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return; // not liked
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({
|
||||
...prev,
|
||||
[commentId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "comment_like_remove",
|
||||
commentId,
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
}, [schedulePendingLike]);
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "comment_like_cast",
|
||||
commentId,
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
|
||||
const removeCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return; // not liked
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({
|
||||
...prev,
|
||||
[commentId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "comment_like_remove",
|
||||
commentId,
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
|
||||
const injectDump = useCallback((dump: Dump) => {
|
||||
setRecentDumps((prev) => {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -18,7 +19,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -26,7 +28,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -34,7 +37,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -44,7 +48,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -52,7 +57,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -62,7 +68,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -70,7 +77,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -78,7 +86,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -86,7 +95,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -96,7 +106,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -104,7 +115,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -112,7 +124,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -120,7 +133,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -130,7 +144,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -138,7 +153,8 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -146,7 +162,8 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
@font-face {
|
||||
@@ -154,6 +171,7 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -64,8 +64,11 @@ export function useAnchoredFloat(
|
||||
const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2);
|
||||
const content = Math.max(
|
||||
minContentHeight,
|
||||
Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) -
|
||||
chromeHeight),
|
||||
Math.min(
|
||||
maxContentHeight,
|
||||
(placeAbove ? roomAbove : roomBelow) -
|
||||
chromeHeight,
|
||||
),
|
||||
);
|
||||
|
||||
// Anchoring by `bottom` when above means the panel's own height never
|
||||
@@ -101,7 +104,14 @@ export function useAnchoredFloat(
|
||||
globalThis.removeEventListener("resize", place);
|
||||
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
|
||||
// painted, since the layout effect re-places the panel before the next paint.
|
||||
|
||||
@@ -116,8 +116,14 @@ export const useAuth = () => {
|
||||
});
|
||||
// Mirrors what `fetch` throws on a network failure, so the shared
|
||||
// `friendlyFetchError` path renders it the same way.
|
||||
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch")));
|
||||
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch")));
|
||||
xhr.addEventListener(
|
||||
"error",
|
||||
() => reject(new TypeError("Failed to fetch")),
|
||||
);
|
||||
xhr.addEventListener(
|
||||
"abort",
|
||||
() => reject(new TypeError("Failed to fetch")),
|
||||
);
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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";
|
||||
|
||||
|
||||
@@ -31,9 +31,7 @@ let badge = 0;
|
||||
|
||||
function applyTitle() {
|
||||
const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, "");
|
||||
document.title = badge > 0
|
||||
? `(${badge > 99 ? "99+" : badge}) ${base}`
|
||||
: base;
|
||||
document.title = badge > 0 ? `(${badge > 99 ? "99+" : badge}) ${base}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 { insideCode } from "../utils/editorText.ts";
|
||||
|
||||
@@ -44,6 +50,18 @@ function getMentionQuery(
|
||||
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(
|
||||
/** Replaces `[start, end)` of the textarea's value, with a space after
|
||||
* when `word` is set; see TextEditor. */
|
||||
@@ -145,7 +163,7 @@ export function useMentionAutocomplete(
|
||||
|
||||
/** Returns whether it handled the key. */
|
||||
const handleMentionKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
(e: MentionKey): boolean => {
|
||||
const s = stateRef.current;
|
||||
if (!s.open || s.results.length === 0) return false;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type Ref,
|
||||
type RefCallback,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -41,7 +41,7 @@ interface UseUserDumpFeedResult {
|
||||
state: State;
|
||||
setState: React.Dispatch<React.SetStateAction<State>>;
|
||||
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
sentinelRef: RefCallback<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,9 +66,12 @@ const PAUSED = 2;
|
||||
const RESUME_WINDOW_MS = 4000;
|
||||
const MAX_RESUMES = 3;
|
||||
|
||||
interface YTGlobal {
|
||||
YT?: YTNamespace;
|
||||
onYouTubeIframeAPIReady?: () => void;
|
||||
// The globals YouTube's IFrame API script sets and calls.
|
||||
declare global {
|
||||
interface Window {
|
||||
YT?: YTNamespace;
|
||||
onYouTubeIframeAPIReady?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const API_SRC = "https://www.youtube.com/iframe_api";
|
||||
@@ -79,7 +82,7 @@ let apiPromise: Promise<YTNamespace> | null = null;
|
||||
function loadPlayerApi(): Promise<YTNamespace> {
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise((resolve, reject) => {
|
||||
const w = globalThis as unknown as YTGlobal;
|
||||
const w = window;
|
||||
if (w.YT?.Player) {
|
||||
resolve(w.YT);
|
||||
return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,9 @@ export function DumpEdit() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
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) {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
|
||||
@@ -11,7 +11,11 @@ import { useLocation, useParams } from "react-router";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
import { PresenceRow } from "../components/PresenceRow.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 { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
@@ -93,10 +97,11 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
|
||||
: preferredTab;
|
||||
// "followed" needs a session: a guest there (a shared link, or signed out
|
||||
// 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 as FeedTab)
|
||||
: defaultTab;
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
// ── Main feed state ──
|
||||
|
||||
@@ -287,7 +292,9 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
|
||||
const categoryId = category?.id;
|
||||
const inScope = useCallback(
|
||||
(d: Dump) =>
|
||||
categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true,
|
||||
categorySlug
|
||||
? !!categoryId && !!d.categoryIds?.includes(categoryId)
|
||||
: true,
|
||||
[categorySlug, categoryId],
|
||||
);
|
||||
|
||||
@@ -322,7 +329,10 @@ export function Index({ categorySlug }: { categorySlug?: string }) {
|
||||
<AppHeader
|
||||
centerSlot={
|
||||
<div className="header-center-slot">
|
||||
<PresenceRow fitWithin=".app-header-center" fitBeside=".feed-sort" />
|
||||
<PresenceRow
|
||||
fitWithin=".app-header-center"
|
||||
fitBeside=".feed-sort"
|
||||
/>
|
||||
<FeedTabBar />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -555,7 +555,11 @@ export function PlaylistDetail() {
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps },
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
...updated,
|
||||
dumps: prev.playlist.dumps,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
@@ -873,8 +877,10 @@ function PlaylistEditForm(
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<Controller
|
||||
name="image"
|
||||
|
||||
@@ -69,8 +69,12 @@ export function UserRegister() {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ username, password, inviteToken: token, email } satisfies
|
||||
RegisterRequest,
|
||||
{
|
||||
username,
|
||||
password,
|
||||
inviteToken: token,
|
||||
email,
|
||||
} satisfies RegisterRequest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -41,10 +41,12 @@ export function JournalFeed(
|
||||
|
||||
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
|
||||
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">
|
||||
{blocks.map((block, index) => (
|
||||
<Fragment key={block.key}>
|
||||
|
||||
@@ -35,14 +35,34 @@ describe("ChatModal: history", () => {
|
||||
test("shows the latest messages under a day separator, grouping runs", async () => {
|
||||
const t0 = Date.now() - 60_000;
|
||||
await setup([
|
||||
makeChatMessage({ id: "1", 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) }),
|
||||
makeChatMessage({
|
||||
id: "1",
|
||||
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();
|
||||
// "two" continues ann's run: no second author link.
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -57,27 +77,55 @@ describe("ChatModal: history", () => {
|
||||
});
|
||||
|
||||
test("a full page offers older messages", async () => {
|
||||
const page = Array.from({ length: 50 }, (_, i) =>
|
||||
makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) })
|
||||
const page = Array.from(
|
||||
{ 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) =>
|
||||
req.url.searchParams.has("before")
|
||||
? wire([
|
||||
makeChatMessage({
|
||||
id: "m0",
|
||||
body: "the oldest",
|
||||
createdAt: new Date(Date.now() - 999_000),
|
||||
}),
|
||||
])
|
||||
: wire(page),
|
||||
);
|
||||
route("GET /api/chat", (req) =>
|
||||
req.url.searchParams.has("before")
|
||||
? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })])
|
||||
: wire(page));
|
||||
const user = userEvent.setup();
|
||||
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");
|
||||
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(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 () => {
|
||||
const readAt = new Date(Date.now() - 30_000);
|
||||
await setup([
|
||||
makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }),
|
||||
makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }),
|
||||
makeChatMessage({
|
||||
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() } });
|
||||
const divider = document.querySelector(".chat-new-sep")!;
|
||||
expect(divider).toHaveTextContent("New");
|
||||
@@ -91,7 +139,10 @@ describe("ChatModal: history", () => {
|
||||
|
||||
test("tells the server the chat is open, and closed", async () => {
|
||||
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({
|
||||
type: "chat_message",
|
||||
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.getByText("hello there")).toBeInTheDocument();
|
||||
@@ -130,13 +189,25 @@ describe("ChatModal: sending", () => {
|
||||
const { user, socket, composer } = await setup();
|
||||
await user.type(composer, "oops{Enter}");
|
||||
const [send] = sent(socket, "chat_send");
|
||||
socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" });
|
||||
const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement;
|
||||
socket.receive({
|
||||
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" }));
|
||||
expect(sent(socket, "chat_send")).toHaveLength(2);
|
||||
|
||||
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.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(composer).toHaveValue("draft\n\noops");
|
||||
@@ -144,21 +215,32 @@ describe("ChatModal: sending", () => {
|
||||
});
|
||||
|
||||
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 {
|
||||
const { socket, composer } = await setup();
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
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));
|
||||
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 {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
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");
|
||||
const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!);
|
||||
expect(stored.draft).toBe("half a thought");
|
||||
@@ -167,38 +249,68 @@ describe("ChatModal: sending", () => {
|
||||
|
||||
describe("ChatModal: replies, edits, deletes", () => {
|
||||
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 = () =>
|
||||
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 () => {
|
||||
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" }));
|
||||
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}");
|
||||
expect(screen.queryByText("Replying to")).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(msg).getByRole("button", { name: "Reply" }));
|
||||
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 () => {
|
||||
await setup([theirs(), mine()]);
|
||||
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
|
||||
const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement;
|
||||
expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||
const t = screen.getByText("anything").closest(
|
||||
".chat-message",
|
||||
)! 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: "Delete" })).toBeInTheDocument();
|
||||
expect(within(m).getByRole("button", { name: "Delete" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("moderators can delete anyone's", async () => {
|
||||
await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse });
|
||||
const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement;
|
||||
expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
await setup([theirs()], {
|
||||
session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse,
|
||||
});
|
||||
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 () => {
|
||||
@@ -208,33 +320,51 @@ describe("ChatModal: replies, edits, deletes", () => {
|
||||
const box = await screen.findByDisplayValue("my words");
|
||||
await user.clear(box);
|
||||
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();
|
||||
});
|
||||
|
||||
test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => {
|
||||
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(screen.getByRole("button", { name: "Save" }));
|
||||
expect(sent(socket, "chat_edit")).toHaveLength(0);
|
||||
await user.click(within(m).getByRole("button", { name: "Edit" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deleting asks first; Escape on the question keeps the chat open", async () => {
|
||||
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" }));
|
||||
expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?");
|
||||
expect(screen.getByRole("alertdialog")).toHaveTextContent(
|
||||
"Delete this message?",
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.click(within(m).getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
|
||||
expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]);
|
||||
await user.click(
|
||||
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" });
|
||||
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 () => {
|
||||
const { socket } = await setup([
|
||||
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" });
|
||||
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();
|
||||
socket.receive({
|
||||
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 () => {
|
||||
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 () => {
|
||||
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();
|
||||
socket.receive({
|
||||
type: "chat_typing_update",
|
||||
@@ -276,7 +425,10 @@ describe("ChatModal: live", () => {
|
||||
],
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -289,16 +441,27 @@ describe("ChatModal: live", () => {
|
||||
});
|
||||
|
||||
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 edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) });
|
||||
const old = makeChatMessage({
|
||||
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]);
|
||||
route("GET /api/chat", () => wire([old, { ...edited, body: "after" }]));
|
||||
socket.drop();
|
||||
expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disconnected — reconnecting…"))
|
||||
.toBeInTheDocument();
|
||||
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||
FakeWebSocket.last.welcome();
|
||||
await screen.findByText("after");
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) {
|
||||
token={session?.token ?? null}
|
||||
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
|
||||
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) =>
|
||||
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;
|
||||
}
|
||||
|
||||
const created = (over: Partial<Comment>) =>
|
||||
(req: { body: unknown }) =>
|
||||
wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over }));
|
||||
const created = (over: Partial<Comment>) => (req: { body: unknown }) =>
|
||||
wire(
|
||||
makeComment({
|
||||
dumpId: "d1",
|
||||
userId: "me",
|
||||
authorUsername: "me",
|
||||
body: (req.body as { body: string }).body,
|
||||
...over,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("CommentThread", () => {
|
||||
test("counts visible comments and nests replies", () => {
|
||||
@@ -44,7 +53,8 @@ describe("CommentThread", () => {
|
||||
makeComment({ id: "b", parentId: "a", body: "reply" }),
|
||||
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")!;
|
||||
expect(within(top).getByText("reply")).toBeInTheDocument();
|
||||
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
||||
@@ -52,8 +62,10 @@ describe("CommentThread", () => {
|
||||
|
||||
test("guests read but can't write, reply or like", () => {
|
||||
setup([makeComment({ body: "hello" })], null);
|
||||
expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Add a comment…")).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reply" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -66,8 +78,11 @@ describe("CommentThread", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Post comment" }));
|
||||
await screen.findByText("First!", { selector: ".comment-body *" });
|
||||
expect(box).toHaveValue("");
|
||||
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" });
|
||||
expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument();
|
||||
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
|
||||
body: "First!",
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "1 comment" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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);
|
||||
await user.type(box, "ok");
|
||||
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 () => {
|
||||
@@ -96,7 +113,8 @@ describe("CommentThread", () => {
|
||||
|
||||
test("the top-level Cancel only shows with text, and clears it", async () => {
|
||||
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.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
|
||||
@@ -110,8 +128,14 @@ describe("CommentThread", () => {
|
||||
await waitFor(() => expect(box).toHaveFocus());
|
||||
await user.type(box, "child");
|
||||
await user.click(screen.getByRole("button", { name: "Post reply" }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument());
|
||||
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" });
|
||||
await waitFor(() =>
|
||||
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")!;
|
||||
expect(within(parent).getByText("child")).toBeInTheDocument();
|
||||
});
|
||||
@@ -119,14 +143,31 @@ describe("CommentThread", () => {
|
||||
test("Escape backs out of a reply", async () => {
|
||||
const user = setup([makeComment({ id: "a" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Reply" }));
|
||||
await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}");
|
||||
expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Write a reply…"),
|
||||
"nah{Escape}",
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Write a reply…")).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("editing your own comment", async () => {
|
||||
route("PATCH /api/comments/:id", (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" })]);
|
||||
route(
|
||||
"PATCH /api/comments/:id",
|
||||
(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" }));
|
||||
const box = screen.getByDisplayValue("old");
|
||||
await user.clear(box);
|
||||
@@ -140,7 +181,10 @@ describe("CommentThread", () => {
|
||||
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: "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);
|
||||
});
|
||||
|
||||
@@ -151,10 +195,13 @@ describe("CommentThread", () => {
|
||||
];
|
||||
setup(comments);
|
||||
const theirs = screen.getByText("theirs").closest("li")!;
|
||||
expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
|
||||
expect(within(theirs).queryByRole("button", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(within(theirs).queryByRole("button", { name: "Delete" })).not
|
||||
.toBeInTheDocument();
|
||||
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", () => {
|
||||
@@ -163,7 +210,10 @@ describe("CommentThread", () => {
|
||||
});
|
||||
|
||||
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" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
@@ -176,8 +226,14 @@ describe("CommentThread", () => {
|
||||
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
|
||||
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i);
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
/couldn't be deleted/i,
|
||||
);
|
||||
expect(screen.getByText("stays")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -194,7 +250,9 @@ describe("CommentThread", () => {
|
||||
|
||||
test("the timestamp links to the comment", () => {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
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 { DumpCreateModal } from "../../components/DumpCreateModal.tsx";
|
||||
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";
|
||||
|
||||
const session = makeSession({ username: "me" });
|
||||
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 injectDump = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
@@ -21,11 +35,18 @@ function setup(props: Partial<React.ComponentProps<typeof DumpCreateModal>> = {}
|
||||
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) => ({
|
||||
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") },
|
||||
}));
|
||||
route("GET /api/dumps/by-url", () => dupes);
|
||||
@@ -37,7 +58,8 @@ const linkBox = () => screen.getByPlaceholderText("Paste a link…");
|
||||
describe("DumpCreateModal: a link", () => {
|
||||
test("Next stays disabled until there is a link or a file", async () => {
|
||||
const { user } = setup();
|
||||
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "New dump" }))
|
||||
.toBeInTheDocument();
|
||||
expect(nextBtn()).toBeDisabled();
|
||||
await user.type(linkBox(), "not a url at all");
|
||||
expect(nextBtn()).toBeDisabled();
|
||||
@@ -61,21 +83,39 @@ describe("DumpCreateModal: a link", () => {
|
||||
previewRoutes({ reached: false });
|
||||
const { user } = setup();
|
||||
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();
|
||||
});
|
||||
|
||||
test("warns about an earlier dump of the same link", async () => {
|
||||
previewRoutes({
|
||||
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();
|
||||
await user.type(linkBox(), "https://x.test/");
|
||||
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" }));
|
||||
expect(currentPath()).toBe("/dumps/old-one");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -83,10 +123,18 @@ describe("DumpCreateModal: a link", () => {
|
||||
|
||||
test("the whole way through: post, then add to a playlist", async () => {
|
||||
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));
|
||||
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", () => ({}));
|
||||
const { user, injectDump, onClose } = setup();
|
||||
|
||||
@@ -109,7 +157,9 @@ describe("DumpCreateModal: a link", () => {
|
||||
isPrivate: true,
|
||||
categoryIds: ["c1"],
|
||||
});
|
||||
expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" }));
|
||||
expect(injectDump).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "new" }),
|
||||
);
|
||||
expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull();
|
||||
|
||||
const row = await screen.findByRole("checkbox", { name: /Mix/ });
|
||||
@@ -117,7 +167,9 @@ describe("DumpCreateModal: a link", () => {
|
||||
row.focus();
|
||||
await user.keyboard(" ");
|
||||
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" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
@@ -126,7 +178,13 @@ describe("DumpCreateModal: a link", () => {
|
||||
test("a refused playlist toggle leaves the box as it was", async () => {
|
||||
previewRoutes();
|
||||
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));
|
||||
const { user } = setup();
|
||||
await user.type(linkBox(), "https://a.test/{Enter}");
|
||||
@@ -143,14 +201,18 @@ describe("DumpCreateModal: a link", () => {
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
const { user } = setup();
|
||||
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" }));
|
||||
const title = screen.getByLabelText("Title");
|
||||
await waitFor(() => expect(title).toHaveFocus());
|
||||
await user.clear(title);
|
||||
await user.type(title, "My title");
|
||||
// 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");
|
||||
await user.click(screen.getByRole("button", { name: "Edit title" }));
|
||||
await user.clear(title);
|
||||
@@ -158,7 +220,9 @@ describe("DumpCreateModal: a link", () => {
|
||||
await user.click(nextBtn());
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
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 () => {
|
||||
@@ -180,7 +244,8 @@ describe("DumpCreateModal: a link", () => {
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Failed to post");
|
||||
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 () => {
|
||||
@@ -206,21 +271,28 @@ describe("DumpCreateModal: a link", () => {
|
||||
|
||||
describe("DumpCreateModal: drafts", () => {
|
||||
test("a dismissed draft comes back, and can be dropped", async () => {
|
||||
localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({
|
||||
url: "https://draft.test/",
|
||||
title: "",
|
||||
titleFromUser: false,
|
||||
comment: "half-written",
|
||||
isPublic: false,
|
||||
categoryIds: ["c1"],
|
||||
}));
|
||||
localStorage.setItem(
|
||||
"gerbeur.dumpDraft",
|
||||
JSON.stringify({
|
||||
url: "https://draft.test/",
|
||||
title: "",
|
||||
titleFromUser: false,
|
||||
comment: "half-written",
|
||||
isPublic: false,
|
||||
categoryIds: ["c1"],
|
||||
}),
|
||||
);
|
||||
previewRoutes();
|
||||
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/");
|
||||
await user.click(nextBtn());
|
||||
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: "Start fresh" }));
|
||||
expect(linkBox()).toHaveValue("");
|
||||
@@ -231,14 +303,20 @@ describe("DumpCreateModal: drafts", () => {
|
||||
previewRoutes();
|
||||
const { user } = setup();
|
||||
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", () => {
|
||||
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();
|
||||
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/");
|
||||
});
|
||||
});
|
||||
@@ -252,7 +330,10 @@ describe("DumpCreateModal: a file", () => {
|
||||
body: FormData | null = null;
|
||||
private l: 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() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
@@ -265,7 +346,9 @@ describe("DumpCreateModal: a file", () => {
|
||||
this.body = body;
|
||||
}
|
||||
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) {
|
||||
this.status = status;
|
||||
@@ -275,7 +358,8 @@ describe("DumpCreateModal: a file", () => {
|
||||
}
|
||||
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 () => {
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR);
|
||||
@@ -286,19 +370,29 @@ describe("DumpCreateModal: a file", () => {
|
||||
fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, {
|
||||
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.getByLabelText("Title")).toHaveValue("my song-final");
|
||||
await user.click(nextBtn());
|
||||
await user.click(screen.getByRole("button", { name: "Dump it" }));
|
||||
act(() => FakeXHR.last.progress(0.42));
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42");
|
||||
expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
"42",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Uploading 42%" }))
|
||||
.toBeDisabled();
|
||||
const fd = FakeXHR.last.body!;
|
||||
expect(fd.get("title")).toBe("my song-final");
|
||||
expect(fd.get("isPrivate")).toBe("false");
|
||||
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" });
|
||||
});
|
||||
|
||||
@@ -310,9 +404,16 @@ describe("DumpCreateModal: a file", () => {
|
||||
});
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!;
|
||||
await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" }));
|
||||
expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument();
|
||||
const confirm = screen.getByText(/The attached file won't be kept/).closest(
|
||||
"[role=alert]",
|
||||
)!;
|
||||
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: "Close anyway" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
|
||||
@@ -22,7 +22,11 @@ describe("Modal", () => {
|
||||
test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => {
|
||||
const onClose = vi.fn();
|
||||
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"));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
@@ -34,7 +38,9 @@ describe("Modal", () => {
|
||||
test("onBeforeClose can refuse", async () => {
|
||||
const onClose = vi.fn();
|
||||
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.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
@@ -60,7 +66,11 @@ describe("ConfirmModal", () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(
|
||||
<ConfirmModal message="Really?" onConfirm={onConfirm} onCancel={onCancel} />,
|
||||
<ConfirmModal
|
||||
message="Really?"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
const dialog = screen.getByRole("alertdialog");
|
||||
expect(dialog).toHaveAccessibleDescription("Really?");
|
||||
@@ -74,7 +84,12 @@ describe("ConfirmModal", () => {
|
||||
|
||||
test("takes a custom label", () => {
|
||||
wrap(
|
||||
<ConfirmModal message="m" confirmLabel="Discard" onConfirm={() => {}} onCancel={() => {}} />,
|
||||
<ConfirmModal
|
||||
message="m"
|
||||
confirmLabel="Discard"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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 { 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 { makeSession } from "../fixtures.ts";
|
||||
import { renderWithContexts } from "../render.tsx";
|
||||
@@ -54,7 +63,9 @@ describe("TextEditor basics", () => {
|
||||
await user.keyboard("{Meta>}{Enter}{/Meta}");
|
||||
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||
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", () => {
|
||||
@@ -81,7 +92,9 @@ describe("TextEditor basics", () => {
|
||||
await user.type(box, "8");
|
||||
expect(screen.getByText("8 / 10")).toBeInTheDocument();
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -92,7 +105,9 @@ describe("TextEditor basics", () => {
|
||||
|
||||
test("the handle focuses at the end", () => {
|
||||
const ref = createRef<TextEditorHandle>();
|
||||
renderWithContexts(<TextEditor ref={ref} value="abc" onChange={() => {}} />);
|
||||
renderWithContexts(
|
||||
<TextEditor ref={ref} value="abc" onChange={() => {}} />,
|
||||
);
|
||||
act(() => ref.current!.focus({ atEnd: true }));
|
||||
const box = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(box).toHaveFocus();
|
||||
@@ -152,7 +167,9 @@ describe("mentions", () => {
|
||||
await user.type(box, "@al");
|
||||
await screen.findByRole("listbox");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" }));
|
||||
expect(onKeyDown).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: "Enter" }),
|
||||
);
|
||||
expect(box).toHaveValue("@alice ");
|
||||
});
|
||||
|
||||
@@ -172,9 +189,13 @@ describe("image upload", () => {
|
||||
const { user, box } = setup({}, "look: ");
|
||||
await user.click(box);
|
||||
box.setSelectionRange(6, 6);
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
fireEvent.paste(box, {
|
||||
clipboardData: { types: ["Files"], files: [imageFile()] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(box).toHaveValue("look: ")
|
||||
expect(box).toHaveValue(
|
||||
"look: ",
|
||||
)
|
||||
);
|
||||
const [req] = calls("POST /api/attachments");
|
||||
expect(req.body).toBeInstanceOf(FormData);
|
||||
@@ -195,14 +216,20 @@ describe("image upload", () => {
|
||||
route("POST /api/attachments", () => pending.promise);
|
||||
const onKeyDown = vi.fn();
|
||||
const { box } = setup({ onKeyDown });
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…");
|
||||
fireEvent.paste(box, {
|
||||
clipboardData: { types: ["Files"], files: [imageFile()] },
|
||||
});
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"Uploading image…",
|
||||
);
|
||||
expect(box).toHaveAttribute("readonly");
|
||||
expect(box).toHaveAttribute("aria-busy", "true");
|
||||
fireEvent.keyDown(box, { key: "Enter" });
|
||||
expect(onKeyDown).not.toHaveBeenCalled();
|
||||
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"));
|
||||
});
|
||||
@@ -210,29 +237,47 @@ describe("image upload", () => {
|
||||
test("a failed upload is reported", async () => {
|
||||
route("POST /api/attachments", () => fail("Too big", 413));
|
||||
const { box } = setup();
|
||||
fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded.");
|
||||
fireEvent.paste(box, {
|
||||
clipboardData: { types: ["Files"], files: [imageFile()] },
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"The image couldn't be uploaded.",
|
||||
);
|
||||
expect(box).toHaveValue("");
|
||||
expect(box).toHaveAttribute("aria-describedby");
|
||||
});
|
||||
|
||||
test("dropping a non-image is caught and explained", () => {
|
||||
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 });
|
||||
expect(over).toBe(false); // prevented: the browser mustn't open the file
|
||||
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);
|
||||
});
|
||||
|
||||
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 textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } });
|
||||
const textDrag = fireEvent.dragOver(box, {
|
||||
dataTransfer: { types: ["text/plain"], files: [] },
|
||||
});
|
||||
expect(textDrag).toBe(true);
|
||||
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"));
|
||||
expect(box.value.split("\n")).toHaveLength(2);
|
||||
@@ -245,11 +290,14 @@ describe("emoji", () => {
|
||||
const onCancel = vi.fn();
|
||||
const { user, box } = setup({ onCancel });
|
||||
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(box).toHaveFocus();
|
||||
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(box).toHaveValue("hi :astro");
|
||||
});
|
||||
@@ -267,7 +315,8 @@ describe("emoji", () => {
|
||||
serveEmojibase();
|
||||
const { user, box } = setup();
|
||||
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 () => {
|
||||
@@ -276,11 +325,16 @@ describe("emoji", () => {
|
||||
await user.click(box);
|
||||
box.setSelectionRange(1, 1);
|
||||
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
|
||||
expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true");
|
||||
const search = await screen.findByRole("searchbox", { name: "Search emoji" });
|
||||
expect(screen.getByRole("button", { name: "Insert emoji" }))
|
||||
.toHaveAttribute("aria-expanded", "true");
|
||||
const search = await screen.findByRole("searchbox", {
|
||||
name: "Search emoji",
|
||||
});
|
||||
await waitFor(() => expect(search).toHaveFocus());
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -290,6 +344,7 @@ describe("emoji", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Insert emoji" }));
|
||||
await screen.findByRole("searchbox", { name: "Search emoji" });
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useRef } from "react";
|
||||
import { Avatar } from "../../components/Avatar.tsx";
|
||||
import { VoteButton } from "../../components/VoteButton.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 { NotificationBell } from "../../components/NotificationBell.tsx";
|
||||
import { ChatButton } from "../../components/ChatButton.tsx";
|
||||
@@ -13,12 +16,14 @@ import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { ChatContext } from "../../contexts/ChatContext.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts";
|
||||
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
|
||||
import { WSContext } from "../../contexts/WSContext.ts";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts";
|
||||
|
||||
describe("Avatar", () => {
|
||||
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(
|
||||
"src",
|
||||
"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", () => {
|
||||
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 />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<AvatarOverrideContext.Provider value={{ u1: { hasAvatar: true, version: 2 } }}>
|
||||
<AvatarOverrideContext.Provider
|
||||
value={{ u1: { hasAvatar: true, version: 2 } }}
|
||||
>
|
||||
<Avatar userId="u1" username="cy" hasAvatar={false} />
|
||||
</AvatarOverrideContext.Provider>,
|
||||
);
|
||||
// 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 user = userEvent.setup();
|
||||
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" }));
|
||||
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" }));
|
||||
expect(onRemove).toHaveBeenCalledWith("d", 3);
|
||||
});
|
||||
|
||||
test("disabled for guests, with a hint", () => {
|
||||
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" });
|
||||
expect(btn).toBeDisabled();
|
||||
@@ -75,17 +108,27 @@ describe("VoteButton / LikeButton", () => {
|
||||
|
||||
test("the count opens who voted, without voting", async () => {
|
||||
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();
|
||||
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"));
|
||||
expect(onCast).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/fan",
|
||||
);
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ }))
|
||||
.toHaveAttribute(
|
||||
"href",
|
||||
"/users/fan",
|
||||
);
|
||||
});
|
||||
|
||||
test("likes work the same way", async () => {
|
||||
@@ -94,13 +137,27 @@ describe("VoteButton / LikeButton", () => {
|
||||
route("GET /api/comments/:id/likers", () => page([]));
|
||||
const user = userEvent.setup();
|
||||
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" }));
|
||||
expect(onLike).toHaveBeenCalledWith("c", 2);
|
||||
await user.click(screen.getByText("2"));
|
||||
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" }));
|
||||
expect(onUnlike).toHaveBeenCalledWith("c", 2);
|
||||
});
|
||||
@@ -113,28 +170,40 @@ describe("UserListPopover", () => {
|
||||
<>
|
||||
<span ref={ref}>anchor</span>
|
||||
<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 () => {
|
||||
route("GET /api/x/voters", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "a" })], true)
|
||||
: page([makeUser({ username: "b" })], false));
|
||||
route(
|
||||
"GET /api/x/voters",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "a" })], true)
|
||||
: page([makeUser({ username: "b" })], false),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />, { session: makeSession() });
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
await screen.findByText("a");
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("b");
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(
|
||||
/^Bearer /,
|
||||
);
|
||||
});
|
||||
|
||||
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 />);
|
||||
await screen.findByText("Dump not found");
|
||||
first.unmount();
|
||||
@@ -144,9 +213,16 @@ describe("UserListPopover", () => {
|
||||
});
|
||||
|
||||
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", () => page([makeUser({ username: "b" })], false));
|
||||
route(
|
||||
"GET /api/x/voters",
|
||||
() => page([makeUser({ username: "b" })], false),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<Harness />);
|
||||
await screen.findByText("a");
|
||||
@@ -184,7 +260,10 @@ describe("FollowButton", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
renderWithContexts(<FollowUserButton targetUserId="me" targetUsername="me" />, { session: me });
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="me" targetUsername="me" />,
|
||||
{ session: me },
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -206,10 +285,13 @@ describe("FollowButton", () => {
|
||||
test("shows Following, and unfollows", async () => {
|
||||
const unfollowUser = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
|
||||
session: me,
|
||||
follows: { followedUserIds: new Set(["u"]), unfollowUser },
|
||||
});
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{
|
||||
session: me,
|
||||
follows: { followedUserIds: new Set(["u"]), unfollowUser },
|
||||
},
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Unfollow ann" });
|
||||
expect(btn).toHaveTextContent("Following");
|
||||
await user.click(btn);
|
||||
@@ -217,10 +299,13 @@ describe("FollowButton", () => {
|
||||
});
|
||||
|
||||
test("disabled until follows have loaded", () => {
|
||||
renderWithContexts(<FollowUserButton targetUserId="u" targetUsername="ann" />, {
|
||||
session: me,
|
||||
follows: { isLoaded: false },
|
||||
});
|
||||
renderWithContexts(
|
||||
<FollowUserButton targetUserId="u" targetUsername="ann" />,
|
||||
{
|
||||
session: me,
|
||||
follows: { isLoaded: false },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -236,7 +321,11 @@ describe("FollowButton", () => {
|
||||
priv.unmount();
|
||||
renderWithContexts(<FollowPlaylistButton targetPlaylistId="p" isPublic />, {
|
||||
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" }));
|
||||
expect(unfollowPlaylist).toHaveBeenCalledWith("p");
|
||||
@@ -246,8 +335,12 @@ describe("FollowButton", () => {
|
||||
describe("NotificationBell / ChatButton", () => {
|
||||
test("the bell shows the unread count and goes to notifications", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<NotificationBell />, { ws: { unreadNotificationCount: 120 } });
|
||||
const bell = screen.getByRole("button", { name: "Notifications (120 unread)" });
|
||||
renderWithContexts(<NotificationBell />, {
|
||||
ws: { unreadNotificationCount: 120 },
|
||||
});
|
||||
const bell = screen.getByRole("button", {
|
||||
name: "Notifications (120 unread)",
|
||||
});
|
||||
expect(bell).toHaveTextContent("99+");
|
||||
await user.click(bell);
|
||||
expect(currentPath()).toBe("/notifications");
|
||||
@@ -258,7 +351,9 @@ describe("NotificationBell / ChatButton", () => {
|
||||
try {
|
||||
const n1 = makeNotification({ id: "n1" });
|
||||
const withLast = (n: ReturnType<typeof makeNotification>) => (
|
||||
<WSContext.Provider value={{ ...defaultOf(WSContext), lastNotification: n }}>
|
||||
<WSContext.Provider
|
||||
value={{ ...defaultWSValue, lastNotification: n }}
|
||||
>
|
||||
<NotificationBell />
|
||||
</WSContext.Provider>
|
||||
);
|
||||
@@ -280,7 +375,9 @@ describe("NotificationBell / ChatButton", () => {
|
||||
const openChat = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
|
||||
<ChatContext.Provider
|
||||
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
|
||||
>
|
||||
<ChatButton />
|
||||
</ChatContext.Provider>,
|
||||
{ ws: { unreadChatCount: 4 } },
|
||||
|
||||
@@ -19,7 +19,9 @@ describe("Markdown", () => {
|
||||
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("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();
|
||||
});
|
||||
|
||||
@@ -51,11 +53,25 @@ describe("Markdown", () => {
|
||||
md(
|
||||
`[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("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");
|
||||
expect(screen.getByRole("link", { name: "ext" })).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", () => {
|
||||
@@ -71,7 +87,10 @@ describe("Markdown", () => {
|
||||
|
||||
test("images load lazily; inline teasers drop them", () => {
|
||||
const full = md("");
|
||||
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy");
|
||||
expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute(
|
||||
"loading",
|
||||
"lazy",
|
||||
);
|
||||
full.unmount();
|
||||
md(" text", true);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
@@ -96,15 +115,20 @@ describe("Thumbnail", () => {
|
||||
|
||||
test("direct, then proxied, then the placeholder", () => {
|
||||
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" });
|
||||
expect(img).toHaveAttribute("src", "https://img.test/a.png");
|
||||
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain(
|
||||
"/api/proxy-image?url=",
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
|
||||
.toContain(
|
||||
"/api/proxy-image?url=",
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "art" }));
|
||||
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
@@ -112,16 +136,28 @@ describe("Thumbnail", () => {
|
||||
|
||||
test("our own images skip the proxy retry", () => {
|
||||
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" }));
|
||||
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -129,17 +165,31 @@ describe("Thumbnail", () => {
|
||||
describe("ThumbnailPlaceholder", () => {
|
||||
test("tints by the page colour, or by hostname", () => {
|
||||
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");
|
||||
rerender(<ThumbnailPlaceholder url="https://www.site.test/x" accentColor="url(evil)" />);
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test"));
|
||||
rerender(
|
||||
<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", () => {
|
||||
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")!;
|
||||
fireEvent.error(icon);
|
||||
@@ -163,10 +213,16 @@ describe("RichContentCard", () => {
|
||||
|
||||
test("full card: play button, source link and details", async () => {
|
||||
const play = vi.fn();
|
||||
renderWithContexts(<RichContentCard richContent={yt} dumpHref="/dumps/d" />, {
|
||||
player: { play },
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url);
|
||||
renderWithContexts(
|
||||
<RichContentCard richContent={yt} dumpHref="/dumps/d" />,
|
||||
{
|
||||
player: { play },
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute(
|
||||
"href",
|
||||
yt.url,
|
||||
);
|
||||
expect(screen.getByText("About it")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -183,12 +239,21 @@ describe("RichContentCard", () => {
|
||||
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", () => {
|
||||
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(
|
||||
"href",
|
||||
@@ -198,7 +263,10 @@ describe("RichContentCard", () => {
|
||||
|
||||
test("a dump thumbnail overrides the provider's", () => {
|
||||
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(
|
||||
"src",
|
||||
@@ -209,7 +277,11 @@ describe("RichContentCard", () => {
|
||||
test("a native stream that is the active item toggles instead of restarting", async () => {
|
||||
const togglePlay = 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 />, {
|
||||
player: {
|
||||
togglePlay,
|
||||
@@ -249,32 +321,57 @@ describe("DumpCard", () => {
|
||||
commentCount: 2,
|
||||
categoryIds: ["c1", "missing"],
|
||||
});
|
||||
renderWithContexts(<ul><DumpCard dump={dump} {...props} /></ul>, {
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice");
|
||||
renderWithContexts(
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} />
|
||||
</ul>,
|
||||
{
|
||||
categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })],
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/dumps/nice",
|
||||
);
|
||||
expect(screen.getByText("here").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("2 comments")).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", () => {
|
||||
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();
|
||||
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();
|
||||
own.unmount();
|
||||
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();
|
||||
});
|
||||
|
||||
test("the private badge shows to the owner", () => {
|
||||
renderWithContexts(
|
||||
<ul><DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner /></ul>,
|
||||
<ul>
|
||||
<DumpCard dump={makeDump({ isPrivate: true })} {...props} isOwner />
|
||||
</ul>,
|
||||
);
|
||||
expect(screen.getByText("private")).toBeInTheDocument();
|
||||
});
|
||||
@@ -283,7 +380,9 @@ describe("DumpCard", () => {
|
||||
const castVote = vi.fn();
|
||||
const dump = makeDump({ id: "d3" });
|
||||
renderWithContexts(
|
||||
<ul><DumpCard dump={dump} {...props} castVote={castVote} /></ul>,
|
||||
<ul>
|
||||
<DumpCard dump={dump} {...props} castVote={castVote} />
|
||||
</ul>,
|
||||
{ session: makeSession() },
|
||||
);
|
||||
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", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<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} />
|
||||
</ul>,
|
||||
);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0);
|
||||
expect(container.querySelectorAll(".thumb-placeholder").length)
|
||||
.toBeGreaterThan(0);
|
||||
const cards = container.querySelectorAll(".dump-card");
|
||||
expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ import { CountedInput } from "../../components/CountedInput.tsx";
|
||||
import { PageError } from "../../components/PageError.tsx";
|
||||
import { AuthContext } from "../../contexts/AuthContext.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 { makeCategory, makeSession, makeUser } from "../fixtures.ts";
|
||||
import { currentPath, defaultOf, renderWithContexts } from "../render.tsx";
|
||||
import { currentPath, renderWithContexts } from "../render.tsx";
|
||||
|
||||
const categories: Category[] = [
|
||||
makeCategory({ id: "c1", slug: "music", name: "Music" }),
|
||||
@@ -67,7 +67,12 @@ describe("SearchBar", () => {
|
||||
});
|
||||
|
||||
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} />);
|
||||
expect(screen.getByRole("searchbox")).not.toHaveFocus();
|
||||
rerender(<SearchBar expanded={false} {...props} />);
|
||||
@@ -91,7 +96,10 @@ describe("UserMenu", () => {
|
||||
const trigger = screen.getByRole("button", { name: "User menu" });
|
||||
await user.click(trigger);
|
||||
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(
|
||||
"href",
|
||||
"/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", () => {
|
||||
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100);
|
||||
const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get")
|
||||
.mockReturnValue(100);
|
||||
renderWithContexts(
|
||||
<div className="bar">
|
||||
<div className="tabs"><span>a</span></div>
|
||||
<div className="tabs">
|
||||
<span>a</span>
|
||||
</div>
|
||||
<PresenceRow fitWithin=".bar" fitBeside=".tabs" />
|
||||
</div>,
|
||||
{ ws: { onlineUsers: online(9) } },
|
||||
@@ -155,7 +166,10 @@ describe("PresenceRow", () => {
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -168,7 +182,10 @@ describe("CategorySwitcher", () => {
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: "Music" });
|
||||
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" }));
|
||||
expect(currentPath()).toBe("/cinema/new");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
@@ -188,7 +205,11 @@ describe("CategorySwitcher", () => {
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
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.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
@@ -199,17 +220,28 @@ describe("CategorySelect", () => {
|
||||
test("toggle chips reflect the selection", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />, {
|
||||
categories,
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false");
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />,
|
||||
{
|
||||
categories,
|
||||
},
|
||||
);
|
||||
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" }));
|
||||
expect(onToggle).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
test("nothing without categories", () => {
|
||||
renderWithContexts(<CategorySelect selected={new Set()} onToggle={() => {}} />);
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set()} onToggle={() => {}} />,
|
||||
);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<FeedTabBar />, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories });
|
||||
expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument();
|
||||
renderWithContexts(<FeedTabBar />, {
|
||||
route: "/music/hot",
|
||||
path: "/:categorySlug/:feedTab",
|
||||
categories,
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Followed" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active");
|
||||
await user.click(screen.getByRole("button", { name: "Journal" }));
|
||||
expect(currentPath()).toBe("/music/journal");
|
||||
@@ -245,7 +282,9 @@ describe("TabBar / FeedTabBar", () => {
|
||||
route: "/~/followed",
|
||||
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", () => {
|
||||
renderWithContexts(<AppHeader disableNew />, { session: makeSession({ username: "ann" }) });
|
||||
expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann");
|
||||
renderWithContexts(<AppHeader disableNew />, {
|
||||
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: "Notifications" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument();
|
||||
const dump = screen.getByRole("button", { name: /Dump/ });
|
||||
expect(dump).toBeDisabled();
|
||||
expect(dump).toHaveAttribute("title", "Server unreachable");
|
||||
@@ -281,10 +326,11 @@ describe("AppHeader", () => {
|
||||
renderWithContexts(<AppHeader />);
|
||||
const toggle = screen.getByRole("button", { name: "Search" });
|
||||
await user.click(toggle);
|
||||
expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cancel search" }))
|
||||
.toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
const [input] = screen.getAllByRole("searchbox");
|
||||
await user.type(input, "x");
|
||||
await user.keyboard("{Escape}");
|
||||
@@ -303,7 +349,7 @@ describe("AppHeader", () => {
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
|
||||
<WSContext.Provider value={defaultOf(WSContext)}>
|
||||
<WSContext.Provider value={defaultWSValue}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<AppHeader />} />
|
||||
@@ -357,15 +403,25 @@ describe("AppHeader", () => {
|
||||
describe("floating buttons", () => {
|
||||
function withChat(openChat = vi.fn()) {
|
||||
return (ui: React.ReactNode) => (
|
||||
<ChatContext.Provider value={{ isChatOpen: false, openChat, closeChat: () => {} }}>
|
||||
<ChatContext.Provider
|
||||
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
|
||||
>
|
||||
{ui}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
test("guests get neither", () => {
|
||||
renderWithContexts(withChat()(<><ChatFab /><DumpFab /></>));
|
||||
expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument();
|
||||
renderWithContexts(
|
||||
withChat()(
|
||||
<>
|
||||
<ChatFab />
|
||||
<DumpFab />
|
||||
</>,
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("button", { hidden: true })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("they show once the header scrolls away", () => {
|
||||
@@ -400,14 +456,20 @@ describe("floating buttons", () => {
|
||||
expect(chat).toHaveClass("chat-fab--visible");
|
||||
act(() => chat.click());
|
||||
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", () => {
|
||||
test("Tooltip shows on hover", async () => {
|
||||
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"));
|
||||
expect(screen.getByText("Explained")).toBeInTheDocument();
|
||||
await user.unhover(screen.getByText("hover me"));
|
||||
@@ -415,7 +477,13 @@ describe("small pieces", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
expect(count).toHaveClass("text-editor-count--warn");
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
|
||||
|
||||
@@ -10,7 +10,10 @@ function media(container: HTMLElement) {
|
||||
return container.querySelector<HTMLMediaElement>("audio, video")!;
|
||||
}
|
||||
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;
|
||||
fireEvent(el, new Event("durationchange"));
|
||||
fireEvent(el, new Event("timeupdate"));
|
||||
@@ -22,7 +25,12 @@ describe("MediaPlayer", () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play");
|
||||
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause");
|
||||
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" }));
|
||||
expect(play).toHaveBeenCalled();
|
||||
@@ -35,15 +43,30 @@ describe("MediaPlayer", () => {
|
||||
test("autoplays when asked", async () => {
|
||||
const onPlayStateChange = vi.fn();
|
||||
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));
|
||||
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a play refused by the browser stays paused", async () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError"));
|
||||
renderWithContexts(<MediaPlayer src="https://s/a.mp3" kind="audio" trackStyle="progress" autoplay />);
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(
|
||||
new Error("NotAllowedError"),
|
||||
);
|
||||
renderWithContexts(
|
||||
<MediaPlayer
|
||||
src="https://s/a.mp3"
|
||||
kind="audio"
|
||||
trackStyle="progress"
|
||||
autoplay
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
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 () => {
|
||||
const onPlayStateChange = vi.fn();
|
||||
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" }));
|
||||
fireEvent(media(container), new Event("pause"));
|
||||
@@ -79,7 +107,9 @@ describe("MediaPlayer", () => {
|
||||
expect(screen.getByText("1:05")).toBeInTheDocument();
|
||||
expect(screen.getByText("3:20")).toBeInTheDocument();
|
||||
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);
|
||||
fireEvent(el, new Event("ended"));
|
||||
expect(onEnded).toHaveBeenCalled();
|
||||
@@ -104,7 +134,12 @@ describe("MediaPlayer", () => {
|
||||
test("an error is reported for re-resolving", () => {
|
||||
const onError = vi.fn();
|
||||
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"));
|
||||
expect(onError).toHaveBeenCalled();
|
||||
@@ -114,14 +149,21 @@ describe("MediaPlayer", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<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);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mute" }));
|
||||
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");
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -129,7 +171,13 @@ describe("MediaPlayer", () => {
|
||||
const seekRef = { current: null as ((t: number) => void) | null };
|
||||
const toggleRef = { current: null as (() => void) | null };
|
||||
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));
|
||||
expect(media(container).currentTime).toBe(33);
|
||||
@@ -141,7 +189,12 @@ describe("MediaPlayer", () => {
|
||||
|
||||
test("resumes at a start offset once metadata is in", () => {
|
||||
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"));
|
||||
expect(media(container).currentTime).toBe(42);
|
||||
@@ -149,14 +202,23 @@ describe("MediaPlayer", () => {
|
||||
|
||||
test("video has a fullscreen button", () => {
|
||||
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", () => {
|
||||
const session = makeSession();
|
||||
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", () => {
|
||||
renderWithContexts(<FilePreview dump={file("image/png")} />, { session });
|
||||
@@ -175,7 +237,9 @@ describe("FilePreview", () => {
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
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" }));
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: "file",
|
||||
@@ -189,9 +253,13 @@ describe("FilePreview", () => {
|
||||
|
||||
test("compact video claims its generated still as artwork", async () => {
|
||||
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" }));
|
||||
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 () => {
|
||||
@@ -201,7 +269,11 @@ describe("FilePreview", () => {
|
||||
player: {
|
||||
togglePlay,
|
||||
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" }));
|
||||
@@ -211,13 +283,19 @@ describe("FilePreview", () => {
|
||||
test("the global audio waveform starts playback", async () => {
|
||||
const play = vi.fn();
|
||||
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" }));
|
||||
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", () => {
|
||||
const { container } = renderWithContexts(<FilePreview dump={file("application/pdf")} compact />);
|
||||
const { container } = renderWithContexts(
|
||||
<FilePreview dump={file("application/pdf")} compact />,
|
||||
);
|
||||
expect(container).toHaveTextContent("📄");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import { GlobalPlayer } from "../../components/GlobalPlayer.tsx";
|
||||
import { JournalCard } from "../../components/JournalCard.tsx";
|
||||
import { PlaylistCard } from "../../components/PlaylistCard.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 { currentPath, renderWithContexts } from "../render.tsx";
|
||||
import { markPlaylistVisited } from "../../utils/visited.ts";
|
||||
@@ -43,7 +46,14 @@ describe("GlobalPlayer", () => {
|
||||
test("hidden until something plays; the body makes room for it", () => {
|
||||
renderPlayer();
|
||||
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.body).toHaveClass("has-player");
|
||||
act(() => player.stop());
|
||||
@@ -52,27 +62,58 @@ describe("GlobalPlayer", () => {
|
||||
});
|
||||
|
||||
test("an embed autoplays on a fresh play only", () => {
|
||||
localStorage.setItem("player", JSON.stringify({
|
||||
v: 2,
|
||||
queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }],
|
||||
index: 0,
|
||||
time: 0,
|
||||
owner: null,
|
||||
}));
|
||||
localStorage.setItem(
|
||||
"player",
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
queue: [{
|
||||
kind: "embed",
|
||||
embedUrl: "https://www.youtube.com/embed/abc",
|
||||
type: "youtube",
|
||||
}],
|
||||
index: 0,
|
||||
time: 0,
|
||||
owner: null,
|
||||
}),
|
||||
);
|
||||
renderPlayer();
|
||||
const iframe = () => document.querySelector("iframe")!;
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
});
|
||||
|
||||
test("a YouTube playlist embed gets the JS API parameters", () => {
|
||||
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);
|
||||
expect(src.searchParams.get("enablejsapi")).toBe("1");
|
||||
expect(src.searchParams.get("list")).toBe("PL");
|
||||
@@ -82,10 +123,14 @@ describe("GlobalPlayer", () => {
|
||||
const user = userEvent.setup();
|
||||
renderPlayer();
|
||||
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("1 / 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Previous track" }))
|
||||
.toBeDisabled();
|
||||
|
||||
const list = screen.getByRole("list");
|
||||
expect(within(list).getAllByRole("button")).toHaveLength(3);
|
||||
@@ -105,7 +150,8 @@ describe("GlobalPlayer", () => {
|
||||
act(() => player.playQueue([track(0), track(1)]));
|
||||
await user.click(screen.getByRole("button", { name: "Collapse player" }));
|
||||
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)]));
|
||||
expect(screen.getByRole("list")).toBeInTheDocument();
|
||||
});
|
||||
@@ -120,8 +166,16 @@ describe("GlobalPlayer", () => {
|
||||
|
||||
test("a file plays in the built-in player", () => {
|
||||
renderPlayer();
|
||||
act(() => 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();
|
||||
act(() =>
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -138,24 +192,44 @@ describe("GlobalPlayer", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { container, unmount } = renderWithContexts(
|
||||
<ul>
|
||||
<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"
|
||||
{...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} />
|
||||
</ul>,
|
||||
);
|
||||
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(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]).toHaveTextContent("🔗");
|
||||
unmount();
|
||||
@@ -173,7 +247,13 @@ describe("JournalCard", () => {
|
||||
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(
|
||||
<ul>
|
||||
<JournalCard dump={yt} shape="wide" {...base} />
|
||||
@@ -183,7 +263,9 @@ describe("JournalCard", () => {
|
||||
);
|
||||
const cards = container.querySelectorAll("li");
|
||||
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("/");
|
||||
await user.click(cards[1].querySelector(".journal-card-image")!);
|
||||
expect(currentPath()).toBe("/dumps/p");
|
||||
@@ -192,21 +274,50 @@ describe("JournalCard", () => {
|
||||
test("a video file shows its generated still", () => {
|
||||
const { container } = renderWithContexts(
|
||||
<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>,
|
||||
);
|
||||
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", () => {
|
||||
test("title, badge, owner, count; clicking opens it and marks it seen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = makePlaylist({ id: "p1", 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");
|
||||
const p = makePlaylist({
|
||||
id: "p1",
|
||||
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.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("Tunes")).toBeInTheDocument();
|
||||
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", () => {
|
||||
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(container.querySelector(".unread-dot")).toBeNull();
|
||||
});
|
||||
|
||||
test("a new playlist has a dot until visited", () => {
|
||||
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();
|
||||
first.unmount();
|
||||
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();
|
||||
});
|
||||
|
||||
test("delete doesn't open the playlist", async () => {
|
||||
const onDelete = vi.fn();
|
||||
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" }));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
expect(currentPath()).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, useContext, useState } from "react";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
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 { STREAM_STALE_MS } from "../../utils/streamSources.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", () => {
|
||||
localStorage.setItem(
|
||||
"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();
|
||||
expect(p().queueIndex).toBe(1);
|
||||
@@ -223,7 +232,13 @@ describe("persistence", () => {
|
||||
test("another account's queue isn't restored", () => {
|
||||
localStorage.setItem(
|
||||
"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" }));
|
||||
expect(p().current).toBeNull();
|
||||
@@ -232,7 +247,10 @@ describe("persistence", () => {
|
||||
test("an ownerless session holding file URLs isn't restored; one of embeds is", () => {
|
||||
localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 }));
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -241,7 +259,12 @@ describe("persistence", () => {
|
||||
expect(setup().p().current).toBeNull();
|
||||
localStorage.setItem(
|
||||
"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();
|
||||
});
|
||||
@@ -330,7 +353,9 @@ describe("expiring streams", () => {
|
||||
act(() => p().play(embed(9)));
|
||||
await act(async () =>
|
||||
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));
|
||||
@@ -359,7 +384,9 @@ describe("expiring streams", () => {
|
||||
}),
|
||||
);
|
||||
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().startTime).toBe(25);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,9 @@ function setup({ signedIn = true } = {}) {
|
||||
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());
|
||||
|
||||
describe("connection", () => {
|
||||
@@ -174,7 +176,12 @@ describe("votes", () => {
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
expect(ws().voteCounts.d1).toBe(5);
|
||||
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);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
@@ -194,7 +201,12 @@ describe("votes", () => {
|
||||
socket.welcome({ myVotes: ["d1"] });
|
||||
act(() => ws().removeVote("d1", 3));
|
||||
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().voteCounts.d1).toBe(3);
|
||||
});
|
||||
@@ -225,12 +237,34 @@ describe("votes", () => {
|
||||
test("votes_update from another tab of mine syncs myVotes", () => {
|
||||
const { socket, ws } = setup();
|
||||
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().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" });
|
||||
socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" });
|
||||
expect(ws().lastVoteEvent).toEqual({
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -239,8 +273,12 @@ describe("votes", () => {
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 0));
|
||||
socket.receive({
|
||||
type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [],
|
||||
unreadNotificationCount: 0, unreadChatCount: 0,
|
||||
type: "welcome",
|
||||
users: [],
|
||||
myVotes: ["d1"],
|
||||
myCommentLikes: [],
|
||||
unreadNotificationCount: 0,
|
||||
unreadChatCount: 0,
|
||||
});
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
@@ -253,11 +291,22 @@ describe("comment likes", () => {
|
||||
socket.welcome();
|
||||
act(() => ws().castCommentLike("c1", 0));
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" });
|
||||
socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 });
|
||||
expect(socket.messages).toContainEqual({
|
||||
type: "comment_like_cast",
|
||||
commentId: "c1",
|
||||
});
|
||||
socket.receive({
|
||||
type: "comment_like_ack",
|
||||
commentId: "c1",
|
||||
action: "cast",
|
||||
likeCount: 1,
|
||||
});
|
||||
act(() => ws().removeCommentLike("c1"));
|
||||
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" });
|
||||
expect(ws().myLikes.has("c1")).toBe(true);
|
||||
expect(ws().likeCounts.c1).toBe(1);
|
||||
@@ -275,7 +324,13 @@ describe("comment likes", () => {
|
||||
test("comment_likes_update from my other tab", () => {
|
||||
const { socket, ws } = setup();
|
||||
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().likeCounts.c2).toBe(3);
|
||||
expect(ws().lastLikeEvent?.commentId).toBe("c2");
|
||||
@@ -290,7 +345,10 @@ describe("dumps, playlists, comments, users", () => {
|
||||
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: "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[0].title).toBe("again");
|
||||
expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date);
|
||||
@@ -301,7 +359,10 @@ describe("dumps, playlists, comments, users", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
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[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", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) });
|
||||
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"]]);
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ id: "a", title: "one" })),
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -338,14 +411,31 @@ describe("dumps, playlists, comments, users", () => {
|
||||
socket.welcome();
|
||||
const p = makePlaylist({ id: "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) });
|
||||
expect(ws().lastPlaylistEvent?.type).toBe("updated");
|
||||
socket.receive({ 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_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" });
|
||||
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", () => {
|
||||
@@ -353,27 +443,44 @@ describe("dumps, playlists, comments, users", () => {
|
||||
socket.welcome();
|
||||
const c = makeComment({ id: "c", dumpId: "d" });
|
||||
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);
|
||||
socket.receive({ type: "comment_updated", comment: wire(c) });
|
||||
expect(ws().lastCommentEvent?.type).toBe("updated");
|
||||
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", () => {
|
||||
const { socket, hook } = setup();
|
||||
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) });
|
||||
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");
|
||||
});
|
||||
|
||||
test("presence updates", () => {
|
||||
const { socket, ws } = setup();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -382,7 +489,10 @@ describe("notifications", () => {
|
||||
test("a new one bumps the count and is remembered", () => {
|
||||
const { socket, ws } = setup();
|
||||
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().lastNotification?.id).toBe("n");
|
||||
act(() => ws().clearUnreadNotifications());
|
||||
@@ -397,7 +507,10 @@ describe("chat", () => {
|
||||
const m = makeChatMessage({ id: "m1", userId: "other" });
|
||||
socket.receive({ type: "chat_message", 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().unreadChatCount).toBe(1);
|
||||
});
|
||||
@@ -406,7 +519,10 @@ describe("chat", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
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[0].id).toBe("m5");
|
||||
@@ -437,7 +553,9 @@ describe("chat", () => {
|
||||
test("sending shows a pending message, retired by its echo", () => {
|
||||
const { socket, ws } = setup();
|
||||
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;
|
||||
expect(pending).toMatchObject({ body: "hello", status: "sending" });
|
||||
expect(socket.messages).toContainEqual({
|
||||
@@ -482,7 +600,9 @@ describe("chat", () => {
|
||||
const retry = ws().pendingChatMessages[0];
|
||||
expect(retry.status).toBe("sending");
|
||||
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.
|
||||
socket.receive({
|
||||
type: "chat_message",
|
||||
@@ -497,8 +617,16 @@ describe("chat", () => {
|
||||
socket.welcome();
|
||||
act(() => ws().sendChatMessage("hi"));
|
||||
const { nonce } = ws().pendingChatMessages[0];
|
||||
socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" });
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" });
|
||||
socket.receive({
|
||||
type: "error",
|
||||
scope: "chat",
|
||||
nonce,
|
||||
message: "Too long",
|
||||
});
|
||||
expect(ws().pendingChatMessages[0]).toMatchObject({
|
||||
status: "failed",
|
||||
error: "Too long",
|
||||
});
|
||||
act(() => ws().discardChatMessage(nonce));
|
||||
expect(ws().pendingChatMessages).toEqual([]);
|
||||
});
|
||||
@@ -534,9 +662,15 @@ describe("chat", () => {
|
||||
]);
|
||||
const m = makeChatMessage({ id: "m1", body: "old" });
|
||||
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");
|
||||
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);
|
||||
socket.receive({ type: "chat_message_deleted", id: "m1" });
|
||||
expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]);
|
||||
@@ -548,7 +682,10 @@ describe("chat", () => {
|
||||
socket.welcome();
|
||||
act(() => ws().setChatTyping(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);
|
||||
});
|
||||
|
||||
@@ -558,11 +695,15 @@ describe("chat", () => {
|
||||
act(() => ws().markChatRead(new Date(1_000_000)));
|
||||
expect(ws().unreadChatCount).toBe(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(3_000_000)));
|
||||
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));
|
||||
const reads = socket.messages.filter((m) => m.type === "chat_read");
|
||||
expect(reads).toHaveLength(2);
|
||||
@@ -570,7 +711,9 @@ describe("chat", () => {
|
||||
// Older than what's been sent: ignored.
|
||||
act(() => ws().markChatRead(new Date(10)));
|
||||
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", () => {
|
||||
@@ -587,7 +730,11 @@ describe("chat", () => {
|
||||
test("chat_read_update from another tab moves the marker", () => {
|
||||
const { socket, ws } = setup();
|
||||
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().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z"));
|
||||
});
|
||||
@@ -598,7 +745,11 @@ describe("chat", () => {
|
||||
const t0 = Date.now();
|
||||
const at = (s: number) => new Date(t0 + s * 1000);
|
||||
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) });
|
||||
for (const m of [early, edited, gone]) {
|
||||
socket.receive({ type: "chat_message", message: wire(m) });
|
||||
@@ -611,7 +762,12 @@ describe("chat", () => {
|
||||
ws().reconcileChat(
|
||||
[
|
||||
{ ...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),
|
||||
@@ -643,6 +799,9 @@ describe("chat", () => {
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
FakeWebSocket.last.welcome();
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,10 @@ describe("AuthProvider", () => {
|
||||
test("a role changed on the server is picked up", async () => {
|
||||
const session = makeSession({ role: "user" });
|
||||
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 });
|
||||
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
|
||||
@@ -99,13 +102,17 @@ describe("AuthProvider", () => {
|
||||
const session = makeSession({ username: "elsewhere" });
|
||||
storeSession(session);
|
||||
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");
|
||||
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" }));
|
||||
globalThis.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "unrelated" }),
|
||||
);
|
||||
});
|
||||
expect(result.current.user).not.toBeNull();
|
||||
act(() => {
|
||||
@@ -132,7 +139,9 @@ describe("AuthProvider", () => {
|
||||
});
|
||||
|
||||
describe("FollowProvider", () => {
|
||||
function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) {
|
||||
function setup(
|
||||
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
|
||||
) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
@@ -260,7 +269,9 @@ describe("ThemeProvider", () => {
|
||||
expect(result.current.style).toBe("nyt");
|
||||
expect(result.current.colorScheme).toBe("light");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>/. */
|
||||
export function serveEmojibase() {
|
||||
route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData)));
|
||||
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" } }));
|
||||
route(
|
||||
"GET /emojibase/:version/en/data.json",
|
||||
() => new Response(JSON.stringify(enData)),
|
||||
);
|
||||
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" } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,11 +24,7 @@ describe("usePlayRichContent", () => {
|
||||
const playQueue = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ctxMod.PlayerContext.Provider
|
||||
value={{
|
||||
...(ctxMod.PlayerContext as unknown as { _currentValue: object })._currentValue,
|
||||
play,
|
||||
playQueue,
|
||||
} as never}
|
||||
value={{ ...ctxMod.defaultPlayerValue, play, playQueue }}
|
||||
>
|
||||
{children}
|
||||
</ctxMod.PlayerContext.Provider>
|
||||
@@ -90,7 +86,13 @@ describe("usePlayRichContent", () => {
|
||||
sourceUrl: bc.url,
|
||||
itemType: "album",
|
||||
resolvedAt: 1,
|
||||
tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }],
|
||||
tracks: [{
|
||||
index: 0,
|
||||
title: "T",
|
||||
streamUrl: "https://s/0",
|
||||
streamable: true,
|
||||
capped: false,
|
||||
}],
|
||||
}));
|
||||
const { hook, playQueue, canPlay } = await load("native");
|
||||
expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true);
|
||||
@@ -108,7 +110,9 @@ describe("usePlayRichContent", () => {
|
||||
const { hook, play, playQueue } = await load("native");
|
||||
await act(() => hook.result.current.playRichContent(bc));
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" }));
|
||||
expect(play).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "embed" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown mode reads as embed", async () => {
|
||||
@@ -119,7 +123,9 @@ describe("usePlayRichContent", () => {
|
||||
|
||||
describe("useUserDumpFeed", () => {
|
||||
const ann = makeUser({ username: "ann" });
|
||||
function setup(opts: { session?: ReturnType<typeof makeSession> | null } = {}) {
|
||||
function setup(
|
||||
opts: { session?: ReturnType<typeof makeSession> | null } = {},
|
||||
) {
|
||||
const session = opts.session ?? null;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState(session);
|
||||
@@ -147,7 +153,10 @@ describe("useUserDumpFeed", () => {
|
||||
const session = makeSession();
|
||||
const { result } = setup({ session });
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.profileUser.username).toBe("ann");
|
||||
expect(s.items[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(s.hasMore).toBe(true);
|
||||
@@ -167,35 +176,51 @@ describe("useUserDumpFeed", () => {
|
||||
route("GET /api/users/:u", () => new TypeError("Failed to fetch"));
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state).toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringMatching(/Could not connect/),
|
||||
}));
|
||||
await waitFor(() =>
|
||||
expect(result.current.state).toMatchObject({
|
||||
status: "error",
|
||||
error: expect.stringMatching(/Could not connect/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the sentinel loads the next page", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/dumps", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeDump({ id: "a" })], true)
|
||||
: page([makeDump({ id: "b" })], false));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeDump({ id: "a" })], true)
|
||||
: page([makeDump({ id: "b" })], false),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
const sentinel = document.createElement("div");
|
||||
act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel));
|
||||
act(() => {
|
||||
result.current.sentinelRef(sentinel);
|
||||
});
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
|
||||
act(() => obs.report(true));
|
||||
await waitFor(() => {
|
||||
const s = result.current.state;
|
||||
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.hasMore).toBe(false);
|
||||
expect(s.page).toBe(2);
|
||||
});
|
||||
|
||||
test("changing user resets to loading", async () => {
|
||||
route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u })));
|
||||
route(
|
||||
"GET /api/users/:u",
|
||||
(req) => wire(makeUser({ username: req.params.u })),
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
const { result, rerender } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
@@ -206,17 +231,26 @@ describe("useUserDumpFeed", () => {
|
||||
|
||||
test("setItems edits the loaded list", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ id: "a" }), makeDump({ id: "b" })]),
|
||||
);
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
|
||||
act(() => result.current.setItems((prev) => prev.slice(1)));
|
||||
const s = result.current.state as Extract<typeof result.current.state, { status: "loaded" }>;
|
||||
const s = result.current.state as Extract<
|
||||
typeof result.current.state,
|
||||
{ status: "loaded" }
|
||||
>;
|
||||
expect(s.items.map((d) => d.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useYouTubePlaylist", () => {
|
||||
type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void };
|
||||
type Events = {
|
||||
onReady?: () => void;
|
||||
onStateChange?: (e: { data: number }) => void;
|
||||
};
|
||||
function installYT(ids = ["a", "b", "c"]) {
|
||||
const player = {
|
||||
events: {} as Events,
|
||||
@@ -231,23 +265,33 @@ describe("useYouTubePlaylist", () => {
|
||||
},
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
(globalThis as unknown as { YT: unknown }).YT = {
|
||||
Player: class {
|
||||
constructor(_el: unknown, opts: { events: Events }) {
|
||||
player.events = opts.events;
|
||||
return player;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Forwards to `player`, which the tests inspect and steer.
|
||||
class Player {
|
||||
constructor(_el: HTMLIFrameElement, opts: { events: Events }) {
|
||||
player.events = opts.events;
|
||||
}
|
||||
playVideo = player.playVideo;
|
||||
playVideoAt = player.playVideoAt;
|
||||
nextVideo = player.nextVideo;
|
||||
previousVideo = player.previousVideo;
|
||||
getPlaylist = player.getPlaylist;
|
||||
getPlaylistIndex = () => player.index;
|
||||
destroy = player.destroy;
|
||||
}
|
||||
window.YT = { Player };
|
||||
return player;
|
||||
}
|
||||
|
||||
// The IFrame API loads once per document and is cached in the module.
|
||||
async function setup(embedUrl: string | null) {
|
||||
vi.resetModules();
|
||||
const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts");
|
||||
const { useYouTubePlaylist } = await import(
|
||||
"../../hooks/useYouTubePlaylist.ts"
|
||||
);
|
||||
return renderHook(({ url }) => {
|
||||
const ref = useRef<HTMLIFrameElement | null>(document.createElement("iframe"));
|
||||
const ref = useRef<HTMLIFrameElement | null>(
|
||||
document.createElement("iframe"),
|
||||
);
|
||||
return useYouTubePlaylist(url, ref);
|
||||
}, { initialProps: { url: embedUrl } });
|
||||
}
|
||||
@@ -261,7 +305,9 @@ describe("useYouTubePlaylist", () => {
|
||||
{ videoId: "c", title: "C", duration: 30 },
|
||||
],
|
||||
}));
|
||||
const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1");
|
||||
const { result } = await setup(
|
||||
"https://www.youtube.com/embed/videoseries?list=PL1",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(2));
|
||||
expect(result.current.ready).toBe(false);
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
@@ -285,12 +331,17 @@ describe("useYouTubePlaylist", () => {
|
||||
|
||||
test("a pause as the tab goes to the background is undone, a few times at most", async () => {
|
||||
const player = installYT();
|
||||
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
await setup("https://www.youtube.com/embed/x?list=PL2");
|
||||
await waitFor(() => expect(player.events.onStateChange).toBeDefined());
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 }));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => player.events.onStateChange!({ data: 2 }));
|
||||
}
|
||||
expect(player.playVideo).toHaveBeenCalledTimes(3);
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
@@ -307,8 +358,13 @@ describe("useYouTubePlaylist", () => {
|
||||
test("a new embed drops the old list; unmount destroys the player", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const player = installYT();
|
||||
route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] }));
|
||||
const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3");
|
||||
route(
|
||||
"GET /api/youtube/playlist",
|
||||
() => ({ tracks: [{ videoId: "a", title: "A" }] }),
|
||||
);
|
||||
const { result, rerender, unmount } = await setup(
|
||||
"https://www.youtube.com/embed/x?list=PL3",
|
||||
);
|
||||
await waitFor(() => expect(result.current.rows).toHaveLength(1));
|
||||
await waitFor(() => expect(player.events.onReady).toBeDefined());
|
||||
route("GET /api/youtube/playlist", () => fail("x", 500));
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts";
|
||||
import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts";
|
||||
import {
|
||||
type MentionKey,
|
||||
useMentionAutocomplete,
|
||||
} from "../../hooks/useMentionAutocomplete.ts";
|
||||
import { calls, deferred, route } from "../fetch.ts";
|
||||
|
||||
function key(k: string, mods: Partial<KeyboardEvent> = {}) {
|
||||
function key(k: string, mods: Partial<MentionKey> = {}): MentionKey {
|
||||
return {
|
||||
key: k,
|
||||
ctrlKey: false,
|
||||
@@ -15,7 +17,7 @@ function key(k: string, mods: Partial<KeyboardEvent> = {}) {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...mods,
|
||||
} as unknown as KeyboardEvent<HTMLTextAreaElement>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useEmojiTrigger", () => {
|
||||
@@ -125,13 +127,20 @@ describe("useMentionAutocomplete", () => {
|
||||
}
|
||||
|
||||
test("looks up the @query after a pause and opens with results", async () => {
|
||||
route("GET /api/users/search", (req) =>
|
||||
users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!)));
|
||||
route(
|
||||
"GET /api/users/search",
|
||||
(req) =>
|
||||
users.filter((u) =>
|
||||
u.username.startsWith(req.url.searchParams.get("q")!)
|
||||
),
|
||||
);
|
||||
const { hook, type } = setup();
|
||||
await type("hi @al");
|
||||
expect(hook.result.current.mentionOpen).toBe(true);
|
||||
expect(hook.result.current.mentionResults).toHaveLength(3);
|
||||
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al");
|
||||
expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe(
|
||||
"al",
|
||||
);
|
||||
});
|
||||
|
||||
test("debounces keystrokes into one lookup", async () => {
|
||||
@@ -168,7 +177,9 @@ describe("useMentionAutocomplete", () => {
|
||||
const { hook, type } = setup();
|
||||
await type("@al");
|
||||
await type("@alz");
|
||||
await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users }))));
|
||||
await act(async () =>
|
||||
slow.resolve(new Response(JSON.stringify({ success: true, data: users })))
|
||||
);
|
||||
expect(hook.result.current.mentionOpen).toBe(false);
|
||||
});
|
||||
|
||||
@@ -231,7 +242,9 @@ describe("useMentionAutocomplete", () => {
|
||||
|
||||
test("keys pass through while closed", () => {
|
||||
const { hook } = setup();
|
||||
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false);
|
||||
expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("moving the caret off the mention closes it", async () => {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { useFading } from "../../hooks/useFading.ts";
|
||||
import { useFeedCache } from "../../hooks/useFeedCache.ts";
|
||||
import { useTabParam } from "../../hooks/useTabParam.ts";
|
||||
@@ -112,7 +124,13 @@ describe("useFeedCache", () => {
|
||||
test("a fresh (PUSH) visit ignores the cache", () => {
|
||||
sessionStorage.setItem(
|
||||
"k",
|
||||
JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }),
|
||||
JSON.stringify({
|
||||
items: [1],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
scrollY: 0,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
function Probe() {
|
||||
const { cached } = useFeedCache("k", (x) => x);
|
||||
@@ -120,7 +138,11 @@ describe("useFeedCache", () => {
|
||||
}
|
||||
function Home() {
|
||||
const navigate = useNavigate();
|
||||
return <button type="button" onClick={() => navigate("/feed")}>go</button>;
|
||||
return (
|
||||
<button type="button" onClick={() => navigate("/feed")}>
|
||||
go
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -156,7 +178,13 @@ describe("useTabParam", () => {
|
||||
return useTabParam(TABS, "dumps");
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter initialEntries={[{ pathname: path.split("?")[0], search: path.includes("?") ? "?" + path.split("?")[1] : "", state }]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[{
|
||||
pathname: path.split("?")[0],
|
||||
search: path.includes("?") ? "?" + path.split("?")[1] : "",
|
||||
state,
|
||||
}]}
|
||||
>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
),
|
||||
@@ -165,8 +193,12 @@ describe("useTabParam", () => {
|
||||
}
|
||||
|
||||
test("reads the tab after /~/", () => {
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists");
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists");
|
||||
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
|
||||
"playlists",
|
||||
);
|
||||
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
||||
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
||||
});
|
||||
@@ -197,7 +229,8 @@ describe("useTabParam", () => {
|
||||
describe("useDocumentTitle", () => {
|
||||
test("composes page, site name and unread badge", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ title }: { title: string | null | undefined }) => useDocumentTitle(title),
|
||||
({ title }: { title: string | null | undefined }) =>
|
||||
useDocumentTitle(title),
|
||||
{ initialProps: { title: "Cats" as string | null | undefined } },
|
||||
);
|
||||
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
|
||||
@@ -226,12 +259,7 @@ describe("useInfiniteScroll", () => {
|
||||
}
|
||||
const intersect = (isIntersecting: boolean) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() =>
|
||||
obs.callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
obs as unknown as IntersectionObserver,
|
||||
)
|
||||
);
|
||||
act(() => obs.report(isIntersecting));
|
||||
};
|
||||
|
||||
test("loads more when the sentinel shows, only while enabled", () => {
|
||||
@@ -269,7 +297,16 @@ describe("useInfiniteScroll", () => {
|
||||
}
|
||||
delays.push(waited);
|
||||
}
|
||||
expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
|
||||
expect(delays).toEqual([
|
||||
1000,
|
||||
2000,
|
||||
4000,
|
||||
8000,
|
||||
16000,
|
||||
30000,
|
||||
30000,
|
||||
30000,
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-rendering doesn't restart the wait", () => {
|
||||
@@ -292,18 +329,13 @@ describe("useInfiniteScroll", () => {
|
||||
render(<Feed onMore={onMore} enabled />);
|
||||
const at = (top: number) => {
|
||||
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
||||
act(() =>
|
||||
obs.callback(
|
||||
[{ isIntersecting: true, boundingClientRect: { top } } as IntersectionObserverEntry],
|
||||
obs as unknown as IntersectionObserver,
|
||||
)
|
||||
);
|
||||
act(() => obs.report(true, top));
|
||||
};
|
||||
at(500);
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(1); // waiting
|
||||
intersect(false);
|
||||
intersect(true);
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
|
||||
at(500);
|
||||
expect(onMore).toHaveBeenCalledTimes(2);
|
||||
at(500);
|
||||
at(900); // the page grew: that load worked
|
||||
@@ -395,7 +427,7 @@ describe("useDumpCardLink", () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (!link.handledAsNewTab(e)) (e.currentTarget.dataset.played = "1");
|
||||
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
|
||||
}}
|
||||
>
|
||||
play
|
||||
@@ -431,14 +463,27 @@ describe("useDumpCardLink", () => {
|
||||
|
||||
test("a middle click opens a new tab, except over a link", () => {
|
||||
const { open } = setup();
|
||||
const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
|
||||
const mouseDown = new MouseEvent("mousedown", {
|
||||
button: 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
screen.getByTestId("card").dispatchEvent(mouseDown);
|
||||
expect(mouseDown.defaultPrevented).toBe(true);
|
||||
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true }));
|
||||
fireEvent(
|
||||
screen.getByText("inner link"),
|
||||
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true }));
|
||||
fireEvent(
|
||||
screen.getByTestId("card"),
|
||||
new MouseEvent("auxclick", { button: 2, bubbles: true }),
|
||||
);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -469,7 +514,14 @@ describe("useAnchoredFloat", () => {
|
||||
ref.current = el;
|
||||
if (el) {
|
||||
el.getBoundingClientRect = () =>
|
||||
({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect;
|
||||
({
|
||||
top,
|
||||
bottom: top + 20,
|
||||
left: 900,
|
||||
right: 1000,
|
||||
width: 100,
|
||||
height: 20,
|
||||
}) as DOMRect;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -522,9 +574,10 @@ describe("useHeightTransition", () => {
|
||||
height = 250;
|
||||
rerender(<Box open />);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]];
|
||||
expect(frames[0].height).toBe("100px");
|
||||
expect(frames[1].height).toBe("250px");
|
||||
expect(animate.mock.calls[0][0]).toEqual([
|
||||
expect.objectContaining({ height: "100px" }),
|
||||
expect.objectContaining({ height: "250px" }),
|
||||
]);
|
||||
|
||||
// No change in height, no animation.
|
||||
rerender(<Box open={false} />);
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts";
|
||||
import {
|
||||
defaultWSValue,
|
||||
WSContext,
|
||||
type WSContextValue,
|
||||
} from "../../contexts/WSContext.ts";
|
||||
import type { Dump, Playlist } from "../../model.ts";
|
||||
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
|
||||
import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts";
|
||||
import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts";
|
||||
import { makeDump, makePlaylist } from "../fixtures.ts";
|
||||
import { defaultOf } from "../render.tsx";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
function liveWS() {
|
||||
let push: (patch: Partial<WSContextValue>) => void = () => {};
|
||||
const base = defaultOf(WSContext);
|
||||
const base = defaultWSValue;
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState(base);
|
||||
push = (patch) => setValue((v) => ({ ...v, ...patch }));
|
||||
return <WSContext.Provider value={value}>{children}</WSContext.Provider>;
|
||||
}
|
||||
return { Wrapper, emit: (patch: Partial<WSContextValue>) => act(() => push(patch)) };
|
||||
return {
|
||||
Wrapper,
|
||||
emit: (patch: Partial<WSContextValue>) => act(() => push(patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function useList<T>(initial: T[]) {
|
||||
@@ -29,7 +35,10 @@ function useList<T>(initial: T[]) {
|
||||
const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
|
||||
|
||||
describe("useDumpListSync", () => {
|
||||
function setup(initial: Dump[], opts?: Parameters<typeof useDumpListSync>[1]) {
|
||||
function setup(
|
||||
initial: Dump[],
|
||||
opts?: Parameters<typeof useDumpListSync>[1],
|
||||
) {
|
||||
const ws = liveWS();
|
||||
const hook = renderHook(() => {
|
||||
const list = useList(initial);
|
||||
@@ -40,7 +49,10 @@ describe("useDumpListSync", () => {
|
||||
}
|
||||
|
||||
test("drops deleted dumps", () => {
|
||||
const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]);
|
||||
const { hook, emit } = setup([
|
||||
makeDump({ id: "a" }),
|
||||
makeDump({ id: "b" }),
|
||||
]);
|
||||
emit({ deletedDumpIds: new Set(["a"]) });
|
||||
expect(ids(hook.result.current.items)).toEqual(["b"]);
|
||||
});
|
||||
@@ -116,15 +128,25 @@ describe("usePlaylistListSync", () => {
|
||||
test("prepends a created playlist once", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a" })]);
|
||||
const p = makePlaylist({ id: "b" });
|
||||
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } });
|
||||
emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } });
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p },
|
||||
});
|
||||
emit({
|
||||
lastPlaylistEvent: {
|
||||
type: "created",
|
||||
playlistId: "b",
|
||||
playlist: { ...p },
|
||||
},
|
||||
});
|
||||
expect(ids(hook.result.current.items)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
||||
const p = makePlaylist({ id: "p", userId: "them" });
|
||||
const byOwner = setup([], { ownerId: "me" });
|
||||
byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
|
||||
byOwner.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(byOwner.hook.result.current.items).toEqual([]);
|
||||
|
||||
const priv = setup([]);
|
||||
@@ -138,7 +160,9 @@ describe("usePlaylistListSync", () => {
|
||||
expect(priv.hook.result.current.items).toEqual([]);
|
||||
|
||||
const followed = setup([], { noNewEntries: true });
|
||||
followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } });
|
||||
followed.emit({
|
||||
lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(followed.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -146,15 +170,27 @@ describe("usePlaylistListSync", () => {
|
||||
const p = makePlaylist({ id: "p", title: "one" });
|
||||
const { hook, emit } = setup([p]);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } },
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "two" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("two");
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } },
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, isPublic: false },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } },
|
||||
lastPlaylistEvent: {
|
||||
type: "updated",
|
||||
playlistId: "p",
|
||||
playlist: { ...p, title: "back" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("back");
|
||||
});
|
||||
@@ -163,7 +199,9 @@ describe("usePlaylistListSync", () => {
|
||||
const p = makePlaylist({ id: "p" });
|
||||
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
|
||||
const { hook, emit } = setup([], opts);
|
||||
emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } });
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -182,7 +220,11 @@ describe("usePlaylistListSync", () => {
|
||||
test("dumps_updated sets the count", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] },
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y", "z"],
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(3);
|
||||
});
|
||||
@@ -190,7 +232,12 @@ describe("usePlaylistListSync", () => {
|
||||
test("dumps_updated counts the members this viewer can't see too", () => {
|
||||
const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y"], dumpCount: 4 },
|
||||
lastPlaylistEvent: {
|
||||
type: "dumps_updated",
|
||||
playlistId: "a",
|
||||
dumpIds: ["x", "y"],
|
||||
dumpCount: 4,
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].dumpCount).toBe(4);
|
||||
});
|
||||
@@ -230,7 +277,13 @@ describe("usePositionAwareSync", () => {
|
||||
const hook = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Playlist | null>(null);
|
||||
usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic,
|
||||
);
|
||||
return { items, setItems, setEvent };
|
||||
});
|
||||
const c = initial[2];
|
||||
@@ -240,7 +293,9 @@ describe("usePositionAwareSync", () => {
|
||||
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
|
||||
// Nothing saved any more: a second public event is left alone.
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" }));
|
||||
act(() =>
|
||||
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
|
||||
);
|
||||
expect(ids(hook.result.current.items)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ function wrapperFor(initial: AuthResponse | null) {
|
||||
};
|
||||
}
|
||||
|
||||
const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
|
||||
const stored = () =>
|
||||
JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null");
|
||||
|
||||
describe("useAuth", () => {
|
||||
test("exposes the session", () => {
|
||||
@@ -98,7 +99,9 @@ describe("authFetch", () => {
|
||||
method: "POST",
|
||||
body: new FormData(),
|
||||
});
|
||||
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false);
|
||||
expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("no Authorization header when signed out", async () => {
|
||||
@@ -135,7 +138,12 @@ describe("authFetch", () => {
|
||||
|
||||
describe("authUpload", () => {
|
||||
class FakeXHR {
|
||||
static last: FakeXHR;
|
||||
static last: FakeXHR | undefined;
|
||||
/** The request just made; a test that expects one fails without it. */
|
||||
static get current(): FakeXHR {
|
||||
if (!FakeXHR.last) throw new Error("No XMLHttpRequest was made");
|
||||
return FakeXHR.last;
|
||||
}
|
||||
method = "";
|
||||
url = "";
|
||||
headers: Record<string, string> = {};
|
||||
@@ -196,7 +204,7 @@ describe("authUpload", () => {
|
||||
body,
|
||||
(f) => progress.push(f),
|
||||
);
|
||||
const xhr = FakeXHR.last;
|
||||
const xhr = FakeXHR.current;
|
||||
expect(xhr.method).toBe("POST");
|
||||
expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`);
|
||||
expect(xhr.body).toBe(body);
|
||||
@@ -215,9 +223,14 @@ describe("authUpload", () => {
|
||||
const { result } = renderHook(useAuth, {
|
||||
wrapper: wrapperFor(makeSession()),
|
||||
});
|
||||
const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT");
|
||||
expect(FakeXHR.last.method).toBe("PUT");
|
||||
FakeXHR.last.error();
|
||||
const promise = result.current.authUpload(
|
||||
"u",
|
||||
new FormData(),
|
||||
() => {},
|
||||
"PUT",
|
||||
);
|
||||
expect(FakeXHR.current.method).toBe("PUT");
|
||||
FakeXHR.current.error();
|
||||
await expect(promise).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
@@ -227,14 +240,14 @@ describe("authUpload", () => {
|
||||
wrapper: wrapperFor(makeSession()),
|
||||
});
|
||||
const promise = result.current.authUpload("u", new FormData(), () => {});
|
||||
act(() => FakeXHR.last.respond(401, ""));
|
||||
act(() => FakeXHR.current.respond(401, ""));
|
||||
await promise;
|
||||
await waitFor(() => expect(result.current.user).toBeNull());
|
||||
});
|
||||
|
||||
test("an expired token short-circuits", async () => {
|
||||
vi.stubGlobal("XMLHttpRequest", FakeXHR);
|
||||
FakeXHR.last = undefined as unknown as FakeXHR;
|
||||
FakeXHR.last = undefined;
|
||||
const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) };
|
||||
const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) });
|
||||
const res = await act(() =>
|
||||
@@ -258,4 +271,3 @@ describe("useRequiredAuth", () => {
|
||||
expect(result.current.user.username).toBe("z");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,23 +22,40 @@ const owner = makeSession({ id: "owner", username: "owner" });
|
||||
const other = makeSession({ id: "other", username: "other" });
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
|
||||
function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) {
|
||||
function dumpRoutes(
|
||||
dump = makeDump({
|
||||
id: "d1",
|
||||
slug: "a-find",
|
||||
title: "A find",
|
||||
userId: "owner",
|
||||
comment: "Because **reasons**",
|
||||
categoryIds: ["c1"],
|
||||
}),
|
||||
) {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })));
|
||||
route(
|
||||
"GET /api/users/by-id/:id",
|
||||
() => wire(makeUser({ id: "owner", username: "owner" })),
|
||||
);
|
||||
route("GET /api/dumps/:id/comments", () => []);
|
||||
route("GET /api/dumps/:id/related", () => []);
|
||||
route("GET /api/dumps/:id/playlists", () => []);
|
||||
return dump;
|
||||
}
|
||||
|
||||
function openDump(session: ReturnType<typeof makeSession> | null = null, url = "/dumps/a-find") {
|
||||
function openDump(
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
url = "/dumps/a-find",
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Dump />, {
|
||||
route: url,
|
||||
path: "/dumps/:selectedDump",
|
||||
session,
|
||||
categories: [music],
|
||||
extraRoutes: <Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />,
|
||||
extraRoutes: (
|
||||
<Route path="/dumps/:selectedDump/edit" element={<p>edit page</p>} />
|
||||
),
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome();
|
||||
@@ -49,9 +66,16 @@ describe("Dump page", () => {
|
||||
test("shows the dump, its author, categories and note", async () => {
|
||||
dumpRoutes();
|
||||
openDump();
|
||||
expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner");
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music");
|
||||
expect(await screen.findByRole("heading", { name: "A find" }))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute(
|
||||
"href",
|
||||
"/music",
|
||||
);
|
||||
expect(screen.getByText("reasons").tagName).toBe("STRONG");
|
||||
await waitFor(() => expect(document.title).toMatch(/^A find · /));
|
||||
expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find");
|
||||
@@ -63,21 +87,38 @@ describe("Dump page", () => {
|
||||
route("GET /api/dumps/:id/related", () => []);
|
||||
route("GET /api/dumps/:id/playlists", () => []);
|
||||
openDump();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found");
|
||||
expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Dump not found",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "← Back to all dumps" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner can rename it in place, following the new slug", async () => {
|
||||
dumpRoutes();
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" })));
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
() =>
|
||||
wire(
|
||||
makeDump({
|
||||
id: "d1",
|
||||
slug: "a-better-find",
|
||||
title: "A better find",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
||||
const input = screen.getByDisplayValue("A find");
|
||||
await user.clear(input);
|
||||
await user.type(input, "A better find{Enter}");
|
||||
expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "A better find" }))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find"));
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" });
|
||||
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
|
||||
title: "A better find",
|
||||
});
|
||||
});
|
||||
|
||||
test("a refused rename says why; Escape backs out", async () => {
|
||||
@@ -96,7 +137,8 @@ describe("Dump page", () => {
|
||||
const { user } = openDump(other);
|
||||
await user.click(await screen.findByRole("heading", { name: "A find" }));
|
||||
expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner gets an Edit link", async () => {
|
||||
@@ -116,33 +158,54 @@ describe("Dump page", () => {
|
||||
});
|
||||
|
||||
test("votes go through the socket", async () => {
|
||||
dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }));
|
||||
dumpRoutes(
|
||||
makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }),
|
||||
);
|
||||
const { user, socket } = openDump(other);
|
||||
await user.click(await screen.findByRole("button", { name: "Upvote" }));
|
||||
expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" });
|
||||
expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3");
|
||||
expect(screen.getByRole("button", { name: "Remove vote" }))
|
||||
.toHaveTextContent("3");
|
||||
});
|
||||
|
||||
test("comments load, and live ones arrive", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/comments",
|
||||
() => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]),
|
||||
);
|
||||
const { socket } = openDump(other);
|
||||
await screen.findByText("first");
|
||||
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) });
|
||||
socket.receive({
|
||||
type: "comment_created",
|
||||
comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })),
|
||||
});
|
||||
expect(screen.getByText("second")).toBeInTheDocument();
|
||||
socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" });
|
||||
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
||||
// Another dump's comment isn't shown here.
|
||||
socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) });
|
||||
socket.receive({
|
||||
type: "comment_created",
|
||||
comment: wire(
|
||||
makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" }),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("elsewhere")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("related dumps and the playlists it's in", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]));
|
||||
route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/related",
|
||||
() => wire([makeDump({ title: "Links here" })]),
|
||||
);
|
||||
route(
|
||||
"GET /api/dumps/:id/playlists",
|
||||
() => wire([makePlaylist({ title: "Best of" })]),
|
||||
);
|
||||
openDump();
|
||||
expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Related" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Links here")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Best of")).toBeInTheDocument();
|
||||
});
|
||||
@@ -151,16 +214,26 @@ describe("Dump page", () => {
|
||||
const d = dumpRoutes();
|
||||
const { socket } = openDump();
|
||||
await screen.findByRole("heading", { name: "A find" });
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) });
|
||||
expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...d, title: "Retitled" }),
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Retitled" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adding to a playlist", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => [
|
||||
{ playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true },
|
||||
{
|
||||
playlist: wire(makePlaylist({ id: "p", title: "Mine" })),
|
||||
hasDump: true,
|
||||
},
|
||||
]);
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
||||
const row = await screen.findByRole("checkbox", { name: /Mine/ });
|
||||
@@ -172,24 +245,44 @@ describe("Dump page", () => {
|
||||
test("a new playlist can be made from there, with the dump in it", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/playlists/by-dump/:id/memberships", () => []);
|
||||
route("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title })));
|
||||
route(
|
||||
"POST /api/playlists",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "np",
|
||||
title: (req.body as { title: string }).title,
|
||||
}),
|
||||
),
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
const { user } = openDump(owner);
|
||||
await user.click(await screen.findByRole("button", { name: "+ Playlist" }));
|
||||
await user.click(await screen.findByRole("button", { name: "+ New playlist" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "+ New playlist" }),
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}");
|
||||
const row = await screen.findByRole("checkbox", { name: /Fresh/ });
|
||||
expect(row).toHaveAttribute("aria-checked", "true");
|
||||
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1");
|
||||
expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/np/dumps/d1",
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolls to a linked comment", async () => {
|
||||
dumpRoutes();
|
||||
route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]));
|
||||
route(
|
||||
"GET /api/dumps/:id/comments",
|
||||
() => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]),
|
||||
);
|
||||
const scroll = vi.spyOn(Element.prototype, "scrollIntoView");
|
||||
openDump(null, "/dumps/a-find#comment-c9");
|
||||
await screen.findByText("linked");
|
||||
await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight"));
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById("comment-c9")).toHaveClass(
|
||||
"comment-node--highlight",
|
||||
)
|
||||
);
|
||||
expect(scroll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -199,7 +292,9 @@ describe("DumpEdit page", () => {
|
||||
route("GET /api/dumps/:id", () => wire(dump));
|
||||
const user = userEvent.setup();
|
||||
renderApp(
|
||||
<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>,
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: `/dumps/${dump.slug ?? dump.id}/edit`,
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
@@ -226,11 +321,18 @@ describe("DumpEdit page", () => {
|
||||
url: "https://a.test/",
|
||||
comment: "old why",
|
||||
categoryIds: [],
|
||||
richContent: { type: "link", url: "https://a.test/", title: "Page's own title" },
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "https://a.test/",
|
||||
title: "Page's own title",
|
||||
},
|
||||
});
|
||||
|
||||
test("saves title, note, categories and visibility", async () => {
|
||||
route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })));
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "d1", slug: "new-title" })),
|
||||
);
|
||||
const user = openEdit(urlDump());
|
||||
const title = await screen.findByLabelText("Title");
|
||||
expect(document.title).toMatch(/^Editing A find/);
|
||||
@@ -264,7 +366,8 @@ describe("DumpEdit page", () => {
|
||||
const title = await screen.findByLabelText("Title");
|
||||
await user.click(screen.getByRole("button", { name: "Reset to default" }));
|
||||
expect(title).toHaveValue("Page's own title");
|
||||
expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Reset to default" }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
test("a refused save stays on the form", async () => {
|
||||
@@ -277,20 +380,51 @@ describe("DumpEdit page", () => {
|
||||
});
|
||||
|
||||
test("refreshing the metadata", async () => {
|
||||
route("POST /api/dumps/:id/refresh-metadata", () =>
|
||||
wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } }));
|
||||
route(
|
||||
"POST /api/dumps/:id/refresh-metadata",
|
||||
() =>
|
||||
wire({
|
||||
...urlDump(),
|
||||
richContent: {
|
||||
type: "link",
|
||||
url: "https://a.test/",
|
||||
title: "Fresh title",
|
||||
description: "New blurb",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Refresh metadata" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Refresh metadata" }),
|
||||
);
|
||||
expect(await screen.findByText("New blurb")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("replacing a file sends a visibility change in the same request", async () => {
|
||||
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
|
||||
route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }));
|
||||
const fileDump = makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
title: "Song",
|
||||
userId: "owner",
|
||||
fileName: "song.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
isPrivate: false,
|
||||
});
|
||||
route(
|
||||
"PUT /api/dumps/:id/file",
|
||||
() => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }),
|
||||
);
|
||||
const user = openEdit(fileDump);
|
||||
await screen.findByText("song.mp3");
|
||||
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'.fdz input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await screen.findByText("dump page");
|
||||
@@ -302,15 +436,31 @@ describe("DumpEdit page", () => {
|
||||
});
|
||||
|
||||
test("a failed file replacement stays on the form, with nothing half-saved", async () => {
|
||||
const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false });
|
||||
const fileDump = makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
title: "Song",
|
||||
userId: "owner",
|
||||
fileName: "song.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
isPrivate: false,
|
||||
});
|
||||
route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)"));
|
||||
const user = openEdit(fileDump);
|
||||
await screen.findByText("song.mp3");
|
||||
const input = document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'.fdz input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Private" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("File too large (max 50 MB)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("File too large (max 50 MB)"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText("dump page")).not.toBeInTheDocument();
|
||||
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
|
||||
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
||||
@@ -319,36 +469,66 @@ describe("DumpEdit page", () => {
|
||||
test("deleting asks, then goes home", async () => {
|
||||
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Delete dump" }),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete dump",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("home");
|
||||
expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!))
|
||||
expect(
|
||||
JSON.parse(
|
||||
document.querySelector('[data-testid="location"]')!.getAttribute(
|
||||
"data-state",
|
||||
)!,
|
||||
),
|
||||
)
|
||||
.toEqual({ deletedDumpId: "d1" });
|
||||
});
|
||||
|
||||
test("a delete that can't reach the server says so", async () => {
|
||||
route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch"));
|
||||
const user = openEdit(urlDump());
|
||||
await user.click(await screen.findByRole("button", { name: "Delete dump" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Delete dump" }),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete dump",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
/Could not connect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a thumbnail that won't upload says so", async () => {
|
||||
route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400));
|
||||
const user = openEdit(urlDump());
|
||||
await screen.findByLabelText("Title");
|
||||
const picker = document.querySelector<HTMLInputElement>('.dump-edit-thumbnail-row input[type="file"]')!;
|
||||
await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }));
|
||||
const picker = document.querySelector<HTMLInputElement>(
|
||||
'.dump-edit-thumbnail-row input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
picker,
|
||||
new File([new Uint8Array(3)], "t.png", { type: "image/png" }),
|
||||
);
|
||||
expect(await screen.findByText("Not an image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("guests are sent to log in", () => {
|
||||
renderApp(<RestrictedLoggedIn><DumpEdit /></RestrictedLoggedIn>, {
|
||||
route: "/dumps/x/edit",
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
});
|
||||
renderApp(
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>,
|
||||
{
|
||||
route: "/dumps/x/edit",
|
||||
path: "/dumps/:selectedDump/edit",
|
||||
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useParams } from "react-router";
|
||||
import { Index } from "../../pages/Index.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makeSession,
|
||||
page,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
@@ -39,8 +45,18 @@ const titles = () =>
|
||||
.map((a) => a.textContent);
|
||||
|
||||
describe("Index feeds", () => {
|
||||
const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) });
|
||||
const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) });
|
||||
const older = makeDump({
|
||||
id: "old",
|
||||
title: "Old but loved",
|
||||
voteCount: 30,
|
||||
createdAt: new Date(Date.now() - 5 * HOUR),
|
||||
});
|
||||
const newer = makeDump({
|
||||
id: "new",
|
||||
title: "Brand new",
|
||||
voteCount: 0,
|
||||
createdAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
test("New sorts by date, Hot by score", async () => {
|
||||
route("GET /api/dumps/", () => page([older, newer]));
|
||||
@@ -72,7 +88,9 @@ describe("Index feeds", () => {
|
||||
test("a failed load shows an error and disables posting", async () => {
|
||||
route("GET /api/dumps/", () => fail("boom", 500));
|
||||
setup("/~/new", { session: makeSession() });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Failed to load",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -81,18 +99,25 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([]));
|
||||
setup("/~/new", { session });
|
||||
await screen.findByText("No dumps yet. Be the first!");
|
||||
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolling to the end loads the next page", async () => {
|
||||
route("GET /api/dumps/", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([newer], true)
|
||||
: page([older], false));
|
||||
route(
|
||||
"GET /api/dumps/",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([newer], true)
|
||||
: page([older], false),
|
||||
);
|
||||
setup("/~/new");
|
||||
await screen.findByText("Brand new");
|
||||
const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never));
|
||||
const observer = FakeIntersectionObserver.instances.findLast((o) =>
|
||||
o.elements.size > 0
|
||||
)!;
|
||||
act(() => observer.report(true));
|
||||
await screen.findByText("Old but loved");
|
||||
expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2");
|
||||
});
|
||||
@@ -101,7 +126,12 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
const { socket } = setup("/~/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(
|
||||
makeDump({ id: "live", title: "Just now", createdAt: new Date() }),
|
||||
),
|
||||
});
|
||||
expect(titles()[0]).toBe("Just now");
|
||||
socket.receive({ type: "dump_deleted", dumpId: "old" });
|
||||
expect(screen.queryByText("Old but loved")).not.toBeInTheDocument();
|
||||
@@ -111,8 +141,16 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
const { socket } = setup("/~/new");
|
||||
await screen.findByText("Old but loved");
|
||||
socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" });
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31");
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "old",
|
||||
voteCount: 31,
|
||||
voterId: "x",
|
||||
action: "cast",
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent(
|
||||
"31",
|
||||
);
|
||||
});
|
||||
|
||||
test("guests can't vote", async () => {
|
||||
@@ -134,13 +172,30 @@ describe("Index feeds", () => {
|
||||
});
|
||||
|
||||
test("a category scopes the feed, and live arrivals to it", async () => {
|
||||
route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]));
|
||||
route(
|
||||
"GET /api/categories/:slug/dumps",
|
||||
() => page([makeDump({ title: "A song", categoryIds: ["c-music"] })]),
|
||||
);
|
||||
const { socket } = setup("/music/new");
|
||||
await screen.findByText("A song");
|
||||
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music");
|
||||
expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe(
|
||||
"music",
|
||||
);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Music · /));
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) });
|
||||
socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) });
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "dump_created",
|
||||
dump: wire(
|
||||
makeDump({
|
||||
title: "Another song",
|
||||
categoryIds: ["c-music"],
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Another song")).toBeInTheDocument();
|
||||
});
|
||||
@@ -148,7 +203,8 @@ describe("Index feeds", () => {
|
||||
test("an unknown category is a 404", async () => {
|
||||
route("GET /api/categories/:slug/dumps", () => fail("nope", 404));
|
||||
setup("/nonsense/new");
|
||||
expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "404" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a guest on the followed tab gets the default feed, not a blank page", async () => {
|
||||
@@ -161,22 +217,32 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([]));
|
||||
route("GET /api/preview", () => ({ reached: false, richContent: null }));
|
||||
route("GET /api/dumps/by-url", () => []);
|
||||
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() });
|
||||
setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", {
|
||||
session: makeSession(),
|
||||
});
|
||||
const dialog = await screen.findByRole("dialog", { name: "New dump" });
|
||||
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/");
|
||||
expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue(
|
||||
"https://shared.test/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Followed feed", () => {
|
||||
test("from people and from playlists", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })]));
|
||||
route(
|
||||
"GET /api/follows/feed/users",
|
||||
() => page([makeDump({ title: "By a friend" })]),
|
||||
);
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
const { user } = setup("/~/followed", { session });
|
||||
await screen.findByText("By a friend");
|
||||
await user.click(screen.getByRole("button", { name: "From playlists" }));
|
||||
expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument();
|
||||
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`);
|
||||
expect(
|
||||
screen.getByText("Follow some public playlists to see their dumps here."),
|
||||
).toBeInTheDocument();
|
||||
expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization"))
|
||||
.toBe(`Bearer ${session.token}`);
|
||||
});
|
||||
|
||||
test("a category narrows it", async () => {
|
||||
@@ -184,7 +250,9 @@ describe("Followed feed", () => {
|
||||
route("GET /api/follows/feed/playlists", () => page([]));
|
||||
setup("/music/followed", { session: makeSession() });
|
||||
await screen.findByText("Follow some users to see their dumps here.");
|
||||
expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music");
|
||||
expect(
|
||||
calls("GET /api/follows/feed/users")[0].url.searchParams.get("category"),
|
||||
).toBe("music");
|
||||
});
|
||||
|
||||
test("a server error reads as one, not as a lost connection", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Notifications } from "../../pages/Notifications.tsx";
|
||||
import type { Notification } from "../../model.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
@@ -12,33 +12,122 @@ const me = makeSession({ id: "me" });
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function open(items: Notification[], { hasMore = false } = {}) {
|
||||
route("GET /api/notifications", (req) =>
|
||||
req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([]));
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page(items, hasMore)
|
||||
: page([]),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length });
|
||||
socket.welcome({
|
||||
unreadNotificationCount: items.filter((n) => !n.read).length,
|
||||
});
|
||||
return { user, socket };
|
||||
}
|
||||
|
||||
const n = {
|
||||
follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }),
|
||||
playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }),
|
||||
posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }),
|
||||
added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }),
|
||||
upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }),
|
||||
commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }),
|
||||
liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }),
|
||||
mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }),
|
||||
mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }),
|
||||
mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }),
|
||||
follow: makeNotification({
|
||||
type: "user_followed",
|
||||
data: { followerId: "f", followerUsername: "fan" },
|
||||
}),
|
||||
playlistFollow: makeNotification({
|
||||
type: "playlist_followed",
|
||||
data: {
|
||||
followerId: "f",
|
||||
followerUsername: "fan",
|
||||
playlistId: "p1",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
posted: makeNotification({
|
||||
type: "user_dump_posted",
|
||||
data: {
|
||||
dumperId: "a",
|
||||
dumperUsername: "ann",
|
||||
dumpId: "d1",
|
||||
dumpTitle: "Song",
|
||||
},
|
||||
}),
|
||||
added: makeNotification({
|
||||
type: "playlist_dump_added",
|
||||
data: {
|
||||
dumpId: "d2",
|
||||
dumpTitle: "Tune",
|
||||
playlistId: "p",
|
||||
playlistTitle: "Mix",
|
||||
},
|
||||
}),
|
||||
upvoted: makeNotification({
|
||||
type: "dump_upvoted",
|
||||
data: {
|
||||
voterId: "v",
|
||||
voterUsername: "bob",
|
||||
dumpId: "d3",
|
||||
dumpTitle: "Pic",
|
||||
},
|
||||
}),
|
||||
commented: makeNotification({
|
||||
type: "dump_commented",
|
||||
data: {
|
||||
commenterId: "c",
|
||||
commenterUsername: "cy",
|
||||
commentId: "c1",
|
||||
dumpId: "d4",
|
||||
dumpTitle: "Post",
|
||||
},
|
||||
}),
|
||||
liked: makeNotification({
|
||||
type: "comment_liked",
|
||||
data: {
|
||||
likerId: "l",
|
||||
likerUsername: "lu",
|
||||
commentId: "c2",
|
||||
dumpId: "d5",
|
||||
dumpTitle: "Thing",
|
||||
},
|
||||
}),
|
||||
mentionComment: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "comment",
|
||||
contextId: "c3",
|
||||
contextTitle: "",
|
||||
dumpId: "d6",
|
||||
},
|
||||
}),
|
||||
mentionChat: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "chat",
|
||||
contextId: "x",
|
||||
contextTitle: "",
|
||||
},
|
||||
}),
|
||||
mentionPlaylist: makeNotification({
|
||||
type: "user_mentioned",
|
||||
data: {
|
||||
mentionerId: "m",
|
||||
mentionerUsername: "mo",
|
||||
contextType: "playlist",
|
||||
contextId: "p9",
|
||||
contextTitle: "Faves",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
describe("Notifications", () => {
|
||||
test("every kind reads right and links to its subject", async () => {
|
||||
open(Object.values(n));
|
||||
const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href");
|
||||
const link = async (text: RegExp) =>
|
||||
(await screen.findByText(text)).closest("a")!.getAttribute("href");
|
||||
expect(await link(/started following you/)).toBe("/users/fan");
|
||||
expect(await link(/followed your playlist/)).toBe("/playlists/p1");
|
||||
expect(await link(/^posted$/)).toBe("/dumps/d1");
|
||||
@@ -46,8 +135,14 @@ describe("Notifications", () => {
|
||||
expect(await link(/^upvoted$/)).toBe("/dumps/d3");
|
||||
expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1");
|
||||
expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2");
|
||||
const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href"));
|
||||
expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]);
|
||||
const mentions = screen.getAllByText(/mentioned you in/).map((el) =>
|
||||
el.closest("a")!.getAttribute("href")
|
||||
);
|
||||
expect(mentions).toEqual([
|
||||
"/dumps/d6#comment-c3",
|
||||
"/notifications?chat=open",
|
||||
"/playlists/p9",
|
||||
]);
|
||||
expect(screen.getByText("a comment")).toBeInTheDocument();
|
||||
expect(screen.getByText("the chat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Faves")).toBeInTheDocument();
|
||||
@@ -59,28 +154,37 @@ describe("Notifications", () => {
|
||||
makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }),
|
||||
makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }),
|
||||
]);
|
||||
expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Today" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Earlier" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opening them marks them read and clears the bell, but still shows which were new", async () => {
|
||||
open([n.follow, { ...n.upvoted, read: true }]);
|
||||
await screen.findByText(/started following you/);
|
||||
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
expect(document.querySelectorAll(".notif-dot")).toHaveLength(1);
|
||||
expect(screen.getByText(/started following you/).closest("li"))
|
||||
.toHaveClass("notification-item--unread");
|
||||
expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new");
|
||||
expect(document.querySelector(".notifications-unread-pill"))
|
||||
.toHaveTextContent("1 new");
|
||||
});
|
||||
|
||||
test("a list that loaded stays shown if marking it read fails", async () => {
|
||||
route("POST /api/notifications/read-all", () => new TypeError("offline"));
|
||||
open([n.follow]);
|
||||
expect(await screen.findByText(/started following you/)).toBeInTheDocument();
|
||||
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1));
|
||||
expect(await screen.findByText(/started following you/))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/started following you/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -102,31 +206,45 @@ describe("Notifications", () => {
|
||||
test("a new one arrives at the top while the page is open", async () => {
|
||||
const { socket } = open([n.follow]);
|
||||
await screen.findByText(/started following you/);
|
||||
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.upvoted),
|
||||
});
|
||||
const items = document.querySelectorAll(".notification-item");
|
||||
expect(items[0]).toHaveTextContent("bob upvoted Pic");
|
||||
expect(items[0]).toHaveClass("notification-item--unread");
|
||||
// Seen as it arrives: marked read, so the bell doesn't count it.
|
||||
await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(2));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument()
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("one that arrives while the list is loading isn't lost", async () => {
|
||||
let answer!: (r: Response) => void;
|
||||
route("GET /api/notifications", () => new Promise<Response>((r) => (answer = r)));
|
||||
const list = deferred<unknown>();
|
||||
route("GET /api/notifications", () => list.promise);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({ unreadNotificationCount: 1 });
|
||||
await waitFor(() => expect(calls("GET /api/notifications")).toHaveLength(1));
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/notifications")).toHaveLength(1)
|
||||
);
|
||||
// Created after the list was read, so the list won't have it — but the
|
||||
// read-all after the load will mark it read.
|
||||
socket.receive({ type: "notification_created", notification: wire(n.upvoted) });
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.upvoted),
|
||||
});
|
||||
// And one the list does have, sent while it was on its way: shown once.
|
||||
socket.receive({ type: "notification_created", notification: wire(n.follow) });
|
||||
answer(page([n.follow]));
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.follow),
|
||||
});
|
||||
list.resolve(page([n.follow]));
|
||||
await screen.findByText(/started following you/);
|
||||
const items = document.querySelectorAll(".notification-item");
|
||||
expect(items).toHaveLength(2);
|
||||
@@ -135,15 +253,19 @@ describe("Notifications", () => {
|
||||
});
|
||||
|
||||
test("load more", async () => {
|
||||
route("GET /api/notifications", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([n.follow], true)
|
||||
: page([n.upvoted], false));
|
||||
route(
|
||||
"GET /api/notifications",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([n.follow], true)
|
||||
: page([n.upvoted], false),
|
||||
);
|
||||
route("POST /api/notifications/read-all", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me });
|
||||
await user.click(await screen.findByRole("button", { name: "Load more" }));
|
||||
await screen.findByText(/^upvoted$/);
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route } from "react-router";
|
||||
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
|
||||
@@ -19,7 +25,11 @@ const dumps = () => [
|
||||
];
|
||||
|
||||
function open(
|
||||
{ session = owner as ReturnType<typeof makeSession> | null, playlist = {} as Partial<Playlist>, items = dumps() as Dump[] } = {},
|
||||
{
|
||||
session = owner as ReturnType<typeof makeSession> | null,
|
||||
playlist = {} as Partial<Playlist>,
|
||||
items = dumps() as Dump[],
|
||||
} = {},
|
||||
) {
|
||||
const pl = makePlaylist({
|
||||
id: "p1",
|
||||
@@ -44,30 +54,44 @@ function open(
|
||||
}
|
||||
|
||||
const titles = () =>
|
||||
Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
|
||||
Array.from(document.querySelectorAll(".dump-card-title")).map((a) =>
|
||||
a.textContent
|
||||
);
|
||||
|
||||
describe("PlaylistDetail", () => {
|
||||
test("shows the playlist and its dumps in order", async () => {
|
||||
open({ session: null });
|
||||
expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Mix" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("tunes").tagName).toBe("STRONG");
|
||||
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner");
|
||||
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/owner",
|
||||
);
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
await waitFor(() => expect(document.title).toMatch(/^Mix · /));
|
||||
expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("not found", async () => {
|
||||
route("GET /api/playlists/:id", () => fail("nope", 404));
|
||||
renderApp(<PlaylistDetail />, { route: "/playlists/x", path: "/playlists/:playlistId" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found");
|
||||
renderApp(<PlaylistDetail />, {
|
||||
route: "/playlists/x",
|
||||
path: "/playlists/:playlistId",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Playlist not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("a visitor can follow a public playlist, not edit it", async () => {
|
||||
open({ session: visitor });
|
||||
await screen.findByRole("heading", { name: "Mix" });
|
||||
expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Follow playlist" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the cover is versioned on the last edit", async () => {
|
||||
@@ -80,8 +104,19 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("editing the description shows at once, without waiting for the socket", async () => {
|
||||
route("PUT /api/playlists/:id", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
description: "Better tunes",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const box = screen.getByPlaceholderText("Description (optional)");
|
||||
@@ -89,13 +124,26 @@ describe("PlaylistDetail", () => {
|
||||
await user.type(box, "Better tunes");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true });
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
||||
description: "Better tunes",
|
||||
isPublic: true,
|
||||
});
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
});
|
||||
|
||||
test("a new title moves to the new slug", async () => {
|
||||
route("PUT /api/playlists/:id", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "remix",
|
||||
title: "Remix",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const title = screen.getByPlaceholderText("Playlist title");
|
||||
@@ -107,14 +155,44 @@ describe("PlaylistDetail", () => {
|
||||
|
||||
test("a new cover shows once saved; a refused one says why", async () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
|
||||
route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" })));
|
||||
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true });
|
||||
route("POST /api/playlists/:id/image", () =>
|
||||
wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) })));
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
}),
|
||||
),
|
||||
);
|
||||
route("POST /api/playlists/:id/image", () => fail("Not an image", 400), {
|
||||
once: true,
|
||||
});
|
||||
route(
|
||||
"POST /api/playlists/:id/image",
|
||||
() =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
imageMime: "image/png",
|
||||
updatedAt: new Date(9000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const picker = document.querySelector<HTMLInputElement>('.img-picker input[type="file"]')!;
|
||||
await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" }));
|
||||
const picker = document.querySelector<HTMLInputElement>(
|
||||
'.img-picker input[type="file"]',
|
||||
)!;
|
||||
await user.upload(
|
||||
picker,
|
||||
new File([new Uint8Array(3)], "c.png", { type: "image/png" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -136,19 +214,36 @@ describe("PlaylistDetail", () => {
|
||||
test("removing a dump fades it out, with an undo", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
|
||||
route("PUT /api/playlists/:pid/order", () => ({}));
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b");
|
||||
await user.click(within(bravo).getByRole("button", { name: "Cancel removal" }));
|
||||
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] });
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
".playlist-dump-item",
|
||||
) as HTMLElement;
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
|
||||
"/api/playlists/mix/dumps/b",
|
||||
);
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Cancel removal" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["a", "b", "c"],
|
||||
});
|
||||
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
@@ -157,20 +252,31 @@ describe("PlaylistDetail", () => {
|
||||
});
|
||||
|
||||
test("a refused removal puts the dump back and says so", async () => {
|
||||
route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403));
|
||||
route(
|
||||
"DELETE /api/playlists/:pid/dumps/:did",
|
||||
() => fail("Forbidden", 403),
|
||||
);
|
||||
const { user } = open();
|
||||
await screen.findByText("Bravo");
|
||||
const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement;
|
||||
await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed");
|
||||
expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument();
|
||||
const bravo = screen.getByText("Bravo").closest(
|
||||
".playlist-dump-item",
|
||||
) as HTMLElement;
|
||||
await user.click(
|
||||
within(bravo).getByRole("button", { name: "Remove from playlist" }),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"couldn't be removed",
|
||||
);
|
||||
expect(within(bravo).getByRole("button", { name: "Remove from playlist" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("dragging reorders, and a refused reorder reloads the real order", async () => {
|
||||
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
|
||||
open();
|
||||
await screen.findByText("Charlie");
|
||||
const items = () => Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
|
||||
const items = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(".playlist-dump-item"));
|
||||
for (const el of items()) {
|
||||
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
|
||||
}
|
||||
@@ -179,18 +285,33 @@ describe("PlaylistDetail", () => {
|
||||
fireEvent.dragOver(c, { clientY: 90 });
|
||||
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
|
||||
fireEvent.dragEnd(items()[2]);
|
||||
await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1));
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] });
|
||||
await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2));
|
||||
await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]));
|
||||
await waitFor(() =>
|
||||
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
|
||||
dumpIds: ["b", "c", "a"],
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(calls("GET /api/playlists/:id")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])
|
||||
);
|
||||
});
|
||||
|
||||
test("deleting the playlist asks, then goes home", async () => {
|
||||
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:id",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -199,22 +320,44 @@ describe("PlaylistDetail", () => {
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||
expect(currentPath()).toBe("/playlists/mix");
|
||||
});
|
||||
|
||||
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
|
||||
route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" })));
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "d", title: "Delta" })),
|
||||
);
|
||||
const { socket } = open({ session: visitor });
|
||||
await screen.findByText("Alpha");
|
||||
socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"], dumpCount: 4 });
|
||||
socket.receive({
|
||||
type: "playlist_dumps_updated",
|
||||
playlistId: "p1",
|
||||
dumpIds: ["d", "a", "b", "c"],
|
||||
dumpCount: 4,
|
||||
});
|
||||
expect(await screen.findByText("Delta")).toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) });
|
||||
expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p1", title: "Renamed", userId: "owner" }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Renamed" }))
|
||||
.toBeInTheDocument();
|
||||
socket.receive({ type: "dump_deleted", dumpId: "a" });
|
||||
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" });
|
||||
socket.receive({
|
||||
type: "playlist_deleted",
|
||||
playlistId: "p1",
|
||||
userId: "owner",
|
||||
});
|
||||
expect(await screen.findByText("home")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,39 @@ import { act, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Search } from "../../pages/Search.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
function results(
|
||||
{ dumps = [] as ReturnType<typeof makeDump>[], hasMore = false, users = [] as ReturnType<typeof makeUser>[], playlists = [] as ReturnType<typeof makePlaylist>[] } = {},
|
||||
{
|
||||
dumps = [] as ReturnType<typeof makeDump>[],
|
||||
hasMore = false,
|
||||
users = [] as ReturnType<typeof makeUser>[],
|
||||
playlists = [] as ReturnType<typeof makePlaylist>[],
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore },
|
||||
dumps: {
|
||||
items: wire(dumps),
|
||||
total: dumps.length + (hasMore ? 1 : 0),
|
||||
hasMore,
|
||||
},
|
||||
users: wire(users),
|
||||
playlists: wire(playlists),
|
||||
};
|
||||
}
|
||||
|
||||
function open(url: string, session: ReturnType<typeof makeSession> | null = null) {
|
||||
function open(
|
||||
url: string,
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session });
|
||||
return user;
|
||||
@@ -44,7 +62,10 @@ describe("Search", () => {
|
||||
expect(req.headers.get("Authorization")).toMatch(/^Bearer /);
|
||||
await user.click(screen.getByRole("button", { name: "Users (1)" }));
|
||||
expect(currentPath()).toBe("/search/~/users?q=cat");
|
||||
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover");
|
||||
expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/catlover",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Playlists (1)" }));
|
||||
expect(screen.getByText("Cats")).toBeInTheDocument();
|
||||
});
|
||||
@@ -52,7 +73,8 @@ describe("Search", () => {
|
||||
test("nothing found", async () => {
|
||||
route("GET /api/search", () => results());
|
||||
const user = open("/search?q=zzz");
|
||||
expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No dumps match "zzz".'))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Users (0)" }));
|
||||
expect(screen.getByText('No users match "zzz".')).toBeInTheDocument();
|
||||
});
|
||||
@@ -66,45 +88,73 @@ describe("Search", () => {
|
||||
});
|
||||
|
||||
test("more dumps on scroll; a failed page keeps what's shown", async () => {
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
|
||||
{ once: true },
|
||||
);
|
||||
route("GET /api/search", () => new TypeError("offline"), { once: true });
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] }));
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "Second" })] }),
|
||||
);
|
||||
open("/search?q=x");
|
||||
await screen.findByText("First");
|
||||
const scroll = () => {
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never));
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) =>
|
||||
o.elements.size > 0
|
||||
)!;
|
||||
act(() => obs.report(true));
|
||||
};
|
||||
scroll();
|
||||
await act(() => new Promise((r) => setTimeout(r, 50)));
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
// Scrolling away and back tries again at once.
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!;
|
||||
act(() => obs.callback([{ isIntersecting: false } as IntersectionObserverEntry], obs as never));
|
||||
const obs = FakeIntersectionObserver.instances.findLast((o) =>
|
||||
o.elements.size > 0
|
||||
)!;
|
||||
act(() => obs.report(false));
|
||||
scroll();
|
||||
await screen.findByText("Second");
|
||||
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2");
|
||||
expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe(
|
||||
"2",
|
||||
);
|
||||
});
|
||||
|
||||
test("a later page that keeps failing isn't asked for in a loop", async () => {
|
||||
route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true });
|
||||
route(
|
||||
"GET /api/search",
|
||||
() => results({ dumps: [makeDump({ title: "First" })], hasMore: true }),
|
||||
{ once: true },
|
||||
);
|
||||
route("GET /api/search", () => fail("down", 500));
|
||||
// A short page: the sentinel is on screen and stays there.
|
||||
FakeIntersectionObserver.inView = true;
|
||||
open("/search?q=x");
|
||||
await screen.findByText("First");
|
||||
// In short steps: React holds what an act() schedules until it ends.
|
||||
for (let i = 0; i < 20; i++) await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
const pages = calls("GET /api/search").filter((c) => c.url.searchParams.get("page") !== "1");
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
}
|
||||
const pages = calls("GET /api/search").filter((c) =>
|
||||
c.url.searchParams.get("page") !== "1"
|
||||
);
|
||||
// Once at once, once more a second later; the next is 2s after that.
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(screen.getByText("First")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a new query replaces the results", async () => {
|
||||
route("GET /api/search", (req) =>
|
||||
results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] }));
|
||||
route(
|
||||
"GET /api/search",
|
||||
(req) =>
|
||||
results({
|
||||
dumps: [
|
||||
makeDump({ title: `About ${req.url.searchParams.get("q")}` }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const user = open("/search?q=one");
|
||||
await screen.findByText("About one");
|
||||
const [box] = screen.getAllByRole("searchbox");
|
||||
|
||||
@@ -3,7 +3,13 @@ import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UserPlaylists } from "../../pages/UserPlaylists.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makePlaylist, makeSession, makeUser, page, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
makeUser,
|
||||
page,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
@@ -12,12 +18,29 @@ const annSession = makeSession({ id: "ann", username: "ann" });
|
||||
|
||||
function open(session: ReturnType<typeof makeSession> | null = null) {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/playlists", () =>
|
||||
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true));
|
||||
route("GET /api/users/:u/followed-playlists", () =>
|
||||
page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })]));
|
||||
route(
|
||||
"GET /api/users/:u/playlists",
|
||||
() =>
|
||||
page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true),
|
||||
);
|
||||
route(
|
||||
"GET /api/users/:u/followed-playlists",
|
||||
() =>
|
||||
page([
|
||||
makePlaylist({
|
||||
id: "f1",
|
||||
title: "Someone's mix",
|
||||
userId: "bob",
|
||||
ownerUsername: "bob",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserPlaylists />, { route: "/users/ann/playlists", path: "/users/:username/playlists", session });
|
||||
renderApp(<UserPlaylists />, {
|
||||
route: "/users/ann/playlists",
|
||||
path: "/users/:username/playlists",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
@@ -25,15 +48,27 @@ describe("UserPlaylists", () => {
|
||||
test("created and followed playlists", async () => {
|
||||
open();
|
||||
expect(await screen.findByText("Her mix")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Created (1+)" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Followed (1)" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Someone's mix")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete playlist" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete playlist" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner creates one and it goes on top", async () => {
|
||||
route("POST /api/playlists", (req) =>
|
||||
wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" })));
|
||||
route(
|
||||
"POST /api/playlists",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "new",
|
||||
title: (req.body as { title: string }).title,
|
||||
userId: "ann",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: /New playlist/ }));
|
||||
@@ -42,16 +77,26 @@ describe("UserPlaylists", () => {
|
||||
await user.click(within(dialog).getByRole("button", { name: "Private" }));
|
||||
await user.click(within(dialog).getByRole("button", { name: "Create" }));
|
||||
expect(await screen.findByText("Road trip")).toBeInTheDocument();
|
||||
expect(calls("POST /api/playlists")[0].body).toEqual({ title: "Road trip", isPublic: false });
|
||||
expect(calls("POST /api/playlists")[0].body).toEqual({
|
||||
title: "Road trip",
|
||||
isPublic: false,
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the owner deletes one, after confirming", async () => {
|
||||
route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 }));
|
||||
route(
|
||||
"DELETE /api/playlists/:id",
|
||||
() => new Response(null, { status: 204 }),
|
||||
);
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
await screen.findByText("No playlists yet.");
|
||||
});
|
||||
|
||||
@@ -60,7 +105,11 @@ describe("UserPlaylists", () => {
|
||||
const { user } = open(annSession);
|
||||
await screen.findByText("Her mix");
|
||||
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete playlist",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||
expect(screen.getByText("Her mix")).toBeInTheDocument();
|
||||
});
|
||||
@@ -69,9 +118,24 @@ describe("UserPlaylists", () => {
|
||||
const { socket } = open();
|
||||
await screen.findByText("Her mix");
|
||||
socket.welcome();
|
||||
socket.receive({ type: "playlist_created", playlist: wire(makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" })) });
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByText("Brand new mix")).toBeInTheDocument();
|
||||
socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Her mix", userId: "ann", isPublic: false })) });
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
title: "Her mix",
|
||||
userId: "ann",
|
||||
isPublic: false,
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Her mix")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,17 @@ import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
|
||||
|
||||
const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" });
|
||||
const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" });
|
||||
const ann = makeUser({
|
||||
id: "ann",
|
||||
username: "ann",
|
||||
description: "I **dump** things",
|
||||
invitedByUsername: "root",
|
||||
});
|
||||
const annSession = makeSession({
|
||||
id: "ann",
|
||||
username: "ann",
|
||||
email: "ann@x.test",
|
||||
});
|
||||
|
||||
function open(
|
||||
{
|
||||
@@ -33,7 +42,10 @@ function open(
|
||||
route("GET /api/users/:u", () => wire(user));
|
||||
route("GET /api/users/:u/dumps", () => page(dumps));
|
||||
route("GET /api/users/:u/votes", () => page(votes));
|
||||
route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })]));
|
||||
route(
|
||||
"GET /api/users/:u/playlists",
|
||||
() => page([makePlaylist({ title: "Her mix", userId: "ann" })]),
|
||||
);
|
||||
const u = userEvent.setup();
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: url,
|
||||
@@ -47,14 +59,20 @@ function open(
|
||||
describe("UserPublicProfile", () => {
|
||||
test("header, description, and the dumps tab", async () => {
|
||||
open();
|
||||
expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root");
|
||||
expect(await screen.findByRole("heading", { name: "ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/root",
|
||||
);
|
||||
expect(screen.getByText("dump").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
expect(screen.getByText("She liked this")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps (1)" }))
|
||||
.toBeInTheDocument();
|
||||
await waitFor(() => expect(document.title).toMatch(/^ann · /));
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
@@ -62,8 +80,13 @@ describe("UserPublicProfile", () => {
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
route("GET /api/users/:u/votes", () => page([]));
|
||||
route("GET /api/users/:u/playlists", () => page([]));
|
||||
renderApp(<UserPublicProfile />, { route: "/users/nobody", path: "/users/:username/*" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("User not found");
|
||||
renderApp(<UserPublicProfile />, {
|
||||
route: "/users/nobody",
|
||||
path: "/users/:username/*",
|
||||
});
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"User not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("a role chip for moderators, and none for users", async () => {
|
||||
@@ -80,40 +103,61 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("followed: people and playlists, with more people on demand", async () => {
|
||||
route("GET /api/users/:u/followed-users", (req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "bob" })], true)
|
||||
: page([makeUser({ username: "cy" })]));
|
||||
route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })]));
|
||||
route(
|
||||
"GET /api/users/:u/followed-users",
|
||||
(req) =>
|
||||
req.url.searchParams.get("page") === "1"
|
||||
? page([makeUser({ username: "bob" })], true)
|
||||
: page([makeUser({ username: "cy" })]),
|
||||
);
|
||||
route(
|
||||
"GET /api/users/:u/followed-playlists",
|
||||
() => page([makePlaylist({ title: "Their mix" })]),
|
||||
);
|
||||
const { user } = open({ url: "/users/ann/~/followed" });
|
||||
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob");
|
||||
expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/bob",
|
||||
);
|
||||
expect(screen.getByText("Their mix")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("link", { name: /@cy/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("invitees as a tree", async () => {
|
||||
route("GET /api/users/:u/invitees", () => wire([
|
||||
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
|
||||
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
|
||||
]));
|
||||
route("GET /api/users/:u/invitees", () =>
|
||||
wire([
|
||||
{ id: "b", username: "bob", invitedById: "ann", createdAt: new Date() },
|
||||
{ id: "c", username: "cy", invitedById: "b", createdAt: new Date() },
|
||||
]));
|
||||
open({ url: "/users/ann/~/invitees" });
|
||||
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!;
|
||||
const bob = (await screen.findByRole("link", { name: /@bob/ })).closest(
|
||||
"li",
|
||||
)!;
|
||||
expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("visitors can follow; signed in, a settings tab is only on your own profile", async () => {
|
||||
open({ session: makeSession({ id: "bob" }) });
|
||||
expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "Follow ann" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Settings" })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("your own profile", () => {
|
||||
test("edit the description; clearing it really clears it", async () => {
|
||||
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement;
|
||||
const view = (await screen.findByText("dump")).closest(
|
||||
"[title='Edit description']",
|
||||
) as HTMLElement;
|
||||
view.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
const box = screen.getByPlaceholderText("Who am I?");
|
||||
@@ -124,35 +168,56 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("edit the email", async () => {
|
||||
route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) }));
|
||||
route(
|
||||
"PATCH /api/users/me",
|
||||
(req) => wire({ ...ann, ...(req.body as object) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
const input = screen.getByDisplayValue("ann@x.test");
|
||||
await user.clear(input);
|
||||
await user.type(input, "new@x.test{Enter}");
|
||||
expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test");
|
||||
expect(await screen.findByRole("button", { name: /new@x\.test/ }))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
|
||||
.toBe("new@x.test");
|
||||
});
|
||||
|
||||
test("a refused email says why", async () => {
|
||||
route("PATCH /api/users/me", () => fail("Email already in use", 409));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: /ann@x\.test/ }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /ann@x\.test/ }),
|
||||
);
|
||||
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Email already in use",
|
||||
);
|
||||
});
|
||||
|
||||
test("upload an avatar", async () => {
|
||||
route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }));
|
||||
route(
|
||||
"POST /api/avatars/me",
|
||||
() =>
|
||||
wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) }),
|
||||
);
|
||||
const { user } = open({ session: annSession });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
".avatar-change-overlay input",
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/ann?v=7000",
|
||||
)
|
||||
expect(document.querySelector(".profile-avatar-wrapper img"))
|
||||
.toHaveAttribute(
|
||||
"src",
|
||||
"http://localhost:8000/api/avatars/ann?v=7000",
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,21 +225,33 @@ describe("UserPublicProfile", () => {
|
||||
route("POST /api/avatars/me", () => fail("Not an image", 400));
|
||||
const { user } = open({ session: annSession });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
const input = document.querySelector<HTMLInputElement>(".avatar-change-overlay input")!;
|
||||
await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
".avatar-change-overlay input",
|
||||
)!;
|
||||
await user.upload(
|
||||
input,
|
||||
new File([new Uint8Array(3)], "me.png", { type: "image/png" }),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Not an image",
|
||||
);
|
||||
});
|
||||
|
||||
test("invite someone: a link to copy", async () => {
|
||||
route("POST /api/invites", () => ({ token: "tok123" }));
|
||||
const { user } = open({ session: annSession });
|
||||
await user.click(await screen.findByRole("button", { name: "+ Invite someone" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "+ Invite someone" }),
|
||||
);
|
||||
const link = await screen.findByRole("textbox", { name: "Invite link" });
|
||||
expect(link).toHaveValue(`${location.origin}/register?token=tok123`);
|
||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||
// user-event stands in for the clipboard.
|
||||
expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`);
|
||||
expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
|
||||
expect(await navigator.clipboard.readText()).toBe(
|
||||
`${location.origin}/register?token=tok123`,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copied!" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("your upvotes show before the live connection welcomes you", async () => {
|
||||
@@ -189,19 +266,34 @@ describe("UserPublicProfile", () => {
|
||||
const { socket } = open({ session: annSession });
|
||||
await screen.findByText("She liked this");
|
||||
socket.welcome({ myVotes: ["v1"] });
|
||||
socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 });
|
||||
expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading");
|
||||
socket.receive({
|
||||
type: "vote_ack",
|
||||
dumpId: "v1",
|
||||
action: "remove",
|
||||
voteCount: 0,
|
||||
});
|
||||
expect(screen.getByText("She liked this").closest(".dump-card"))
|
||||
.toHaveClass("dump-card--fading");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("settings: theme, colour scheme, default tab and sounds", async () => {
|
||||
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
|
||||
await user.click(await screen.findByRole("button", { name: "Brutalist" }));
|
||||
expect(document.documentElement.getAttribute("data-style")).toBe("brutalist");
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Brutalist" }),
|
||||
);
|
||||
expect(document.documentElement.getAttribute("data-style")).toBe(
|
||||
"brutalist",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Dark" }));
|
||||
expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-color-scheme")).toBe(
|
||||
"dark",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "New" }));
|
||||
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
||||
await user.click(screen.getByRole("button", { name: "Off" }));
|
||||
@@ -211,19 +303,45 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("change password keeps you signed in with the fresh tokens", async () => {
|
||||
route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" }));
|
||||
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
|
||||
await user.click(await screen.findByRole("button", { name: "Change password…" }));
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => ({ token: "fresh-token", mediaToken: "fresh-media" }),
|
||||
);
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(within(dialog).getByLabelText("Current password"), "oldpassword");
|
||||
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
|
||||
await user.type(within(dialog).getByLabelText("Confirm new password"), "different");
|
||||
expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument();
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Current password"),
|
||||
"oldpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"different",
|
||||
);
|
||||
expect(await within(dialog).findByText("Passwords do not match"))
|
||||
.toBeInTheDocument();
|
||||
await user.clear(within(dialog).getByLabelText("Confirm new password"));
|
||||
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Change password" }));
|
||||
expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token");
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: "Change password" }),
|
||||
);
|
||||
expect(await within(dialog).findByText("Password changed successfully."))
|
||||
.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
"fresh-token",
|
||||
);
|
||||
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
|
||||
currentPassword: "oldpassword",
|
||||
newPassword: "newpassword",
|
||||
@@ -231,14 +349,33 @@ describe("UserPublicProfile", () => {
|
||||
});
|
||||
|
||||
test("a wrong current password is reported", async () => {
|
||||
route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400));
|
||||
const { user } = open({ session: annSession, url: "/users/ann/~/settings" });
|
||||
await user.click(await screen.findByRole("button", { name: "Change password…" }));
|
||||
route(
|
||||
"POST /api/users/me/change-password",
|
||||
() => fail("Current password is incorrect", 400),
|
||||
);
|
||||
const { user } = open({
|
||||
session: annSession,
|
||||
url: "/users/ann/~/settings",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Change password…" }),
|
||||
);
|
||||
const dialog = screen.getByRole("dialog", { name: "Change password" });
|
||||
await user.type(within(dialog).getByLabelText("Current password"), "wrong");
|
||||
await user.type(within(dialog).getByLabelText("New password"), "newpassword");
|
||||
await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}");
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect");
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Current password"),
|
||||
"wrong",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("New password"),
|
||||
"newpassword",
|
||||
);
|
||||
await user.type(
|
||||
within(dialog).getByLabelText("Confirm new password"),
|
||||
"newpassword{Enter}",
|
||||
);
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"Current password is incorrect",
|
||||
);
|
||||
});
|
||||
|
||||
test("log out", async () => {
|
||||
@@ -250,25 +387,53 @@ describe("UserPublicProfile", () => {
|
||||
|
||||
describe("manage", () => {
|
||||
test("an admin can change someone's role", async () => {
|
||||
route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role }));
|
||||
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
|
||||
await user.click(await screen.findByRole("button", { name: "Moderator" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active"));
|
||||
expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role");
|
||||
expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator");
|
||||
route(
|
||||
"PATCH /api/users/:id/role",
|
||||
(req) => wire({ ...ann, role: (req.body as { role: string }).role }),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Moderator" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass(
|
||||
"active",
|
||||
)
|
||||
);
|
||||
expect(calls("PATCH /api/users/:id/role")[0].path).toBe(
|
||||
"/api/users/ann/role",
|
||||
);
|
||||
expect(document.querySelector(".profile-role-chip")).toHaveTextContent(
|
||||
"Moderator",
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused role change says why", async () => {
|
||||
route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400));
|
||||
const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" });
|
||||
route(
|
||||
"PATCH /api/users/:id/role",
|
||||
() => fail("Cannot demote the last admin", 400),
|
||||
);
|
||||
const { user } = open({
|
||||
session: makeSession({ id: "admin", role: "admin" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await user.click(await screen.findByRole("button", { name: "Admin" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Cannot demote the last admin",
|
||||
);
|
||||
});
|
||||
|
||||
test("non-admins get no manage tab", async () => {
|
||||
open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" });
|
||||
open({
|
||||
session: makeSession({ id: "mod", role: "moderator" }),
|
||||
url: "/users/ann/~/manage",
|
||||
});
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Manage" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Her dump")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -286,31 +451,66 @@ describe("UserPublicProfile", () => {
|
||||
return null;
|
||||
});
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
|
||||
await user.click(await screen.findByRole("button", { name: "Add category" }));
|
||||
const { user } = open({
|
||||
session: admin,
|
||||
url: "/users/ann/~/manage",
|
||||
user: { ...ann, role: "admin" },
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
|
||||
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"cinema",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" });
|
||||
expect(await screen.findByRole("button", { name: "Cinema" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
name: "Cinema",
|
||||
slug: "cinema",
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" }));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument());
|
||||
await user.click(
|
||||
within(screen.getByRole("alertdialog")).getByRole("button", {
|
||||
name: "Delete category",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Music" })).not
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused category is reported, and Escape closes the editor", async () => {
|
||||
route("GET /api/categories", () => []);
|
||||
route("POST /api/categories", () => fail("Slug already taken", 409));
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } });
|
||||
await user.click(await screen.findByRole("button", { name: "Add category" }));
|
||||
const { user } = open({
|
||||
session: admin,
|
||||
url: "/users/ann/~/manage",
|
||||
user: { ...ann, role: "admin" },
|
||||
});
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
|
||||
await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken");
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"x{Enter}",
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Slug already taken",
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Name" }),
|
||||
"{Escape}",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add category" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,19 +13,30 @@ const annSession = makeSession({ id: "ann", username: "ann" });
|
||||
|
||||
function openDumps(session: ReturnType<typeof makeSession> | null = null) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserDumps />, { route: "/users/ann/dumps", path: "/users/:username/dumps", session });
|
||||
renderApp(<UserDumps />, {
|
||||
route: "/users/ann/dumps",
|
||||
path: "/users/:username/dumps",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
describe("UserDumps", () => {
|
||||
test("lists a user's dumps under a header back to the profile", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/dumps", () => page([makeDump({ title: "Hers", userId: "ann" })]));
|
||||
route(
|
||||
"GET /api/users/:u/dumps",
|
||||
() => page([makeDump({ title: "Hers", userId: "ann" })]),
|
||||
);
|
||||
openDumps();
|
||||
expect(await screen.findByText("Hers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann");
|
||||
expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute(
|
||||
"href",
|
||||
"/users/ann",
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /New dump/ })).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("an unknown user", async () => {
|
||||
@@ -33,7 +44,8 @@ describe("UserDumps", () => {
|
||||
route("GET /api/users/:u/dumps", () => page([]));
|
||||
openDumps();
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← Back to profile" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "← Back to profile" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("on your own page: a new-dump button, and your dumps going private stay", async () => {
|
||||
@@ -43,43 +55,64 @@ describe("UserDumps", () => {
|
||||
const { user, socket } = openDumps(annSession);
|
||||
await screen.findByText("Mine");
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) });
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...mine, isPrivate: true }),
|
||||
});
|
||||
expect(screen.getByText("Mine")).toBeInTheDocument();
|
||||
route("GET /api/preview", () => ({ reached: false, richContent: null }));
|
||||
await user.click(screen.getByRole("button", { name: /New dump/ }));
|
||||
expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "New dump" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a visitor loses a dump that goes private, and gets it back where it was", async () => {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
const list = ["x", "y", "z"].map((id) => makeDump({ id, title: `T-${id}`, userId: "ann" }));
|
||||
const list = ["x", "y", "z"].map((id) =>
|
||||
makeDump({ id, title: `T-${id}`, userId: "ann" })
|
||||
);
|
||||
route("GET /api/users/:u/dumps", () => page(list));
|
||||
const { socket } = openDumps();
|
||||
await screen.findByText("T-z");
|
||||
socket.welcome();
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: true }) });
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: true }),
|
||||
});
|
||||
expect(screen.queryByText("T-y")).not.toBeInTheDocument();
|
||||
socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) });
|
||||
const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent);
|
||||
socket.receive({
|
||||
type: "dump_updated",
|
||||
dump: wire({ ...list[1], isPrivate: false }),
|
||||
});
|
||||
const titles = Array.from(document.querySelectorAll(".dump-card-title"))
|
||||
.map((a) => a.textContent);
|
||||
expect(titles).toEqual(["T-x", "T-y", "T-z"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserUpvoted", () => {
|
||||
const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })];
|
||||
const voted = () => [
|
||||
makeDump({ id: "v1", title: "Liked one" }),
|
||||
makeDump({ id: "v2", title: "Liked two" }),
|
||||
];
|
||||
|
||||
function openVotes(session: ReturnType<typeof makeSession> | null = null) {
|
||||
route("GET /api/users/:u", () => wire(ann));
|
||||
route("GET /api/users/:u/votes", () => page(voted()));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<UserUpvoted />, { route: "/users/ann/upvoted", path: "/users/:username/upvoted", session });
|
||||
renderApp(<UserUpvoted />, {
|
||||
route: "/users/ann/upvoted",
|
||||
path: "/users/:username/upvoted",
|
||||
session,
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
test("lists what they upvoted", async () => {
|
||||
openVotes();
|
||||
expect(await screen.findByText("Liked one")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Upvoted" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Upvoted" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("your own list shows even before the live connection says what you voted", async () => {
|
||||
@@ -95,8 +128,16 @@ describe("UserUpvoted", () => {
|
||||
const { socket } = openVotes(annSession);
|
||||
await screen.findByText("Liked one");
|
||||
socket.welcome({ myVotes: ["v1", "v2"] });
|
||||
socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" });
|
||||
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading");
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v1",
|
||||
voteCount: 0,
|
||||
voterId: "ann",
|
||||
action: "remove",
|
||||
});
|
||||
expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass(
|
||||
"dump-card--fading",
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(2_400));
|
||||
expect(screen.queryByText("Liked one")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
@@ -105,13 +146,24 @@ describe("UserUpvoted", () => {
|
||||
});
|
||||
|
||||
test("their new upvote appears at the top", async () => {
|
||||
route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" })));
|
||||
route(
|
||||
"GET /api/dumps/:id",
|
||||
() => wire(makeDump({ id: "v3", title: "Fresh like" })),
|
||||
);
|
||||
const { socket } = openVotes();
|
||||
await screen.findByText("Liked one");
|
||||
socket.welcome();
|
||||
socket.receive({ type: "votes_update", dumpId: "v3", voteCount: 1, voterId: "ann", action: "cast" });
|
||||
socket.receive({
|
||||
type: "votes_update",
|
||||
dumpId: "v3",
|
||||
voteCount: 1,
|
||||
voterId: "ann",
|
||||
action: "cast",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like")
|
||||
expect(document.querySelector(".dump-card-title")).toHaveTextContent(
|
||||
"Fresh like",
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user