451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
// Must stay first: see setup.ts.
|
||
import { TEST_DIR } from "./setup.ts";
|
||
|
||
import { stub } from "@std/testing/mock";
|
||
import { app } from "../main.ts";
|
||
import { db } from "../db/connection.ts";
|
||
import { createJWT, createMediaToken } from "../lib/jwt.ts";
|
||
import { hashPassword } from "../lib/password.ts";
|
||
import { uniqueSlug } from "../lib/slugify.ts";
|
||
import { slugify } from "../../shared/slug.ts";
|
||
import { dumpPath, playlistPath } from "../../shared/paths.ts";
|
||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||
import { clearRichContentCache } from "../services/rich-content-service.ts";
|
||
import { clearImageCache } from "../remote/image-proxy.ts";
|
||
import {
|
||
type OutgoingEmail,
|
||
setEmailTransport,
|
||
} from "../services/email-service.ts";
|
||
import type { APIError, APIResponse } from "../../shared/api.ts";
|
||
import type { Role } from "../../shared/user.ts";
|
||
|
||
export { app, db, TEST_DIR };
|
||
|
||
// ── Database ─────────────────────────────────────────────────────────────────
|
||
|
||
const TABLES = [
|
||
"dump_backlinks",
|
||
"dump_categories",
|
||
"playlist_dumps",
|
||
"comment_likes",
|
||
"comments",
|
||
"votes",
|
||
"follows",
|
||
"notifications",
|
||
"password_reset_tokens",
|
||
"invites",
|
||
"attachments",
|
||
"chat_messages",
|
||
"user_settings",
|
||
"digest_schedule",
|
||
"dump_slug_aliases",
|
||
"playlist_slug_aliases",
|
||
"user_aliases",
|
||
"categories",
|
||
"playlists",
|
||
"dumps",
|
||
"users",
|
||
];
|
||
|
||
/** Empties every table, the default admin included. */
|
||
export function resetDb(): void {
|
||
db.exec("PRAGMA foreign_keys = OFF;");
|
||
for (const table of TABLES) db.exec(`DELETE FROM ${table};`);
|
||
db.exec("PRAGMA foreign_keys = ON;");
|
||
}
|
||
|
||
/**
|
||
* Wraps a test body so it starts from an empty database. Usernames and emails
|
||
* still have to be unique across the whole run where the rate limiter keys on
|
||
* them (login, password reset), since its buckets live for the process.
|
||
*/
|
||
export function test(
|
||
name: string,
|
||
fn: (t: Deno.TestContext) => void | Promise<void>,
|
||
options: Omit<Deno.TestDefinition, "name" | "fn"> = {},
|
||
): void {
|
||
Deno.test({
|
||
name,
|
||
...options,
|
||
async fn(t) {
|
||
resetDb();
|
||
await fn(t);
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Users ────────────────────────────────────────────────────────────────────
|
||
|
||
export const PASSWORD = "correct-horse";
|
||
// scrypt is deliberately slow; hash the shared password once.
|
||
let passwordHash: Promise<string> | undefined;
|
||
|
||
let seq = 0;
|
||
/** A name no other test in this process has used. */
|
||
export function uniqueName(prefix = "user"): string {
|
||
return `${prefix}_${++seq}_${crypto.randomUUID().slice(0, 6)}`;
|
||
}
|
||
|
||
export interface TestUser {
|
||
id: string;
|
||
username: string;
|
||
email: string;
|
||
role: Role;
|
||
token: string;
|
||
mediaToken: string;
|
||
/** Authorization header for this user. */
|
||
auth: { Authorization: string };
|
||
}
|
||
|
||
export async function createUser(
|
||
opts: {
|
||
username?: string;
|
||
role?: Role;
|
||
email?: string;
|
||
invitedBy?: string;
|
||
createdAt?: string;
|
||
} = {},
|
||
): Promise<TestUser> {
|
||
const id = crypto.randomUUID();
|
||
const username = opts.username ?? uniqueName();
|
||
const email = opts.email ?? `${username}@example.test`;
|
||
const role = opts.role ?? "user";
|
||
passwordHash ??= hashPassword(PASSWORD);
|
||
db.prepare(
|
||
`INSERT INTO users (id, username, password_hash, role, created_at, email, invited_by)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||
).run(
|
||
id,
|
||
username,
|
||
await passwordHash,
|
||
role,
|
||
opts.createdAt ?? new Date().toISOString(),
|
||
email,
|
||
opts.invitedBy ?? null,
|
||
);
|
||
const token = await createJWT({ userId: id, username, role });
|
||
return {
|
||
id,
|
||
username,
|
||
email,
|
||
role,
|
||
token,
|
||
mediaToken: await createMediaToken(id),
|
||
auth: { Authorization: `Bearer ${token}` },
|
||
};
|
||
}
|
||
|
||
// ── Content ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface InsertDumpOptions {
|
||
userId: string;
|
||
title?: string;
|
||
kind?: "url" | "file";
|
||
url?: string;
|
||
comment?: string;
|
||
isPrivate?: boolean;
|
||
createdAt?: string;
|
||
fileName?: string;
|
||
fileMime?: string;
|
||
/** Bytes written to the uploads directory for a file dump. */
|
||
fileData?: Uint8Array;
|
||
richContent?: Record<string, unknown>;
|
||
}
|
||
|
||
/** Returns the name of user `userId`, for the page paths of their things. */
|
||
function usernameOf(userId: string): string {
|
||
return (db.prepare(`SELECT username FROM users WHERE id = ?;`).get(
|
||
userId,
|
||
) as { username: string }).username;
|
||
}
|
||
|
||
/**
|
||
* Inserts a dump row directly, skipping the metadata fetch the create route
|
||
* does, with a slug made from its title as the service would. Returns its id,
|
||
* slug and page path.
|
||
*/
|
||
export function insertDump(
|
||
opts: InsertDumpOptions,
|
||
): { id: string; slug: string; path: string } {
|
||
const id = crypto.randomUUID();
|
||
const title = opts.title ?? "A dump";
|
||
const slug = uniqueSlug(db, "dump", opts.userId, slugify(title));
|
||
const kind = opts.kind ?? (opts.fileName ? "file" : "url");
|
||
const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null;
|
||
const data = opts.fileData ?? new TextEncoder().encode("hello");
|
||
db.prepare(
|
||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical,
|
||
rich_content, file_name, file_mime, file_size, is_private)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||
).run(
|
||
id,
|
||
kind,
|
||
title,
|
||
slug,
|
||
opts.comment ?? null,
|
||
opts.userId,
|
||
opts.createdAt ?? new Date().toISOString(),
|
||
url,
|
||
url ? canonicalizeUrl(url) : null,
|
||
opts.richContent ? JSON.stringify(opts.richContent) : null,
|
||
kind === "file" ? (opts.fileName ?? "file.txt") : null,
|
||
kind === "file" ? (opts.fileMime ?? "text/plain") : null,
|
||
kind === "file" ? data.byteLength : null,
|
||
opts.isPrivate ? 1 : 0,
|
||
);
|
||
if (kind === "file") {
|
||
Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true });
|
||
Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data);
|
||
}
|
||
return {
|
||
id,
|
||
slug,
|
||
path: dumpPath({ ownerUsername: usernameOf(opts.userId), slug }),
|
||
};
|
||
}
|
||
|
||
/** Inserts a playlist row as {@link insertDump} does a dump. */
|
||
export function insertPlaylist(
|
||
opts: { userId: string; title?: string; isPublic?: boolean },
|
||
): { id: string; slug: string; path: string } {
|
||
const id = crypto.randomUUID();
|
||
const title = opts.title ?? "A playlist";
|
||
const slug = uniqueSlug(db, "playlist", opts.userId, slugify(title));
|
||
db.prepare(
|
||
`INSERT INTO playlists (id, user_id, title, slug, is_public, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||
).run(
|
||
id,
|
||
opts.userId,
|
||
title,
|
||
slug,
|
||
opts.isPublic === false ? 0 : 1,
|
||
new Date().toISOString(),
|
||
);
|
||
return {
|
||
id,
|
||
slug,
|
||
path: playlistPath({ ownerUsername: usernameOf(opts.userId), slug }),
|
||
};
|
||
}
|
||
|
||
export function insertComment(
|
||
opts: {
|
||
dumpId: string;
|
||
userId: string;
|
||
body?: string;
|
||
parentId?: string;
|
||
createdAt?: string;
|
||
},
|
||
): string {
|
||
const id = crypto.randomUUID();
|
||
db.prepare(
|
||
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||
).run(
|
||
id,
|
||
opts.dumpId,
|
||
opts.userId,
|
||
opts.parentId ?? null,
|
||
opts.body ?? "A comment",
|
||
opts.createdAt ?? new Date().toISOString(),
|
||
);
|
||
return id;
|
||
}
|
||
|
||
export function insertCategory(
|
||
opts: { slug: string; name?: string; position?: number },
|
||
): string {
|
||
const id = crypto.randomUUID();
|
||
db.prepare(
|
||
`INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`,
|
||
).run(
|
||
id,
|
||
opts.slug,
|
||
opts.name ?? opts.slug,
|
||
opts.position ?? 0,
|
||
new Date().toISOString(),
|
||
);
|
||
return id;
|
||
}
|
||
|
||
export function count(sql: string, ...params: (string | number)[]): number {
|
||
return (db.prepare(sql).get(...params) as { n: number }).n;
|
||
}
|
||
|
||
// ── HTTP ─────────────────────────────────────────────────────────────────────
|
||
|
||
export const BASE_URL = "http://gerbeur.test";
|
||
|
||
export interface ApiResponse<T = unknown> {
|
||
status: number;
|
||
headers: Headers;
|
||
/** 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. `T` is what the endpoint
|
||
* answers with in `data`.
|
||
*/
|
||
export async function request<T = unknown>(
|
||
method: string,
|
||
path: string,
|
||
opts: RequestOptions = {},
|
||
): Promise<ApiResponse<T>> {
|
||
const headers = new Headers({ ...opts.auth, ...opts.headers });
|
||
let body: BodyInit | undefined;
|
||
if (opts.json !== undefined) {
|
||
headers.set("Content-Type", "application/json");
|
||
body = JSON.stringify(opts.json);
|
||
} else if (opts.form) {
|
||
body = opts.form;
|
||
}
|
||
const res = await app.handle(
|
||
new Request(`${BASE_URL}${path}`, { method, headers, body }),
|
||
);
|
||
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: APIResponse<T> | null = null;
|
||
if ((res.headers.get("content-type") ?? "").includes("json") && text) {
|
||
parsed = JSON.parse(text);
|
||
}
|
||
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 = <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 ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Smallest valid PNG: a 1×1 transparent pixel. */
|
||
export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
|
||
atob(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||
),
|
||
(c) => c.charCodeAt(0),
|
||
);
|
||
|
||
export function formWithFile(
|
||
bytes: Uint8Array<ArrayBuffer>,
|
||
name = "file.png",
|
||
type = "image/png",
|
||
fields: Record<string, string> = {},
|
||
): FormData {
|
||
const form = new FormData();
|
||
form.set("file", new File([bytes], name, { type }));
|
||
for (const [key, value] of Object.entries(fields)) form.set(key, value);
|
||
return form;
|
||
}
|
||
|
||
// ── Network ──────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Replaces outbound fetches and DNS for the life of the returned handle
|
||
* (`using net = stubNetwork(...)`). Every hostname resolves to a public
|
||
* address unless `dns` says otherwise; `handler` answers every fetch.
|
||
*/
|
||
export function stubNetwork(
|
||
handler: (url: URL, init?: RequestInit) => Response | Promise<Response>,
|
||
dns: Record<string, string[]> = {},
|
||
): { calls: URL[] } & Disposable {
|
||
const calls: URL[] = [];
|
||
// A new network: metadata cached from the last one no longer describes it.
|
||
clearRichContentCache();
|
||
clearImageCache();
|
||
const fetchStub = stub(
|
||
globalThis,
|
||
"fetch",
|
||
(input: string | URL | Request, init?: RequestInit) => {
|
||
const url = new URL(input instanceof Request ? input.url : input);
|
||
calls.push(url);
|
||
return Promise.resolve(handler(url, init));
|
||
},
|
||
);
|
||
const dnsStub = stub(
|
||
Deno,
|
||
"resolveDns",
|
||
((host: string, type: string) =>
|
||
Promise.resolve(
|
||
dns[host] ?? (type === "A" ? ["93.184.216.34"] : []),
|
||
)) as typeof Deno.resolveDns,
|
||
);
|
||
return {
|
||
calls,
|
||
[Symbol.dispose]() {
|
||
fetchStub.restore();
|
||
dnsStub.restore();
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── Email ────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Turns email on for the life of the returned handle
|
||
* (`using mail = captureEmails()`), recording each message instead of sending
|
||
* it. Sends that aren't awaited by their route land on the next tick.
|
||
*/
|
||
export function captureEmails(): { sent: OutgoingEmail[] } & Disposable {
|
||
const sent: OutgoingEmail[] = [];
|
||
setEmailTransport({
|
||
sendMail(message) {
|
||
sent.push(message);
|
||
return Promise.resolve();
|
||
},
|
||
});
|
||
return {
|
||
sent,
|
||
[Symbol.dispose]() {
|
||
setEmailTransport(null);
|
||
},
|
||
};
|
||
}
|
||
|
||
export function html(body: string, status = 200): Response {
|
||
return new Response(body, {
|
||
status,
|
||
headers: { "content-type": "text/html; charset=utf-8" },
|
||
});
|
||
}
|