All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
11 KiB
TypeScript
368 lines
11 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 "../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";
|
||
|
||
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<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>;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
// deno-lint-ignore no-explicit-any
|
||
body: any;
|
||
bytes: Uint8Array;
|
||
}
|
||
|
||
/**
|
||
* Sends a request through the full middleware stack in-process — no socket.
|
||
* `json` sends a JSON body, `form` a multipart one.
|
||
*/
|
||
export async function request(
|
||
method: string,
|
||
path: string,
|
||
opts: {
|
||
auth?: { Authorization: string };
|
||
json?: unknown;
|
||
form?: FormData;
|
||
headers?: Record<string, string>;
|
||
} = {},
|
||
): Promise<ApiResponse> {
|
||
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: unknown = null;
|
||
if ((res.headers.get("content-type") ?? "").includes("json") && text) {
|
||
parsed = JSON.parse(text);
|
||
}
|
||
return { status: res.status, headers: res.headers, body: parsed, bytes };
|
||
}
|
||
|
||
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);
|
||
|
||
// ── 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[] = [];
|
||
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" },
|
||
});
|
||
}
|