// 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, hashPassword } from "../lib/jwt.ts"; import { makeSlug } from "../lib/slugify.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 { 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", "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, options: Omit = {}, ): 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 | 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 { 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; } /** * Inserts a dump row directly, skipping the metadata fetch the create route * does. Returns its id and slug. */ export function insertDump( opts: InsertDumpOptions, ): { id: string; slug: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A dump"; const slug = makeSlug(title, id); 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 }; } export function insertPlaylist( opts: { userId: string; title?: string; isPublic?: boolean }, ): { id: string; slug: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A playlist"; const slug = makeSlug(title, id); 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 }; } 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 { status: number; headers: Headers; /** The parsed body when it's JSON, null otherwise. */ body: APIResponse | 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; }; /** * 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( method: string, path: string, opts: RequestOptions = {}, ): Promise> { 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 | 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 = (path: string, opts?: RequestOptions) => request("GET", path, opts); export const post = (path: string, opts?: RequestOptions) => request("POST", path, opts); export const put = (path: string, opts?: RequestOptions) => request("PUT", path, opts); export const patch = (path: string, opts?: RequestOptions) => request("PATCH", path, opts); export const del = (path: string, opts?: RequestOptions) => request("DELETE", path, opts); // ── Files ──────────────────────────────────────────────────────────────────── /** Smallest valid PNG: a 1×1 transparent pixel. */ export const PNG: Uint8Array = Uint8Array.from( atob( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", ), (c) => c.charCodeAt(0), ); export function formWithFile( bytes: Uint8Array, name = "file.png", type = "image/png", fields: Record = {}, ): 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, dns: Record = {}, ): { 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(); }, }; } export function html(body: string, status = 200): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" }, }); }