[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(db): add pagination test

+394 -60
+11 -4
api/health.ts
··· 1 1 import { Hono } from "hono"; 2 + import { AppEnv } from "../context.ts"; 2 3 3 - const app = new Hono(); 4 + const app = new Hono<AppEnv>(); 4 5 5 6 app.get("/", (c) => { 6 7 return c.text( ··· 16 17 This is an AT Protocol Application View (AppView) for the "sprk.so" application. 17 18 18 19 Most API routes are under /xrpc/ 19 - 20 + 20 21 `, 21 22 ); 22 23 }); ··· 27 28 ); 28 29 }); 29 30 30 - app.get("/xrpc/_health", (c) => { 31 + app.get("/xrpc/_health", async (c) => { 31 32 const version = Deno.env.get("COMMIT_SHA") ?? "unknown"; 32 - return c.json({ version }); 33 + 34 + try { 35 + await c.env.db.ping(); 36 + return c.json({ version }); 37 + } catch { 38 + return c.json({ version, error: "Database not connected" }, 503); 39 + } 33 40 }); 34 41 35 42 export default app;
+8
data-plane/db/index.ts
··· 144 144 } 145 145 } 146 146 147 + async ping(): Promise<void> { 148 + const db = this.connection?.db; 149 + if (!db) { 150 + throw new Error("Database not connected"); 151 + } 152 + await db.admin().ping(); 153 + } 154 + 147 155 // Add methods for DID resolution 148 156 async resolveHandle(handle: string): Promise<string | undefined> { 149 157 try {
+5 -1
deno.json
··· 37 37 "test": { 38 38 "permissions": { 39 39 "env": true, 40 - "read": true 40 + "read": true, 41 + "write": true, 42 + "net": true, 43 + "run": true, 44 + "sys": true 41 45 } 42 46 } 43 47 }
+2 -1
main.ts
··· 32 32 // Lexicon/XRPC server and routers 33 33 const lexServer = createServer(); 34 34 API(lexServer, ctx); 35 - app.route("/", lexServer.xrpc.app); 36 35 37 36 app.route("/.well-known", wellKnown); 38 37 app.route("/", health); 38 + app.route("/", lexServer.xrpc.app); 39 + 39 40 return app; 40 41 } 41 42
+58 -54
tests/main_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 2 import { assertMatch } from "@std/assert/match"; 3 3 import { createMockApp, createMockContext } from "./util.ts"; 4 + import { createApp } from "../main.ts"; 4 5 5 - Deno.test("Basic App Creation", async () => { 6 + Deno.test("Basic Endpoints", async (t) => { 6 7 const app = createMockApp(); 8 + await t.step("/", async () => { 9 + const res = await app.request("/", { 10 + headers: { 11 + "Content-Type": "application/json", 12 + }, 13 + }); 7 14 8 - const res = await app.request("/", { 9 - headers: { 10 - "Content-Type": "application/json", 11 - }, 15 + assertEquals(res.status, 200); 12 16 }); 17 + await t.step("/.well-known/did.json", async () => { 18 + const res = await app.request("/.well-known/did.json", { 19 + headers: { 20 + "Content-Type": "application/json", 21 + }, 22 + }); 13 23 14 - assertEquals(res.status, 200); 24 + assertMatch( 25 + await res.text(), 26 + new RegExp( 27 + [ 28 + "^\\{", 29 + '"@context":\\["https://www\\.w3\\.org/ns/did/v1","https://w3id\\.org/security/multikey/v1"\\],', 30 + '"id":"(did:web:[^"]+)",', 31 + '"verificationMethod":\\[\\{', 32 + '"id":"\\1#atproto",', 33 + '"type":"Multikey",', 34 + '"controller":"\\1",', 35 + '"publicKeyMultibase":"[a-zA-Z0-9]+"', 36 + "\\}\\],", 37 + '"service":\\[\\{', 38 + '"id":"#sprk_appview",', 39 + '"type":"SprkAppView",', 40 + '"serviceEndpoint":"https?://[^"]+"', 41 + "\\}\\]", 42 + "\\}$", 43 + ].join(""), 44 + ), 45 + ); 46 + }); 15 47 }); 16 48 17 - Deno.test("Well Known Endpoint", async () => { 18 - const app = createMockApp(); 49 + Deno.test("Health Check", async (t) => { 50 + await t.step("succeeds when DB is healthy", async () => { 51 + const ctx = createMockContext(); 52 + ctx.db.ping = () => Promise.resolve(); 19 53 20 - const res = await app.request("/.well-known/did.json", { 21 - headers: { 22 - "Content-Type": "application/json", 23 - }, 24 - }); 54 + const app = createApp(ctx); 55 + const res = await app.request("/xrpc/_health"); 25 56 26 - assertMatch( 27 - await res.text(), 28 - new RegExp( 29 - [ 30 - "^\\{", 31 - '"@context":\\["https://www\\.w3\\.org/ns/did/v1","https://w3id\\.org/security/multikey/v1"\\],', 32 - '"id":"(did:web:[^"]+)",', 33 - '"verificationMethod":\\[\\{', 34 - '"id":"\\1#atproto",', 35 - '"type":"Multikey",', 36 - '"controller":"\\1",', 37 - '"publicKeyMultibase":"[a-zA-Z0-9]+"', 38 - "\\}\\],", 39 - '"service":\\[\\{', 40 - '"id":"#sprk_appview",', 41 - '"type":"SprkAppView",', 42 - '"serviceEndpoint":"https?://[^"]+"', 43 - "\\}\\]", 44 - "\\}$", 45 - ].join(""), 46 - ), 47 - ); 48 - }); 57 + assertEquals(res.status, 200); 58 + const body = await res.json(); 59 + assertEquals(typeof body.version, "string"); 60 + assertEquals(body.error, undefined); 61 + }); 49 62 50 - Deno.test("Mock Context Creation", () => { 51 - const ctx = createMockContext(); 63 + await t.step("returns 503 when DB is unavailable", async () => { 64 + const ctx = createMockContext(); 65 + ctx.db.ping = () => Promise.reject(new Error("Connection failed")); 52 66 53 - assertEquals(typeof ctx.db, "object"); 54 - assertEquals(typeof ctx.dataplane, "object"); 55 - assertEquals(typeof ctx.hydrator, "object"); 56 - assertEquals(typeof ctx.views, "object"); 57 - assertEquals(typeof ctx.authVerifier, "function"); // AuthVerifier is a callable function 58 - assertEquals(typeof ctx.logger, "object"); 59 - assertEquals(typeof ctx.idResolver, "object"); 60 - assertEquals(ctx.cfg.serverDid, "did:web:localhost"); 61 - }); 67 + const app = createApp(ctx); 68 + const res = await app.request("/xrpc/_health"); 62 69 63 - Deno.test("Mock Context with Config Overrides", () => { 64 - const ctx = createMockContext({ 65 - serverDid: "did:web:custom.test", 66 - adminPasswords: ["custom-password"], 70 + assertEquals(res.status, 503); 71 + const body = await res.json(); 72 + assertEquals(typeof body.version, "string"); 73 + assertEquals(body.error, "Database not connected"); 67 74 }); 68 - 69 - assertEquals(ctx.cfg.serverDid, "did:web:custom.test"); 70 - assertEquals(ctx.cfg.adminPasswords, ["custom-password"]); 71 75 });
+309
tests/pagination_test.ts
··· 1 + import { assertEquals, assertThrows } from "@std/assert"; 2 + import { 3 + CreatedAtDidKeyset, 4 + IndexedAtDidKeyset, 5 + IsoTimeKey, 6 + LikeCountCidKeyset, 7 + RkeyKey, 8 + TimeCidKeyset, 9 + } from "../data-plane/db/pagination.ts"; 10 + import { InvalidRequestError } from "@atp/xrpc-server"; 11 + 12 + // GenericKeyset (tested via TimeCidKeyset) 13 + 14 + Deno.test("GenericKeyset", async (t) => { 15 + const keyset = new TimeCidKeyset(); 16 + 17 + await t.step("packCursor uses colon separator", () => { 18 + const cursor = { primary: "abc123", secondary: "def456" }; 19 + const packed = keyset.packCursor(cursor); 20 + assertEquals(packed, "abc123:def456"); 21 + }); 22 + 23 + await t.step("unpackCursor splits on first colon", () => { 24 + const unpacked = keyset.unpackCursor("abc123:def456"); 25 + assertEquals(unpacked?.primary, "abc123"); 26 + assertEquals(unpacked?.secondary, "def456"); 27 + }); 28 + 29 + await t.step("unpackCursor handles colons in secondary", () => { 30 + const unpacked = keyset.unpackCursor("abc123:def:456:789"); 31 + assertEquals(unpacked?.primary, "abc123"); 32 + assertEquals(unpacked?.secondary, "def:456:789"); 33 + }); 34 + 35 + await t.step("unpackCursor throws on missing separator", () => { 36 + assertThrows( 37 + () => keyset.unpackCursor("noseparatorhere"), 38 + InvalidRequestError, 39 + "Malformed cursor: missing separator", 40 + ); 41 + }); 42 + 43 + await t.step("unpackCursor throws on empty primary", () => { 44 + assertThrows( 45 + () => keyset.unpackCursor(":secondary"), 46 + InvalidRequestError, 47 + "Malformed cursor: missing primary or secondary", 48 + ); 49 + }); 50 + 51 + await t.step("unpackCursor throws on empty secondary", () => { 52 + assertThrows( 53 + () => keyset.unpackCursor("primary:"), 54 + InvalidRequestError, 55 + "Malformed cursor: missing primary or secondary", 56 + ); 57 + }); 58 + 59 + await t.step("getFilter returns descending filter by default", () => { 60 + const labeled = { 61 + primary: "2024-01-15T12:00:00.000Z", 62 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 63 + }; 64 + const filter = keyset.getFilter(labeled); 65 + assertEquals(filter, { 66 + $or: [ 67 + { indexedAt: { $lt: "2024-01-15T12:00:00.000Z" } }, 68 + { 69 + indexedAt: "2024-01-15T12:00:00.000Z", 70 + cid: { $lt: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq" }, 71 + }, 72 + ], 73 + }); 74 + }); 75 + 76 + await t.step("getFilter returns ascending filter when specified", () => { 77 + const labeled = { 78 + primary: "2024-01-15T12:00:00.000Z", 79 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 80 + }; 81 + const filter = keyset.getFilter(labeled, "asc"); 82 + assertEquals(filter, { 83 + $or: [ 84 + { indexedAt: { $gt: "2024-01-15T12:00:00.000Z" } }, 85 + { 86 + indexedAt: "2024-01-15T12:00:00.000Z", 87 + cid: { $gt: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq" }, 88 + }, 89 + ], 90 + }); 91 + }); 92 + 93 + await t.step("pack and unpack roundtrip", () => { 94 + const original = { 95 + primary: "2024-01-15T12:00:00.000Z", 96 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 97 + }; 98 + 99 + const packed = keyset.pack(original); 100 + const unpacked = keyset.unpack(packed); 101 + 102 + assertEquals(unpacked?.primary, original.primary); 103 + assertEquals(unpacked?.secondary, original.secondary); 104 + }); 105 + 106 + await t.step("packFromResult with array uses last result", () => { 107 + const results = [ 108 + { indexedAt: "2024-01-15T12:00:00.000Z", cid: "first" }, 109 + { indexedAt: "2024-01-15T13:00:00.000Z", cid: "second" }, 110 + { indexedAt: "2024-01-15T14:00:00.000Z", cid: "last" }, 111 + ]; 112 + 113 + const packed = keyset.packFromResult(results); 114 + const unpacked = keyset.unpack(packed); 115 + 116 + assertEquals(unpacked?.secondary, "last"); 117 + }); 118 + }); 119 + 120 + // TimeCidKeyset (extends GenericKeyset) 121 + 122 + Deno.test("TimeCidKeyset", async (t) => { 123 + const keyset = new TimeCidKeyset(); 124 + 125 + await t.step( 126 + "labelResult uses current time when indexedAt is missing", 127 + () => { 128 + const result = { 129 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 130 + }; 131 + 132 + const before = new Date(); 133 + const labeled = keyset.labelResult(result); 134 + const after = new Date(); 135 + 136 + const labeledDate = new Date(labeled.primary); 137 + assertEquals(labeledDate >= before, true); 138 + assertEquals(labeledDate <= after, true); 139 + }, 140 + ); 141 + 142 + await t.step("labeledResultToCursor converts to base36 seconds", () => { 143 + const labeled = { 144 + primary: "2024-01-15T12:00:00.000Z", 145 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 146 + }; 147 + 148 + const cursor = keyset.labeledResultToCursor(labeled); 149 + 150 + const expectedBase36 = Math.floor( 151 + new Date("2024-01-15T12:00:00.000Z").getTime() / 1000, 152 + ).toString(36); 153 + assertEquals(cursor.primary, expectedBase36); 154 + }); 155 + 156 + await t.step("labeledResultToCursor throws on invalid date", () => { 157 + const labeled = { 158 + primary: "not-a-valid-date", 159 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 160 + }; 161 + 162 + assertThrows( 163 + () => keyset.labeledResultToCursor(labeled), 164 + InvalidRequestError, 165 + "Invalid date for cursor", 166 + ); 167 + }); 168 + 169 + await t.step("cursorToLabeledResult converts from base36", () => { 170 + const base36Seconds = (1705320000).toString(36); 171 + const cursor = { 172 + primary: base36Seconds, 173 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 174 + }; 175 + 176 + const labeled = keyset.cursorToLabeledResult(cursor); 177 + 178 + assertEquals(labeled.primary, "2024-01-15T12:00:00.000Z"); 179 + }); 180 + }); 181 + 182 + Deno.test("TimeCidKeyset subclasses", async (t) => { 183 + await t.step("CreatedAtDidKeyset works with createdAt field", () => { 184 + const keyset = new CreatedAtDidKeyset(); 185 + const packed = keyset.packFromResult({ 186 + createdAt: "2024-01-15T12:00:00.000Z", 187 + authorDid: "did:plc:testuser123", 188 + }); 189 + assertEquals(typeof packed, "string"); 190 + }); 191 + 192 + await t.step("IndexedAtDidKeyset works with indexedAt field", () => { 193 + const keyset = new IndexedAtDidKeyset(); 194 + const packed = keyset.packFromResult({ 195 + indexedAt: "2024-01-15T12:00:00.000Z", 196 + authorDid: "did:plc:testuser123", 197 + }); 198 + assertEquals(typeof packed, "string"); 199 + }); 200 + }); 201 + 202 + // LikeCountCidKeyset (extends GenericKeyset) 203 + 204 + Deno.test("LikeCountCidKeyset", async (t) => { 205 + await t.step("cursorToLabeledResult throws on invalid like count", () => { 206 + const keyset = new LikeCountCidKeyset(); 207 + const cursor = { 208 + primary: "not-a-number", 209 + secondary: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjq", 210 + }; 211 + 212 + assertThrows( 213 + () => keyset.cursorToLabeledResult(cursor), 214 + InvalidRequestError, 215 + "Malformed cursor: invalid like count", 216 + ); 217 + }); 218 + }); 219 + 220 + // GenericSingleKey (tested via RkeyKey) 221 + 222 + Deno.test("GenericSingleKey", async (t) => { 223 + const keyset = new RkeyKey(); 224 + 225 + await t.step("getFilter returns descending filter by default", () => { 226 + const labeled = { primary: "3jui7kd2zcysk" }; 227 + const filter = keyset.getFilter(labeled); 228 + assertEquals(filter, { 229 + key: { $lt: "3jui7kd2zcysk" }, 230 + }); 231 + }); 232 + 233 + await t.step("getFilter returns ascending filter when specified", () => { 234 + const labeled = { primary: "3jui7kd2zcysk" }; 235 + const filter = keyset.getFilter(labeled, "asc"); 236 + assertEquals(filter, { 237 + key: { $gt: "3jui7kd2zcysk" }, 238 + }); 239 + }); 240 + 241 + await t.step("unpackCursor throws on colon separator", () => { 242 + assertThrows( 243 + () => keyset.unpackCursor("has:colon"), 244 + InvalidRequestError, 245 + "Malformed cursor: unexpected separator", 246 + ); 247 + }); 248 + 249 + await t.step("unpackCursor throws on double underscore separator", () => { 250 + assertThrows( 251 + () => keyset.unpackCursor("has__underscore"), 252 + InvalidRequestError, 253 + "Malformed cursor: unexpected separator", 254 + ); 255 + }); 256 + }); 257 + 258 + // IsoTimeKey (extends GenericSingleKey) 259 + 260 + Deno.test("IsoTimeKey", async (t) => { 261 + const keyset = new IsoTimeKey(); 262 + 263 + await t.step("labeledResultToCursor throws on invalid date", () => { 264 + const labeled = { primary: "not-a-valid-date" }; 265 + assertThrows( 266 + () => keyset.labeledResultToCursor(labeled), 267 + InvalidRequestError, 268 + "Invalid date for cursor", 269 + ); 270 + }); 271 + 272 + await t.step("cursorToLabeledResult throws on invalid date", () => { 273 + const cursor = { primary: "invalid-date" }; 274 + assertThrows( 275 + () => keyset.cursorToLabeledResult(cursor), 276 + InvalidRequestError, 277 + "Malformed cursor: invalid date", 278 + ); 279 + }); 280 + 281 + await t.step("pack produces ISO date but unpack fails due to colons", () => { 282 + // IsoTimeKey produces ISO dates with colons, but GenericSingleKey.unpackCursor 283 + // rejects strings with colons. This is a known limitation. 284 + const result = { indexedAt: "2024-01-15T12:00:00.000Z" }; 285 + const packed = keyset.packFromResult(result); 286 + 287 + assertEquals(packed, "2024-01-15T12:00:00.000Z"); 288 + 289 + assertThrows( 290 + () => keyset.unpack(packed), 291 + InvalidRequestError, 292 + "Malformed cursor: unexpected separator", 293 + ); 294 + }); 295 + }); 296 + 297 + // RkeyKey (extends GenericSingleKey) 298 + 299 + Deno.test("RkeyKey", async (t) => { 300 + await t.step("cursorToLabeledResult throws on invalid record key", () => { 301 + const keyset = new RkeyKey(); 302 + const cursor = { primary: "invalid/key" }; 303 + assertThrows( 304 + () => keyset.cursorToLabeledResult(cursor), 305 + InvalidRequestError, 306 + "Malformed cursor", 307 + ); 308 + }); 309 + });
+1
tests/util.ts
··· 247 247 const mockDb = { 248 248 connect: () => Promise.resolve(), 249 249 disconnect: () => Promise.resolve(), 250 + ping: () => Promise.resolve(), 250 251 models: {}, 251 252 getCursorState: () => Promise.resolve(null), 252 253 saveCursorState: () => Promise.resolve(),