The Appview for the kipclip.com atproto bookmarking service

Add comprehensive tests with mock dependencies

Add tests that exercise the actual feature flows using fakes
for all external dependencies (OAuth, PDS, URL fetching).

New test files:
- tests/test-helpers.ts: Mock utilities for sessions and fetch
- tests/bookmarks.test.ts: 10 tests for bookmark CRUD
- tests/enrichment.test.ts: 11 tests for URL metadata extraction
- tests/plc-resolver.test.ts: 7 tests for DID resolution

Modified lib/session.ts to add setTestSessionProvider() hook
for injecting mock sessions in tests.

Total: 35 tests, all running without network calls.

+1037
+21
lib/session.ts
··· 8 8 import { captureError } from "./sentry.ts"; 9 9 import { getOAuth } from "./oauth-config.ts"; 10 10 11 + // Test session provider override (set via setTestSessionProvider) 12 + let testSessionProvider: 13 + | ((request: Request) => Promise<SessionResult>) 14 + | null = null; 15 + 16 + /** 17 + * Set a test session provider for testing authenticated routes. 18 + * Call with null to restore default behavior. 19 + * @internal Only for use in tests 20 + */ 21 + export function setTestSessionProvider( 22 + provider: ((request: Request) => Promise<SessionResult>) | null, 23 + ): void { 24 + testSessionProvider = provider; 25 + } 26 + 11 27 // Session configuration from environment 12 28 const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 13 29 if (!COOKIE_SECRET) { ··· 62 78 export async function getSessionFromRequest( 63 79 request: Request, 64 80 ): Promise<SessionResult> { 81 + // Check for test session provider (testing only) 82 + if (testSessionProvider) { 83 + return testSessionProvider(request); 84 + } 85 + 65 86 try { 66 87 // Step 1: Extract session data from cookie using atproto-sessions 67 88 const cookieResult = await sessions.getSessionFromRequest(request);
+386
tests/bookmarks.test.ts
··· 1 + /** 2 + * Tests for bookmark CRUD operations. 3 + * Uses mock session and fetch to avoid network calls. 4 + */ 5 + 6 + import "./test-setup.ts"; 7 + 8 + import { assertEquals, assertExists } from "@std/assert"; 9 + import { app } from "../main.ts"; 10 + import { initOAuth } from "../lib/oauth-config.ts"; 11 + import { setTestSessionProvider } from "../lib/session.ts"; 12 + import { 13 + createHtmlResponse, 14 + createMockSessionResult, 15 + createPdsResponse, 16 + createRecordResponse, 17 + listRecordsResponse, 18 + } from "./test-helpers.ts"; 19 + 20 + // Initialize OAuth with test URL 21 + initOAuth("https://kipclip.com"); 22 + const handler = app.handler(); 23 + 24 + // Store original fetch 25 + const originalFetch = globalThis.fetch; 26 + 27 + // Helper to set up mock fetch for metadata extraction 28 + function mockGlobalFetch(responses: Map<string, Response>) { 29 + globalThis.fetch = ( 30 + input: RequestInfo | URL, 31 + _init?: RequestInit, 32 + ): Promise<Response> => { 33 + const url = typeof input === "string" 34 + ? input 35 + : input instanceof URL 36 + ? input.href 37 + : input.url; 38 + 39 + for (const [pattern, response] of responses) { 40 + if (url.includes(pattern)) { 41 + return Promise.resolve(response.clone()); 42 + } 43 + } 44 + return Promise.resolve(new Response("Not Found", { status: 404 })); 45 + }; 46 + } 47 + 48 + // Restore original fetch after each test 49 + function restoreFetch() { 50 + globalThis.fetch = originalFetch; 51 + } 52 + 53 + Deno.test({ 54 + name: "POST /api/bookmarks - creates bookmark with enriched metadata", 55 + async fn() { 56 + // Set up mock session with PDS responses 57 + const pdsResponses = new Map<string, Response>(); 58 + pdsResponses.set("createRecord", createRecordResponse("abc123", "cid456")); 59 + 60 + setTestSessionProvider(() => 61 + Promise.resolve(createMockSessionResult({ pdsResponses })) 62 + ); 63 + 64 + // Mock fetch for URL metadata extraction 65 + mockGlobalFetch( 66 + new Map([ 67 + [ 68 + "example.com", 69 + createHtmlResponse({ 70 + title: "Example Page", 71 + description: "A test page", 72 + favicon: "/icon.png", 73 + }), 74 + ], 75 + ]), 76 + ); 77 + 78 + try { 79 + const req = new Request("https://kipclip.com/api/bookmarks", { 80 + method: "POST", 81 + headers: { "Content-Type": "application/json" }, 82 + body: JSON.stringify({ url: "https://example.com/page" }), 83 + }); 84 + 85 + const res = await handler(req); 86 + assertEquals(res.status, 200); 87 + 88 + const body = await res.json(); 89 + assertEquals(body.success, true); 90 + assertExists(body.bookmark); 91 + assertEquals(body.bookmark.subject, "https://example.com/page"); 92 + assertEquals(body.bookmark.title, "Example Page"); 93 + assertEquals(body.bookmark.description, "A test page"); 94 + assertEquals(body.bookmark.tags, []); 95 + } finally { 96 + setTestSessionProvider(null); 97 + restoreFetch(); 98 + } 99 + }, 100 + }); 101 + 102 + Deno.test({ 103 + name: "POST /api/bookmarks - returns 400 for missing URL", 104 + async fn() { 105 + setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 106 + 107 + try { 108 + const req = new Request("https://kipclip.com/api/bookmarks", { 109 + method: "POST", 110 + headers: { "Content-Type": "application/json" }, 111 + body: JSON.stringify({}), 112 + }); 113 + 114 + const res = await handler(req); 115 + assertEquals(res.status, 400); 116 + 117 + const body = await res.json(); 118 + assertEquals(body.error, "URL is required"); 119 + } finally { 120 + setTestSessionProvider(null); 121 + } 122 + }, 123 + }); 124 + 125 + Deno.test({ 126 + name: "POST /api/bookmarks - returns 400 for invalid URL format", 127 + async fn() { 128 + setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 129 + 130 + try { 131 + const req = new Request("https://kipclip.com/api/bookmarks", { 132 + method: "POST", 133 + headers: { "Content-Type": "application/json" }, 134 + body: JSON.stringify({ url: "not-a-valid-url" }), 135 + }); 136 + 137 + const res = await handler(req); 138 + assertEquals(res.status, 400); 139 + 140 + const body = await res.json(); 141 + assertEquals(body.error, "Invalid URL format"); 142 + } finally { 143 + setTestSessionProvider(null); 144 + } 145 + }, 146 + }); 147 + 148 + Deno.test({ 149 + name: "POST /api/bookmarks - returns 400 for non-HTTP URL", 150 + async fn() { 151 + setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 152 + 153 + try { 154 + const req = new Request("https://kipclip.com/api/bookmarks", { 155 + method: "POST", 156 + headers: { "Content-Type": "application/json" }, 157 + body: JSON.stringify({ url: "ftp://example.com/file" }), 158 + }); 159 + 160 + const res = await handler(req); 161 + assertEquals(res.status, 400); 162 + 163 + const body = await res.json(); 164 + assertEquals(body.error, "Only HTTP(S) URLs are supported"); 165 + } finally { 166 + setTestSessionProvider(null); 167 + } 168 + }, 169 + }); 170 + 171 + Deno.test({ 172 + name: "GET /api/bookmarks - lists user bookmarks", 173 + async fn() { 174 + const mockBookmarks = [ 175 + { 176 + uri: "at://did:plc:test123/community.lexicon.bookmarks.bookmark/abc", 177 + cid: "cid1", 178 + value: { 179 + subject: "https://example.com/page1", 180 + createdAt: "2025-01-01T00:00:00.000Z", 181 + tags: ["work"], 182 + $enriched: { 183 + title: "Page One", 184 + description: "First page", 185 + }, 186 + }, 187 + }, 188 + { 189 + uri: "at://did:plc:test123/community.lexicon.bookmarks.bookmark/def", 190 + cid: "cid2", 191 + value: { 192 + subject: "https://example.com/page2", 193 + createdAt: "2025-01-02T00:00:00.000Z", 194 + tags: [], 195 + $enriched: { 196 + title: "Page Two", 197 + }, 198 + }, 199 + }, 200 + ]; 201 + 202 + const pdsResponses = new Map<string, Response>(); 203 + pdsResponses.set("listRecords", listRecordsResponse(mockBookmarks)); 204 + 205 + setTestSessionProvider(() => 206 + Promise.resolve(createMockSessionResult({ pdsResponses })) 207 + ); 208 + 209 + try { 210 + const req = new Request("https://kipclip.com/api/bookmarks"); 211 + const res = await handler(req); 212 + 213 + assertEquals(res.status, 200); 214 + 215 + const body = await res.json(); 216 + assertEquals(body.bookmarks.length, 2); 217 + assertEquals(body.bookmarks[0].title, "Page One"); 218 + assertEquals(body.bookmarks[0].tags, ["work"]); 219 + assertEquals(body.bookmarks[1].title, "Page Two"); 220 + } finally { 221 + setTestSessionProvider(null); 222 + } 223 + }, 224 + }); 225 + 226 + Deno.test({ 227 + name: "GET /api/bookmarks - returns empty array when no bookmarks", 228 + async fn() { 229 + const pdsResponses = new Map<string, Response>(); 230 + pdsResponses.set("listRecords", listRecordsResponse([])); 231 + 232 + setTestSessionProvider(() => 233 + Promise.resolve(createMockSessionResult({ pdsResponses })) 234 + ); 235 + 236 + try { 237 + const req = new Request("https://kipclip.com/api/bookmarks"); 238 + const res = await handler(req); 239 + 240 + assertEquals(res.status, 200); 241 + 242 + const body = await res.json(); 243 + assertEquals(body.bookmarks, []); 244 + } finally { 245 + setTestSessionProvider(null); 246 + } 247 + }, 248 + }); 249 + 250 + Deno.test({ 251 + name: "DELETE /api/bookmarks/:rkey - deletes bookmark", 252 + async fn() { 253 + const pdsResponses = new Map<string, Response>(); 254 + pdsResponses.set("deleteRecord", createPdsResponse({ success: true })); 255 + 256 + setTestSessionProvider(() => 257 + Promise.resolve(createMockSessionResult({ pdsResponses })) 258 + ); 259 + 260 + try { 261 + const req = new Request("https://kipclip.com/api/bookmarks/abc123", { 262 + method: "DELETE", 263 + }); 264 + 265 + const res = await handler(req); 266 + assertEquals(res.status, 200); 267 + 268 + const body = await res.json(); 269 + assertEquals(body.success, true); 270 + } finally { 271 + setTestSessionProvider(null); 272 + } 273 + }, 274 + }); 275 + 276 + Deno.test({ 277 + name: "PATCH /api/bookmarks/:rkey - updates bookmark tags", 278 + async fn() { 279 + const existingRecord = { 280 + uri: "at://did:plc:test123/community.lexicon.bookmarks.bookmark/abc", 281 + cid: "cid1", 282 + value: { 283 + subject: "https://example.com/page", 284 + createdAt: "2025-01-01T00:00:00.000Z", 285 + tags: [], 286 + $enriched: { 287 + title: "Original Title", 288 + description: "Original description", 289 + }, 290 + }, 291 + }; 292 + 293 + const pdsResponses = new Map<string, Response>(); 294 + pdsResponses.set("getRecord", createPdsResponse(existingRecord)); 295 + pdsResponses.set( 296 + "putRecord", 297 + createPdsResponse({ 298 + uri: existingRecord.uri, 299 + cid: "newcid", 300 + }), 301 + ); 302 + 303 + setTestSessionProvider(() => 304 + Promise.resolve(createMockSessionResult({ pdsResponses })) 305 + ); 306 + 307 + try { 308 + const req = new Request("https://kipclip.com/api/bookmarks/abc", { 309 + method: "PATCH", 310 + headers: { "Content-Type": "application/json" }, 311 + body: JSON.stringify({ tags: ["work", "important"] }), 312 + }); 313 + 314 + const res = await handler(req); 315 + assertEquals(res.status, 200); 316 + 317 + const body = await res.json(); 318 + assertEquals(body.success, true); 319 + assertEquals(body.bookmark.tags, ["work", "important"]); 320 + } finally { 321 + setTestSessionProvider(null); 322 + } 323 + }, 324 + }); 325 + 326 + Deno.test({ 327 + name: "PATCH /api/bookmarks/:rkey - returns 400 for invalid tags", 328 + async fn() { 329 + setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 330 + 331 + try { 332 + const req = new Request("https://kipclip.com/api/bookmarks/abc", { 333 + method: "PATCH", 334 + headers: { "Content-Type": "application/json" }, 335 + body: JSON.stringify({ tags: "not-an-array" }), 336 + }); 337 + 338 + const res = await handler(req); 339 + assertEquals(res.status, 400); 340 + 341 + const body = await res.json(); 342 + assertEquals(body.error, "Tags must be an array"); 343 + } finally { 344 + setTestSessionProvider(null); 345 + } 346 + }, 347 + }); 348 + 349 + Deno.test({ 350 + name: "POST /api/bookmarks - handles PDS error gracefully", 351 + async fn() { 352 + const pdsResponses = new Map<string, Response>(); 353 + pdsResponses.set( 354 + "createRecord", 355 + new Response("Internal Server Error", { status: 500 }), 356 + ); 357 + 358 + setTestSessionProvider(() => 359 + Promise.resolve(createMockSessionResult({ pdsResponses })) 360 + ); 361 + 362 + // Mock fetch for metadata (still needs to work) 363 + mockGlobalFetch( 364 + new Map([ 365 + ["example.com", createHtmlResponse({ title: "Test" })], 366 + ]), 367 + ); 368 + 369 + try { 370 + const req = new Request("https://kipclip.com/api/bookmarks", { 371 + method: "POST", 372 + headers: { "Content-Type": "application/json" }, 373 + body: JSON.stringify({ url: "https://example.com/page" }), 374 + }); 375 + 376 + const res = await handler(req); 377 + assertEquals(res.status, 500); 378 + 379 + const body = await res.json(); 380 + assertExists(body.error); 381 + } finally { 382 + setTestSessionProvider(null); 383 + restoreFetch(); 384 + } 385 + }, 386 + });
+240
tests/enrichment.test.ts
··· 1 + /** 2 + * Tests for URL metadata extraction. 3 + * Uses mock fetcher to avoid network calls. 4 + */ 5 + 6 + import "./test-setup.ts"; 7 + 8 + import { assertEquals } from "@std/assert"; 9 + import { extractUrlMetadataWithFetcher } from "../lib/enrichment.ts"; 10 + import { createHtmlResponse, createMockFetcher } from "./test-helpers.ts"; 11 + 12 + Deno.test("extractUrlMetadata - parses title from <title> tag", async () => { 13 + const mockFetcher = createMockFetcher( 14 + new Map([ 15 + [ 16 + "example.com", 17 + createHtmlResponse({ title: "Example Page Title" }), 18 + ], 19 + ]), 20 + ); 21 + 22 + const metadata = await extractUrlMetadataWithFetcher( 23 + "https://example.com/page", 24 + mockFetcher, 25 + ); 26 + 27 + assertEquals(metadata.title, "Example Page Title"); 28 + }); 29 + 30 + Deno.test("extractUrlMetadata - parses og:title as fallback", async () => { 31 + const mockFetcher = createMockFetcher( 32 + new Map([ 33 + [ 34 + "example.com", 35 + createHtmlResponse({ ogTitle: "OG Title Fallback" }), 36 + ], 37 + ]), 38 + ); 39 + 40 + const metadata = await extractUrlMetadataWithFetcher( 41 + "https://example.com/page", 42 + mockFetcher, 43 + ); 44 + 45 + assertEquals(metadata.title, "OG Title Fallback"); 46 + }); 47 + 48 + Deno.test("extractUrlMetadata - prefers <title> over og:title", async () => { 49 + const mockFetcher = createMockFetcher( 50 + new Map([ 51 + [ 52 + "example.com", 53 + createHtmlResponse({ 54 + title: "HTML Title", 55 + ogTitle: "OG Title", 56 + }), 57 + ], 58 + ]), 59 + ); 60 + 61 + const metadata = await extractUrlMetadataWithFetcher( 62 + "https://example.com/page", 63 + mockFetcher, 64 + ); 65 + 66 + assertEquals(metadata.title, "HTML Title"); 67 + }); 68 + 69 + Deno.test("extractUrlMetadata - parses description from meta tag", async () => { 70 + const mockFetcher = createMockFetcher( 71 + new Map([ 72 + [ 73 + "example.com", 74 + createHtmlResponse({ 75 + title: "Title", 76 + description: "This is the page description", 77 + }), 78 + ], 79 + ]), 80 + ); 81 + 82 + const metadata = await extractUrlMetadataWithFetcher( 83 + "https://example.com/page", 84 + mockFetcher, 85 + ); 86 + 87 + assertEquals(metadata.description, "This is the page description"); 88 + }); 89 + 90 + Deno.test("extractUrlMetadata - parses og:description as fallback", async () => { 91 + const mockFetcher = createMockFetcher( 92 + new Map([ 93 + [ 94 + "example.com", 95 + createHtmlResponse({ 96 + title: "Title", 97 + ogDescription: "OG Description", 98 + }), 99 + ], 100 + ]), 101 + ); 102 + 103 + const metadata = await extractUrlMetadataWithFetcher( 104 + "https://example.com/page", 105 + mockFetcher, 106 + ); 107 + 108 + assertEquals(metadata.description, "OG Description"); 109 + }); 110 + 111 + Deno.test("extractUrlMetadata - extracts favicon URL", async () => { 112 + const mockFetcher = createMockFetcher( 113 + new Map([ 114 + [ 115 + "example.com", 116 + createHtmlResponse({ 117 + title: "Title", 118 + favicon: "/images/favicon.png", 119 + }), 120 + ], 121 + ]), 122 + ); 123 + 124 + const metadata = await extractUrlMetadataWithFetcher( 125 + "https://example.com/page", 126 + mockFetcher, 127 + ); 128 + 129 + assertEquals(metadata.favicon, "https://example.com/images/favicon.png"); 130 + }); 131 + 132 + Deno.test("extractUrlMetadata - defaults favicon to /favicon.ico", async () => { 133 + const mockFetcher = createMockFetcher( 134 + new Map([ 135 + [ 136 + "example.com", 137 + createHtmlResponse({ title: "Title" }), 138 + ], 139 + ]), 140 + ); 141 + 142 + const metadata = await extractUrlMetadataWithFetcher( 143 + "https://example.com/page", 144 + mockFetcher, 145 + ); 146 + 147 + assertEquals(metadata.favicon, "https://example.com/favicon.ico"); 148 + }); 149 + 150 + Deno.test("extractUrlMetadata - handles fetch failure gracefully", async () => { 151 + const mockFetcher = createMockFetcher( 152 + new Map([ 153 + [ 154 + "example.com", 155 + new Response("Internal Server Error", { status: 500 }), 156 + ], 157 + ]), 158 + ); 159 + 160 + const metadata = await extractUrlMetadataWithFetcher( 161 + "https://example.com/page", 162 + mockFetcher, 163 + ); 164 + 165 + // Should return hostname as title on error 166 + assertEquals(metadata.title, "example.com"); 167 + }); 168 + 169 + Deno.test("extractUrlMetadata - handles non-HTML content", async () => { 170 + const mockFetcher = createMockFetcher( 171 + new Map([ 172 + [ 173 + "example.com", 174 + new Response('{"data": "json"}', { 175 + status: 200, 176 + headers: { "Content-Type": "application/json" }, 177 + }), 178 + ], 179 + ]), 180 + ); 181 + 182 + const metadata = await extractUrlMetadataWithFetcher( 183 + "https://example.com/api/data", 184 + mockFetcher, 185 + ); 186 + 187 + // Should return hostname for non-HTML 188 + assertEquals(metadata.title, "example.com"); 189 + }); 190 + 191 + Deno.test("extractUrlMetadata - decodes HTML entities in title", async () => { 192 + const html = ` 193 + <!DOCTYPE html> 194 + <html> 195 + <head><title>Tom &amp; Jerry&#39;s &quot;Show&quot;</title></head> 196 + <body></body> 197 + </html>`; 198 + 199 + const mockFetcher = createMockFetcher( 200 + new Map([ 201 + [ 202 + "example.com", 203 + new Response(html, { 204 + status: 200, 205 + headers: { "Content-Type": "text/html" }, 206 + }), 207 + ], 208 + ]), 209 + ); 210 + 211 + const metadata = await extractUrlMetadataWithFetcher( 212 + "https://example.com/page", 213 + mockFetcher, 214 + ); 215 + 216 + assertEquals(metadata.title, 'Tom & Jerry\'s "Show"'); 217 + }); 218 + 219 + Deno.test("extractUrlMetadata - uses hostname when no title found", async () => { 220 + const html = `<!DOCTYPE html><html><head></head><body></body></html>`; 221 + 222 + const mockFetcher = createMockFetcher( 223 + new Map([ 224 + [ 225 + "example.com", 226 + new Response(html, { 227 + status: 200, 228 + headers: { "Content-Type": "text/html" }, 229 + }), 230 + ], 231 + ]), 232 + ); 233 + 234 + const metadata = await extractUrlMetadataWithFetcher( 235 + "https://example.com/page", 236 + mockFetcher, 237 + ); 238 + 239 + assertEquals(metadata.title, "example.com"); 240 + });
+160
tests/plc-resolver.test.ts
··· 1 + /** 2 + * Tests for PLC directory resolution. 3 + * Uses mock fetcher to avoid network calls. 4 + */ 5 + 6 + import "./test-setup.ts"; 7 + 8 + import { assertEquals } from "@std/assert"; 9 + import { resolveDidWithFetcher } from "../lib/plc-resolver.ts"; 10 + import { createMockFetcher, createPlcResponse } from "./test-helpers.ts"; 11 + 12 + Deno.test("resolveDid - resolves valid DID to PDS URL and handle", async () => { 13 + const mockFetcher = createMockFetcher( 14 + new Map([ 15 + [ 16 + "plc.directory/did:plc:test123", 17 + createPlcResponse({ 18 + did: "did:plc:test123", 19 + pdsUrl: "https://bsky.social", 20 + handle: "alice.bsky.social", 21 + }), 22 + ], 23 + ]), 24 + ); 25 + 26 + const result = await resolveDidWithFetcher("did:plc:test123", mockFetcher); 27 + 28 + assertEquals(result?.did, "did:plc:test123"); 29 + assertEquals(result?.pdsUrl, "https://bsky.social"); 30 + assertEquals(result?.handle, "alice.bsky.social"); 31 + }); 32 + 33 + Deno.test("resolveDid - returns null for 404 DID", async () => { 34 + const mockFetcher = createMockFetcher( 35 + new Map([ 36 + [ 37 + "plc.directory", 38 + new Response("Not Found", { status: 404 }), 39 + ], 40 + ]), 41 + ); 42 + 43 + const result = await resolveDidWithFetcher( 44 + "did:plc:nonexistent", 45 + mockFetcher, 46 + ); 47 + 48 + assertEquals(result, null); 49 + }); 50 + 51 + Deno.test("resolveDid - returns null for non-DID input", async () => { 52 + const mockFetcher = createMockFetcher(new Map()); 53 + 54 + const result = await resolveDidWithFetcher("not-a-did", mockFetcher); 55 + 56 + assertEquals(result, null); 57 + }); 58 + 59 + Deno.test("resolveDid - returns null for DID without PDS service", async () => { 60 + const mockFetcher = createMockFetcher( 61 + new Map([ 62 + [ 63 + "plc.directory/did:plc:nopds", 64 + new Response( 65 + JSON.stringify({ 66 + id: "did:plc:nopds", 67 + alsoKnownAs: ["at://test.handle"], 68 + service: [], // No services 69 + }), 70 + { 71 + status: 200, 72 + headers: { "Content-Type": "application/json" }, 73 + }, 74 + ), 75 + ], 76 + ]), 77 + ); 78 + 79 + const result = await resolveDidWithFetcher("did:plc:nopds", mockFetcher); 80 + 81 + assertEquals(result, null); 82 + }); 83 + 84 + Deno.test("resolveDid - extracts handle from alsoKnownAs", async () => { 85 + const mockFetcher = createMockFetcher( 86 + new Map([ 87 + [ 88 + "plc.directory/did:plc:test", 89 + new Response( 90 + JSON.stringify({ 91 + id: "did:plc:test", 92 + alsoKnownAs: ["at://custom.handle.example"], 93 + service: [ 94 + { 95 + id: "#atproto_pds", 96 + type: "AtprotoPersonalDataServer", 97 + serviceEndpoint: "https://pds.example.com", 98 + }, 99 + ], 100 + }), 101 + { 102 + status: 200, 103 + headers: { "Content-Type": "application/json" }, 104 + }, 105 + ), 106 + ], 107 + ]), 108 + ); 109 + 110 + const result = await resolveDidWithFetcher("did:plc:test", mockFetcher); 111 + 112 + assertEquals(result?.handle, "custom.handle.example"); 113 + }); 114 + 115 + Deno.test("resolveDid - uses DID as handle fallback", async () => { 116 + const mockFetcher = createMockFetcher( 117 + new Map([ 118 + [ 119 + "plc.directory/did:plc:nohandle", 120 + new Response( 121 + JSON.stringify({ 122 + id: "did:plc:nohandle", 123 + alsoKnownAs: [], // No alsoKnownAs 124 + service: [ 125 + { 126 + id: "#atproto_pds", 127 + type: "AtprotoPersonalDataServer", 128 + serviceEndpoint: "https://pds.example.com", 129 + }, 130 + ], 131 + }), 132 + { 133 + status: 200, 134 + headers: { "Content-Type": "application/json" }, 135 + }, 136 + ), 137 + ], 138 + ]), 139 + ); 140 + 141 + const result = await resolveDidWithFetcher("did:plc:nohandle", mockFetcher); 142 + 143 + assertEquals(result?.handle, "did:plc:nohandle"); 144 + }); 145 + 146 + Deno.test("resolveDid - handles network error gracefully", async () => { 147 + const mockFetcher = createMockFetcher( 148 + new Map([ 149 + [ 150 + "plc.directory", 151 + new Response("Internal Server Error", { status: 500 }), 152 + ], 153 + ]), 154 + ); 155 + 156 + const result = await resolveDidWithFetcher("did:plc:error", mockFetcher); 157 + 158 + // Should return null on error (after logging) 159 + assertEquals(result, null); 160 + });
+230
tests/test-helpers.ts
··· 1 + /** 2 + * Test helpers for creating mock dependencies. 3 + * Enables testing without network calls or external services. 4 + */ 5 + 6 + import type { SessionInterface } from "@tijs/atproto-oauth"; 7 + import type { SessionResult } from "../lib/session.ts"; 8 + 9 + /** 10 + * Mock session for testing authenticated routes. 11 + */ 12 + export interface MockSessionOptions { 13 + did?: string; 14 + pdsUrl?: string; 15 + handle?: string; 16 + /** Mock responses for makeRequest calls, keyed by URL pattern */ 17 + pdsResponses?: Map<string, Response>; 18 + /** Default response if no pattern matches */ 19 + defaultPdsResponse?: Response; 20 + } 21 + 22 + /** 23 + * Create a mock OAuth session for testing. 24 + */ 25 + export function createMockSession( 26 + options: MockSessionOptions = {}, 27 + ): SessionInterface { 28 + const { 29 + did = "did:plc:test123", 30 + pdsUrl = "https://test.pds.example", 31 + handle = "test.handle", 32 + pdsResponses = new Map(), 33 + defaultPdsResponse = new Response(JSON.stringify({ success: true }), { 34 + status: 200, 35 + headers: { "Content-Type": "application/json" }, 36 + }), 37 + } = options; 38 + 39 + return { 40 + did, 41 + pdsUrl, 42 + handle, 43 + makeRequest: ( 44 + _method: string, 45 + endpoint: string, 46 + _options?: { body?: unknown; headers?: Record<string, string> }, 47 + ): Promise<Response> => { 48 + // Check for matching response 49 + for (const [pattern, response] of pdsResponses) { 50 + if (endpoint.includes(pattern)) { 51 + // Clone response since Response can only be consumed once 52 + return Promise.resolve(response.clone()); 53 + } 54 + } 55 + return Promise.resolve(defaultPdsResponse.clone()); 56 + }, 57 + } as SessionInterface; 58 + } 59 + 60 + /** 61 + * Create a successful session result for testing. 62 + */ 63 + export function createMockSessionResult( 64 + options: MockSessionOptions = {}, 65 + ): SessionResult { 66 + return { 67 + session: createMockSession(options), 68 + setCookieHeader: "sid=mock-session-id; Path=/; HttpOnly; SameSite=Lax", 69 + }; 70 + } 71 + 72 + /** 73 + * Create a mock PDS response for common operations. 74 + */ 75 + export function createPdsResponse( 76 + data: unknown, 77 + status = 200, 78 + ): Response { 79 + return new Response(JSON.stringify(data), { 80 + status, 81 + headers: { "Content-Type": "application/json" }, 82 + }); 83 + } 84 + 85 + /** 86 + * Create a mock PDS response for createRecord. 87 + */ 88 + export function createRecordResponse( 89 + rkey = "test-rkey-123", 90 + cid = "bafytest123", 91 + ): Response { 92 + return createPdsResponse({ 93 + uri: `at://did:plc:test123/community.lexicon.bookmarks.bookmark/${rkey}`, 94 + cid, 95 + }); 96 + } 97 + 98 + /** 99 + * Create a mock PDS response for listRecords. 100 + */ 101 + export function listRecordsResponse( 102 + records: Array<{ uri: string; cid: string; value: unknown }>, 103 + cursor?: string, 104 + ): Response { 105 + return createPdsResponse({ records, cursor }); 106 + } 107 + 108 + /** 109 + * Create a mock fetch function for URL metadata extraction. 110 + * Returns HTML with the specified metadata. 111 + */ 112 + export function createMockFetcher( 113 + responses: Map<string, Response | (() => Response)>, 114 + ): typeof fetch { 115 + return (input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => { 116 + const url = typeof input === "string" 117 + ? input 118 + : input instanceof URL 119 + ? input.href 120 + : input.url; 121 + 122 + for (const [pattern, response] of responses) { 123 + if (url.includes(pattern)) { 124 + const res = typeof response === "function" 125 + ? response() 126 + : response.clone(); 127 + return Promise.resolve(res); 128 + } 129 + } 130 + 131 + // Default: return 404 132 + return Promise.resolve(new Response("Not Found", { status: 404 })); 133 + }; 134 + } 135 + 136 + /** 137 + * Create a mock HTML response with metadata. 138 + */ 139 + export function createHtmlResponse( 140 + options: { 141 + title?: string; 142 + ogTitle?: string; 143 + description?: string; 144 + ogDescription?: string; 145 + favicon?: string; 146 + } = {}, 147 + ): Response { 148 + const { title, ogTitle, description, ogDescription, favicon } = options; 149 + 150 + const html = ` 151 + <!DOCTYPE html> 152 + <html> 153 + <head> 154 + ${title ? `<title>${title}</title>` : ""} 155 + ${ogTitle ? `<meta property="og:title" content="${ogTitle}">` : ""} 156 + ${description ? `<meta name="description" content="${description}">` : ""} 157 + ${ 158 + ogDescription 159 + ? `<meta property="og:description" content="${ogDescription}">` 160 + : "" 161 + } 162 + ${favicon ? `<link rel="icon" href="${favicon}">` : ""} 163 + </head> 164 + <body></body> 165 + </html>`; 166 + 167 + return new Response(html, { 168 + status: 200, 169 + headers: { "Content-Type": "text/html" }, 170 + }); 171 + } 172 + 173 + /** 174 + * Create a mock PLC directory response. 175 + */ 176 + export function createPlcResponse( 177 + options: { 178 + did?: string; 179 + pdsUrl?: string; 180 + handle?: string; 181 + } = {}, 182 + ): Response { 183 + const { 184 + did = "did:plc:test123", 185 + pdsUrl = "https://test.pds.example", 186 + handle = "test.handle", 187 + } = options; 188 + 189 + return new Response( 190 + JSON.stringify({ 191 + id: did, 192 + alsoKnownAs: [`at://${handle}`], 193 + service: [ 194 + { 195 + id: "#atproto_pds", 196 + type: "AtprotoPersonalDataServer", 197 + serviceEndpoint: pdsUrl, 198 + }, 199 + ], 200 + }), 201 + { 202 + status: 200, 203 + headers: { "Content-Type": "application/json" }, 204 + }, 205 + ); 206 + } 207 + 208 + // Session override for testing 209 + let mockSessionProvider: 210 + | ((request: Request) => Promise<SessionResult>) 211 + | null = null; 212 + 213 + /** 214 + * Set a mock session provider for testing. 215 + * Call with null to restore default behavior. 216 + */ 217 + export function setMockSessionProvider( 218 + provider: ((request: Request) => Promise<SessionResult>) | null, 219 + ): void { 220 + mockSessionProvider = provider; 221 + } 222 + 223 + /** 224 + * Get the current mock session provider, if set. 225 + */ 226 + export function getMockSessionProvider(): 227 + | ((request: Request) => Promise<SessionResult>) 228 + | null { 229 + return mockSessionProvider; 230 + }