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