diff --git a/api/health.ts b/api/health.ts index 023713e..bc458a4 100644 --- a/api/health.ts +++ b/api/health.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; +import { AppEnv } from "../context.ts"; -const app = new Hono(); +const app = new Hono(); app.get("/", (c) => { return c.text( @@ -16,7 +17,7 @@ ${"`"}------'${"`"}------'${"`"}------'${"`"}------'${"`"}------' This is an AT Protocol Application View (AppView) for the "sprk.so" application. Most API routes are under /xrpc/ - + `, ); }); @@ -27,9 +28,15 @@ app.get("/robots.txt", (c) => { ); }); -app.get("/xrpc/_health", (c) => { +app.get("/xrpc/_health", async (c) => { const version = Deno.env.get("COMMIT_SHA") ?? "unknown"; - return c.json({ version }); + + try { + await c.env.db.ping(); + return c.json({ version }); + } catch { + return c.json({ version, error: "Database not connected" }, 503); + } }); export default app; diff --git a/data-plane/db/index.ts b/data-plane/db/index.ts index a67b462..1cf2ea1 100644 --- a/data-plane/db/index.ts +++ b/data-plane/db/index.ts @@ -144,6 +144,14 @@ export class Database { } } + async ping(): Promise { + const db = this.connection?.db; + if (!db) { + throw new Error("Database not connected"); + } + await db.admin().ping(); + } + // Add methods for DID resolution async resolveHandle(handle: string): Promise { try { diff --git a/deno.json b/deno.json index 5db0a6f..678ce1a 100644 --- a/deno.json +++ b/deno.json @@ -37,7 +37,11 @@ "test": { "permissions": { "env": true, - "read": true + "read": true, + "write": true, + "net": true, + "run": true, + "sys": true } } } diff --git a/main.ts b/main.ts index d8a71ce..dc53496 100644 --- a/main.ts +++ b/main.ts @@ -32,10 +32,11 @@ export function createApp(ctx: AppContext): Hono { // Lexicon/XRPC server and routers const lexServer = createServer(); API(lexServer, ctx); - app.route("/", lexServer.xrpc.app); app.route("/.well-known", wellKnown); app.route("/", health); + app.route("/", lexServer.xrpc.app); + return app; } diff --git a/tests/main_test.ts b/tests/main_test.ts index a3a03db..3f97c49 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -1,71 +1,75 @@ import { assertEquals } from "@std/assert"; import { assertMatch } from "@std/assert/match"; import { createMockApp, createMockContext } from "./util.ts"; +import { createApp } from "../main.ts"; -Deno.test("Basic App Creation", async () => { +Deno.test("Basic Endpoints", async (t) => { const app = createMockApp(); + await t.step("/", async () => { + const res = await app.request("/", { + headers: { + "Content-Type": "application/json", + }, + }); - const res = await app.request("/", { - headers: { - "Content-Type": "application/json", - }, + assertEquals(res.status, 200); }); + await t.step("/.well-known/did.json", async () => { + const res = await app.request("/.well-known/did.json", { + headers: { + "Content-Type": "application/json", + }, + }); - assertEquals(res.status, 200); + assertMatch( + await res.text(), + new RegExp( + [ + "^\\{", + '"@context":\\["https://www\\.w3\\.org/ns/did/v1","https://w3id\\.org/security/multikey/v1"\\],', + '"id":"(did:web:[^"]+)",', + '"verificationMethod":\\[\\{', + '"id":"\\1#atproto",', + '"type":"Multikey",', + '"controller":"\\1",', + '"publicKeyMultibase":"[a-zA-Z0-9]+"', + "\\}\\],", + '"service":\\[\\{', + '"id":"#sprk_appview",', + '"type":"SprkAppView",', + '"serviceEndpoint":"https?://[^"]+"', + "\\}\\]", + "\\}$", + ].join(""), + ), + ); + }); }); -Deno.test("Well Known Endpoint", async () => { - const app = createMockApp(); +Deno.test("Health Check", async (t) => { + await t.step("succeeds when DB is healthy", async () => { + const ctx = createMockContext(); + ctx.db.ping = () => Promise.resolve(); - const res = await app.request("/.well-known/did.json", { - headers: { - "Content-Type": "application/json", - }, - }); + const app = createApp(ctx); + const res = await app.request("/xrpc/_health"); - assertMatch( - await res.text(), - new RegExp( - [ - "^\\{", - '"@context":\\["https://www\\.w3\\.org/ns/did/v1","https://w3id\\.org/security/multikey/v1"\\],', - '"id":"(did:web:[^"]+)",', - '"verificationMethod":\\[\\{', - '"id":"\\1#atproto",', - '"type":"Multikey",', - '"controller":"\\1",', - '"publicKeyMultibase":"[a-zA-Z0-9]+"', - "\\}\\],", - '"service":\\[\\{', - '"id":"#sprk_appview",', - '"type":"SprkAppView",', - '"serviceEndpoint":"https?://[^"]+"', - "\\}\\]", - "\\}$", - ].join(""), - ), - ); -}); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(typeof body.version, "string"); + assertEquals(body.error, undefined); + }); -Deno.test("Mock Context Creation", () => { - const ctx = createMockContext(); + await t.step("returns 503 when DB is unavailable", async () => { + const ctx = createMockContext(); + ctx.db.ping = () => Promise.reject(new Error("Connection failed")); - assertEquals(typeof ctx.db, "object"); - assertEquals(typeof ctx.dataplane, "object"); - assertEquals(typeof ctx.hydrator, "object"); - assertEquals(typeof ctx.views, "object"); - assertEquals(typeof ctx.authVerifier, "function"); // AuthVerifier is a callable function - assertEquals(typeof ctx.logger, "object"); - assertEquals(typeof ctx.idResolver, "object"); - assertEquals(ctx.cfg.serverDid, "did:web:localhost"); -}); + const app = createApp(ctx); + const res = await app.request("/xrpc/_health"); -Deno.test("Mock Context with Config Overrides", () => { - const ctx = createMockContext({ - serverDid: "did:web:custom.test", - adminPasswords: ["custom-password"], + assertEquals(res.status, 503); + const body = await res.json(); + assertEquals(typeof body.version, "string"); + assertEquals(body.error, "Database not connected"); }); - - assertEquals(ctx.cfg.serverDid, "did:web:custom.test"); - assertEquals(ctx.cfg.adminPasswords, ["custom-password"]); }); diff --git a/tests/pagination_test.ts b/tests/pagination_test.ts new file mode 100644 index 0000000..d117b18 --- /dev/null +++ b/tests/pagination_test.ts @@ -0,0 +1,309 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + CreatedAtDidKeyset, + IndexedAtDidKeyset, + IsoTimeKey, + LikeCountCidKeyset, + RkeyKey, + TimeCidKeyset, +} from "../data-plane/db/pagination.ts"; +import { InvalidRequestError } from "@atp/xrpc-server"; + +// GenericKeyset (tested via TimeCidKeyset) + +Deno.test("GenericKeyset", async (t) => { + const keyset = new TimeCidKeyset(); + + await t.step("packCursor uses colon separator", () => { + const cursor = { primary: "abc123", secondary: "def456" }; + const packed = keyset.packCursor(cursor); + assertEquals(packed, "abc123:def456"); + }); + + await t.step("unpackCursor splits on first colon", () => { + const unpacked = keyset.unpackCursor("abc123:def456"); + assertEquals(unpacked?.primary, "abc123"); + assertEquals(unpacked?.secondary, "def456"); + }); + + await t.step("unpackCursor handles colons in secondary", () => { + const unpacked = keyset.unpackCursor("abc123:def:456:789"); + assertEquals(unpacked?.primary, "abc123"); + assertEquals(unpacked?.secondary, "def:456:789"); + }); + + await t.step("unpackCursor throws on missing separator", () => { + assertThrows( + () => keyset.unpackCursor("noseparatorhere"), + InvalidRequestError, + "Malformed cursor: missing separator", + ); + }); + + await t.step("unpackCursor throws on empty primary", () => { + assertThrows( + () => keyset.unpackCursor(":secondary"), + InvalidRequestError, + "Malformed cursor: missing primary or secondary", + ); + }); + + await t.step("unpackCursor throws on empty secondary", () => { + assertThrows( + () => keyset.unpackCursor("primary:"), + InvalidRequestError, + "Malformed cursor: missing primary or secondary", + ); + }); + + await t.step("getFilter returns descending filter by default", () => { + const labeled = { + primary: "2024-01-15T12:00:00.000Z", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + const filter = keyset.getFilter(labeled); + assertEquals(filter, { + $or: [ + { indexedAt: { $lt: "2024-01-15T12:00:00.000Z" } }, + { + indexedAt: "2024-01-15T12:00:00.000Z", + cid: { $lt: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq" }, + }, + ], + }); + }); + + await t.step("getFilter returns ascending filter when specified", () => { + const labeled = { + primary: "2024-01-15T12:00:00.000Z", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + const filter = keyset.getFilter(labeled, "asc"); + assertEquals(filter, { + $or: [ + { indexedAt: { $gt: "2024-01-15T12:00:00.000Z" } }, + { + indexedAt: "2024-01-15T12:00:00.000Z", + cid: { $gt: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq" }, + }, + ], + }); + }); + + await t.step("pack and unpack roundtrip", () => { + const original = { + primary: "2024-01-15T12:00:00.000Z", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + const packed = keyset.pack(original); + const unpacked = keyset.unpack(packed); + + assertEquals(unpacked?.primary, original.primary); + assertEquals(unpacked?.secondary, original.secondary); + }); + + await t.step("packFromResult with array uses last result", () => { + const results = [ + { indexedAt: "2024-01-15T12:00:00.000Z", cid: "first" }, + { indexedAt: "2024-01-15T13:00:00.000Z", cid: "second" }, + { indexedAt: "2024-01-15T14:00:00.000Z", cid: "last" }, + ]; + + const packed = keyset.packFromResult(results); + const unpacked = keyset.unpack(packed); + + assertEquals(unpacked?.secondary, "last"); + }); +}); + +// TimeCidKeyset (extends GenericKeyset) + +Deno.test("TimeCidKeyset", async (t) => { + const keyset = new TimeCidKeyset(); + + await t.step( + "labelResult uses current time when indexedAt is missing", + () => { + const result = { + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + const before = new Date(); + const labeled = keyset.labelResult(result); + const after = new Date(); + + const labeledDate = new Date(labeled.primary); + assertEquals(labeledDate >= before, true); + assertEquals(labeledDate <= after, true); + }, + ); + + await t.step("labeledResultToCursor converts to base36 seconds", () => { + const labeled = { + primary: "2024-01-15T12:00:00.000Z", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + const cursor = keyset.labeledResultToCursor(labeled); + + const expectedBase36 = Math.floor( + new Date("2024-01-15T12:00:00.000Z").getTime() / 1000, + ).toString(36); + assertEquals(cursor.primary, expectedBase36); + }); + + await t.step("labeledResultToCursor throws on invalid date", () => { + const labeled = { + primary: "not-a-valid-date", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + assertThrows( + () => keyset.labeledResultToCursor(labeled), + InvalidRequestError, + "Invalid date for cursor", + ); + }); + + await t.step("cursorToLabeledResult converts from base36", () => { + const base36Seconds = (1705320000).toString(36); + const cursor = { + primary: base36Seconds, + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + const labeled = keyset.cursorToLabeledResult(cursor); + + assertEquals(labeled.primary, "2024-01-15T12:00:00.000Z"); + }); +}); + +Deno.test("TimeCidKeyset subclasses", async (t) => { + await t.step("CreatedAtDidKeyset works with createdAt field", () => { + const keyset = new CreatedAtDidKeyset(); + const packed = keyset.packFromResult({ + createdAt: "2024-01-15T12:00:00.000Z", + authorDid: "did:plc:testuser123", + }); + assertEquals(typeof packed, "string"); + }); + + await t.step("IndexedAtDidKeyset works with indexedAt field", () => { + const keyset = new IndexedAtDidKeyset(); + const packed = keyset.packFromResult({ + indexedAt: "2024-01-15T12:00:00.000Z", + authorDid: "did:plc:testuser123", + }); + assertEquals(typeof packed, "string"); + }); +}); + +// LikeCountCidKeyset (extends GenericKeyset) + +Deno.test("LikeCountCidKeyset", async (t) => { + await t.step("cursorToLabeledResult throws on invalid like count", () => { + const keyset = new LikeCountCidKeyset(); + const cursor = { + primary: "not-a-number", + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", + }; + + assertThrows( + () => keyset.cursorToLabeledResult(cursor), + InvalidRequestError, + "Malformed cursor: invalid like count", + ); + }); +}); + +// GenericSingleKey (tested via RkeyKey) + +Deno.test("GenericSingleKey", async (t) => { + const keyset = new RkeyKey(); + + await t.step("getFilter returns descending filter by default", () => { + const labeled = { primary: "3jui7kd2zcysk" }; + const filter = keyset.getFilter(labeled); + assertEquals(filter, { + key: { $lt: "3jui7kd2zcysk" }, + }); + }); + + await t.step("getFilter returns ascending filter when specified", () => { + const labeled = { primary: "3jui7kd2zcysk" }; + const filter = keyset.getFilter(labeled, "asc"); + assertEquals(filter, { + key: { $gt: "3jui7kd2zcysk" }, + }); + }); + + await t.step("unpackCursor throws on colon separator", () => { + assertThrows( + () => keyset.unpackCursor("has:colon"), + InvalidRequestError, + "Malformed cursor: unexpected separator", + ); + }); + + await t.step("unpackCursor throws on double underscore separator", () => { + assertThrows( + () => keyset.unpackCursor("has__underscore"), + InvalidRequestError, + "Malformed cursor: unexpected separator", + ); + }); +}); + +// IsoTimeKey (extends GenericSingleKey) + +Deno.test("IsoTimeKey", async (t) => { + const keyset = new IsoTimeKey(); + + await t.step("labeledResultToCursor throws on invalid date", () => { + const labeled = { primary: "not-a-valid-date" }; + assertThrows( + () => keyset.labeledResultToCursor(labeled), + InvalidRequestError, + "Invalid date for cursor", + ); + }); + + await t.step("cursorToLabeledResult throws on invalid date", () => { + const cursor = { primary: "invalid-date" }; + assertThrows( + () => keyset.cursorToLabeledResult(cursor), + InvalidRequestError, + "Malformed cursor: invalid date", + ); + }); + + await t.step("pack produces ISO date but unpack fails due to colons", () => { + // IsoTimeKey produces ISO dates with colons, but GenericSingleKey.unpackCursor + // rejects strings with colons. This is a known limitation. + const result = { indexedAt: "2024-01-15T12:00:00.000Z" }; + const packed = keyset.packFromResult(result); + + assertEquals(packed, "2024-01-15T12:00:00.000Z"); + + assertThrows( + () => keyset.unpack(packed), + InvalidRequestError, + "Malformed cursor: unexpected separator", + ); + }); +}); + +// RkeyKey (extends GenericSingleKey) + +Deno.test("RkeyKey", async (t) => { + await t.step("cursorToLabeledResult throws on invalid record key", () => { + const keyset = new RkeyKey(); + const cursor = { primary: "invalid/key" }; + assertThrows( + () => keyset.cursorToLabeledResult(cursor), + InvalidRequestError, + "Malformed cursor", + ); + }); +}); diff --git a/tests/util.ts b/tests/util.ts index fa82e16..03cfaaf 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -247,6 +247,7 @@ export function createMockContext( const mockDb = { connect: () => Promise.resolve(), disconnect: () => Promise.resolve(), + ping: () => Promise.resolve(), models: {}, getCursorState: () => Promise.resolve(null), saveCursorState: () => Promise.resolve(),