Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
292fe8f85a test(migrations): cover initial schema, idempotency, and legacy upgrade
Some checks failed
CI / check (push) Failing after 17s
2026-06-07 22:17:54 +02:00
Julien Calixte
2d7863d9cc test(db): cover note CRUD, pagination, and webhook helpers 2026-06-07 22:17:23 +02:00
Julien Calixte
c62936b819 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.
2026-06-07 22:16:47 +02:00
6 changed files with 432 additions and 0 deletions

49
tests/_helpers/app.ts Normal file
View File

@@ -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<TestResponse> => {
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),
};
};

18
tests/_helpers/auth.ts Normal file
View File

@@ -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(),
};
};

60
tests/_helpers/db.ts Normal file
View File

@@ -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> | void,
): () => Promise<void> => {
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> = {}): 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",
});
};

View File

@@ -0,0 +1,57 @@
export type FetchHandler = (
req: Request,
) => Response | Promise<Response>;
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 });

133
tests/db_test.ts Normal file
View File

@@ -0,0 +1,133 @@
import { assertEquals, assertFalse } from "@std/assert";
import {
addWebhookSubscription,
deleteWebhookById,
getNotes,
getNotesByDids,
getWebhooksByDidAndVerb,
listWebhooksByDid,
upsertNote,
} from "../src/data/db.ts";
import { seedNote, seedWebhook, withTestDb } from "./_helpers/db.ts";
Deno.test(
"upsertNote inserts then updates on (did, rkey) conflict",
withTestDb(() => {
upsertNote({
did: "did:plc:user",
rkey: "rkey1",
title: "First",
publishedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
upsertNote({
did: "did:plc:user",
rkey: "rkey1",
title: "Second",
publishedAt: "2026-02-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
const { notes } = getNotes(undefined, 10);
assertEquals(notes.length, 1);
assertEquals(notes[0].title, "Second");
assertEquals(notes[0].publishedAt, "2026-02-01T00:00:00.000Z");
}),
);
Deno.test(
"upsertNote treats undefined discoverable as 1 and false as 0",
withTestDb(() => {
upsertNote({
did: "did:plc:user",
rkey: "default",
title: "Default",
publishedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
});
upsertNote({
did: "did:plc:user",
rkey: "hidden",
title: "Hidden",
publishedAt: "2026-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
discoverable: false,
});
const { notes } = getNotes(undefined, 10);
const rkeys = notes.map((n) => n.rkey);
assertEquals(rkeys, ["default"]);
}),
);
Deno.test(
"getNotes paginates rkey desc and returns cursor only when page is full",
withTestDb(() => {
for (const rkey of ["a", "b", "c", "d", "e"]) {
seedNote({ rkey });
}
const firstPage = getNotes(undefined, 3);
assertEquals(firstPage.notes.map((n) => n.rkey), ["e", "d", "c"]);
assertEquals(firstPage.cursor, "c");
const secondPage = getNotes(firstPage.cursor, 3);
assertEquals(secondPage.notes.map((n) => n.rkey), ["b", "a"]);
assertEquals(secondPage.cursor, undefined);
}),
);
Deno.test(
"getNotesByDids returns empty without running SQL on empty did list",
withTestDb(() => {
seedNote({ did: "did:plc:alice", rkey: "a" });
const result = getNotesByDids([], undefined, 10);
assertEquals(result, { notes: [] });
}),
);
Deno.test(
"webhook CRUD: add omits token, list orders desc, delete by id reports hit",
withTestDb(() => {
const created = addWebhookSubscription({
did: "did:plc:user",
method: "POST",
url: "https://example.com/a",
token: "secret",
verb: "create",
});
assertEquals(created.token, undefined);
assertEquals(created.url, "https://example.com/a");
seedWebhook({ url: "https://example.com/b", verb: "create" });
seedWebhook({ url: "https://example.com/c", verb: "delete" });
const list = listWebhooksByDid("did:plc:user");
assertEquals(list.length, 3);
const ids = list.map((w) => w.id);
assertEquals(ids, [...ids].sort((a, b) => b - a));
assertFalse(deleteWebhookById({ did: "did:plc:user", id: 9999 }));
const deleted = deleteWebhookById({ did: "did:plc:user", id: created.id });
assertEquals(deleted, true);
assertEquals(listWebhooksByDid("did:plc:user").length, 2);
}),
);
Deno.test(
"getWebhooksByDidAndVerb filters by verb and caps at 10",
withTestDb(() => {
for (let i = 0; i < 12; i++) {
seedWebhook({ url: `https://example.com/c${i}`, verb: "create" });
}
seedWebhook({ url: "https://example.com/d", verb: "delete" });
const creates = getWebhooksByDidAndVerb("did:plc:test", "create");
assertEquals(creates.length, 10);
for (const row of creates) assertEquals(row.verb, "create");
const deletes = getWebhooksByDidAndVerb("did:plc:test", "delete");
assertEquals(deletes.length, 1);
assertEquals(deletes[0].verb, "delete");
}),
);

115
tests/migrations_test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { assertEquals } from "@std/assert";
import { Database } from "@db/sqlite";
import { applyMigrations } from "../src/migrations/apply.ts";
type TableInfoRow = { name: string };
type ColumnInfoRow = { name: string };
const tableNames = (db: Database): string[] =>
db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
).all<TableInfoRow>().map((r) => r.name);
const columnNames = (db: Database, table: string): string[] =>
db.prepare(`PRAGMA table_info(${table})`)
.all<ColumnInfoRow>()
.map((r) => r.name)
.sort();
const indexNames = (db: Database): string[] =>
db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name",
).all<TableInfoRow>().map((r) => r.name);
Deno.test("applyMigrations creates expected tables, columns, and indices", () => {
const db = new Database(":memory:");
try {
applyMigrations(db);
const tables = tableNames(db);
assertEquals(
tables.includes("note") &&
tables.includes("state") &&
tables.includes("webhook_subscription"),
true,
);
const noteCols = columnNames(db, "note");
for (
const col of [
"title",
"publishedAt",
"createdAt",
"did",
"rkey",
"discoverable",
"language",
]
) {
assertEquals(noteCols.includes(col), true, `note.${col} missing`);
}
const webhookCols = columnNames(db, "webhook_subscription");
for (const col of ["id", "did", "method", "url", "token", "verb"]) {
assertEquals(
webhookCols.includes(col),
true,
`webhook_subscription.${col} missing`,
);
}
const indices = indexNames(db);
assertEquals(indices.includes("idx_webhook_subscription_did"), true);
assertEquals(indices.includes("idx_webhook_subscription_did_verb"), true);
} finally {
db.close();
}
});
Deno.test("applyMigrations is idempotent", () => {
const db = new Database(":memory:");
try {
applyMigrations(db);
db.exec(
"INSERT INTO webhook_subscription (did, method, url, verb) VALUES ('did:plc:x', 'POST', 'https://e.com', 'create')",
);
applyMigrations(db);
applyMigrations(db);
const [{ count }] = db.prepare(
"SELECT COUNT(*) AS count FROM webhook_subscription WHERE did = 'did:plc:x' AND verb = 'create'",
).all<{ count: number }>();
assertEquals(count, 1);
} finally {
db.close();
}
});
Deno.test(
"applyMigrations upgrades a legacy schema missing language and discoverable",
() => {
const db = new Database(":memory:");
try {
db.exec(`
CREATE TABLE note (
title TEXT NOT NULL,
publishedAt DATETIME,
createdAt DATETIME NOT NULL,
did TEXT NOT NULL,
rkey TEXT NOT NULL,
listed INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (did, rkey)
);
`);
applyMigrations(db);
const cols = columnNames(db, "note");
assertEquals(cols.includes("discoverable"), true);
assertEquals(cols.includes("language"), true);
assertEquals(cols.includes("listed"), false);
} finally {
db.close();
}
},
);