From c62936b819e9a79df399b76c36ac60ee2698e8e2 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 7 Jun 2026 22:16:47 +0200 Subject: [PATCH] test(helpers): add db, fetch, app, and auth test helpers withTestDb sets up an in-memory SQLite handle with migrations applied, swaps the production singleton via _setDbForTest, and restores in a finally block. installFetchStub captures every outbound request and throws if restored twice. requestApp wraps Oak's app.handle so route tests can run without listen(). stubAuthenticate flips the server's authenticator indirection. --- tests/_helpers/app.ts | 49 +++++++++++++++++++++++++++++ tests/_helpers/auth.ts | 18 +++++++++++ tests/_helpers/db.ts | 60 ++++++++++++++++++++++++++++++++++++ tests/_helpers/fetch_stub.ts | 57 ++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100644 tests/_helpers/app.ts create mode 100644 tests/_helpers/auth.ts create mode 100644 tests/_helpers/db.ts create mode 100644 tests/_helpers/fetch_stub.ts diff --git a/tests/_helpers/app.ts b/tests/_helpers/app.ts new file mode 100644 index 0000000..a68bce5 --- /dev/null +++ b/tests/_helpers/app.ts @@ -0,0 +1,49 @@ +import type { Application } from "@oak/oak"; + +export type RequestOptions = { + method?: string; + path: string; + headers?: HeadersInit; + body?: unknown; +}; + +export type TestResponse = { + status: number; + headers: Headers; + text: string; + json: () => unknown; +}; + +export const requestApp = async ( + app: Application, + opts: RequestOptions, +): Promise => { + const method = opts.method ?? "GET"; + const url = `http://test.local${opts.path}`; + const init: RequestInit = { + method, + headers: opts.headers, + }; + if (opts.body !== undefined && method !== "GET" && method !== "HEAD") { + init.body = typeof opts.body === "string" + ? opts.body + : JSON.stringify(opts.body); + const headers = new Headers(opts.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + init.headers = headers; + } + const req = new Request(url, init); + const res = await app.handle(req); + if (!res) { + throw new Error("app.handle returned undefined"); + } + const text = await res.text(); + return { + status: res.status, + headers: res.headers, + text, + json: () => JSON.parse(text), + }; +}; diff --git a/tests/_helpers/auth.ts b/tests/_helpers/auth.ts new file mode 100644 index 0000000..8d1e887 --- /dev/null +++ b/tests/_helpers/auth.ts @@ -0,0 +1,18 @@ +import { + _resetAuthenticatorForTest, + _setAuthenticatorForTest, +} from "../../server.ts"; + +export type AuthStub = { restore: () => void }; + +export const stubAuthenticate = (didOrError: string | Error): AuthStub => { + _setAuthenticatorForTest(() => { + if (didOrError instanceof Error) { + return Promise.reject(didOrError); + } + return Promise.resolve(didOrError); + }); + return { + restore: () => _resetAuthenticatorForTest(), + }; +}; diff --git a/tests/_helpers/db.ts b/tests/_helpers/db.ts new file mode 100644 index 0000000..d354537 --- /dev/null +++ b/tests/_helpers/db.ts @@ -0,0 +1,60 @@ +import { Database } from "@db/sqlite"; +import { + _resetDbForTest, + _setDbForTest, + addWebhookSubscription, + upsertNote, + type WebhookVerb, +} from "../../src/data/db.ts"; +import { applyMigrations } from "../../src/migrations/apply.ts"; +import type { Note } from "../../src/data/note.ts"; + +export const withTestDb = ( + fn: (db: Database) => Promise | void, +): () => Promise => { + return async () => { + const db = new Database(":memory:"); + applyMigrations(db); + _setDbForTest(db); + try { + await fn(db); + } finally { + _resetDbForTest(); + db.close(); + } + }; +}; + +let seedCounter = 0; + +export const seedNote = (overrides: Partial = {}): Note => { + seedCounter++; + const note: Note = { + did: overrides.did ?? "did:plc:test", + rkey: overrides.rkey ?? `seed${seedCounter.toString().padStart(6, "0")}`, + title: overrides.title ?? "Test title", + publishedAt: overrides.publishedAt ?? "2026-01-01T00:00:00.000Z", + createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", + discoverable: overrides.discoverable, + language: overrides.language, + content: overrides.content, + }; + upsertNote(note); + return note; +}; + +export const seedWebhook = (overrides: { + did?: string; + method?: string; + url?: string; + token?: string; + verb?: WebhookVerb; +} = {}) => { + return addWebhookSubscription({ + did: overrides.did ?? "did:plc:test", + method: overrides.method ?? "POST", + url: overrides.url ?? "https://example.com/hook", + token: overrides.token, + verb: overrides.verb ?? "create", + }); +}; diff --git a/tests/_helpers/fetch_stub.ts b/tests/_helpers/fetch_stub.ts new file mode 100644 index 0000000..0b75c9c --- /dev/null +++ b/tests/_helpers/fetch_stub.ts @@ -0,0 +1,57 @@ +export type FetchHandler = ( + req: Request, +) => Response | Promise; + +export type CapturedRequest = { + method: string; + url: string; + headers: Headers; + body: string; +}; + +export type FetchStub = { + calls: CapturedRequest[]; + restore: () => void; +}; + +export const installFetchStub = (handler: FetchHandler): FetchStub => { + const original = globalThis.fetch; + const calls: CapturedRequest[] = []; + let restored = false; + + globalThis.fetch = async (input, init) => { + const req = new Request(input as RequestInfo, init); + const body = req.method === "GET" || req.method === "HEAD" + ? "" + : await req.clone().text(); + calls.push({ + method: req.method, + url: req.url, + headers: new Headers(req.headers), + body, + }); + return await handler(req); + }; + + return { + calls, + restore: () => { + if (restored) throw new Error("FetchStub.restore() called twice"); + restored = true; + globalThis.fetch = original; + }, + }; +}; + +export const jsonResponse = ( + body: unknown, + init: ResponseInit = {}, +): Response => { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); +}; + +export const errorResponse = (status: number, text = "error"): Response => + new Response(text, { status });