Compare commits
3 Commits
62a55fd3e4
...
292fe8f85a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
292fe8f85a | ||
|
|
2d7863d9cc | ||
|
|
c62936b819 |
49
tests/_helpers/app.ts
Normal file
49
tests/_helpers/app.ts
Normal 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
18
tests/_helpers/auth.ts
Normal 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
60
tests/_helpers/db.ts
Normal 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",
|
||||||
|
});
|
||||||
|
};
|
||||||
57
tests/_helpers/fetch_stub.ts
Normal file
57
tests/_helpers/fetch_stub.ts
Normal 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
133
tests/db_test.ts
Normal 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
115
tests/migrations_test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user