Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

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

at main 499 lines 14 kB view raw
1import { l } from "@atp/lex"; 2import { assertEquals, assertRejects } from "@std/assert"; 3import { Client, xrpc, xrpcSafe } from "../mod.ts"; 4import type { XrpcCallCompatibleOptions } from "../types.ts"; 5import { XRPCError, XRPCInvalidResponseError } from "../types.ts"; 6 7type Expect<T extends true> = T; 8type IsNever<T> = [T] extends [never] ? true : false; 9 10Deno.test("calls query with lex method and params", async () => { 11 const method = l.query( 12 "io.example.query", 13 l.params({ limit: l.optional(l.integer()) }), 14 l.jsonPayload({ value: l.string() }), 15 ); 16 17 const client = new Client((url, init) => { 18 assertEquals(url, "/xrpc/io.example.query?limit=7"); 19 assertEquals(init.method, "get"); 20 return Promise.resolve(Response.json({ value: "ok" })); 21 }); 22 23 const result = await client.call(method, { 24 params: { limit: 7 }, 25 }); 26 27 assertEquals(result.data, { value: "ok" }); 28}); 29 30Deno.test("calls query with xrpc", async () => { 31 const method = l.query( 32 "io.example.query", 33 l.params({ limit: l.optional(l.integer()) }), 34 l.jsonPayload({ value: l.string() }), 35 ); 36 37 const client = new Client((url, init) => { 38 assertEquals(url, "/xrpc/io.example.query?limit=9"); 39 assertEquals(init.method, "get"); 40 return Promise.resolve(Response.json({ value: "ok" })); 41 }); 42 43 const result = await client.xrpc(method, { 44 params: { limit: 9 }, 45 }); 46 47 assertEquals(result.data, { value: "ok" }); 48}); 49 50Deno.test("calls top-level xrpc", async () => { 51 const method = l.query( 52 "io.example.query", 53 l.params({ limit: l.optional(l.integer()) }), 54 l.jsonPayload({ value: l.string() }), 55 ); 56 57 const result = await xrpc( 58 (url, init) => { 59 assertEquals(url, "/xrpc/io.example.query?limit=6"); 60 assertEquals(init.method, "get"); 61 return Promise.resolve(Response.json({ value: "ok" })); 62 }, 63 method, 64 { 65 params: { limit: 6 }, 66 }, 67 ); 68 69 assertEquals(result.data, { value: "ok" }); 70}); 71 72Deno.test("narrows xrpcSafe success results on success flag", async () => { 73 const method = l.query( 74 "io.example.query", 75 l.params({ limit: l.optional(l.integer()) }), 76 l.jsonPayload({ value: l.string() }), 77 ); 78 79 const client = new Client((url, init) => { 80 assertEquals(url, "/xrpc/io.example.query?limit=8"); 81 assertEquals(init.method, "get"); 82 return Promise.resolve(Response.json({ value: "ok" })); 83 }); 84 85 const result = await client.xrpcSafe(method, { 86 params: { limit: 8 }, 87 }); 88 89 assertEquals(result.success, true); 90 91 if (result.success) { 92 assertEquals(result.data, { value: "ok" }); 93 } else { 94 throw new Error(result.error); 95 } 96}); 97 98Deno.test("calls top-level xrpcSafe", async () => { 99 const method = l.query( 100 "io.example.query", 101 l.params({ limit: l.optional(l.integer()) }), 102 l.jsonPayload({ value: l.string() }), 103 ); 104 105 const result = await xrpcSafe( 106 () => 107 Promise.resolve( 108 Response.json( 109 { error: "BadRequest", message: "nope" }, 110 { status: 400 }, 111 ), 112 ), 113 method, 114 { 115 params: { limit: 2 }, 116 }, 117 ); 118 119 assertEquals(result.success, false); 120 121 if (!result.success) { 122 assertEquals(result.error, "BadRequest"); 123 assertEquals(result.message, "nope"); 124 } else { 125 throw new Error(JSON.stringify(result.data)); 126 } 127}); 128 129Deno.test("keeps call as a compatibility alias for xrpc", async () => { 130 const method = l.query( 131 "io.example.query", 132 l.params({ limit: l.optional(l.integer()) }), 133 l.jsonPayload({ value: l.string() }), 134 ); 135 136 const client = new Client((url, init) => { 137 assertEquals(url, "/xrpc/io.example.query?limit=4"); 138 assertEquals(init.method, "get"); 139 return Promise.resolve(Response.json({ value: "ok" })); 140 }); 141 142 const result = await client.call(method, { 143 params: { limit: 4 }, 144 }); 145 146 assertEquals(result.data, { value: "ok" }); 147}); 148 149Deno.test("serializes params using schema transforms", async () => { 150 const method = l.query( 151 "io.example.query", 152 l.params({ 153 since: l.optional(l.string({ format: "datetime" })), 154 }), 155 l.jsonPayload({ value: l.string() }), 156 ); 157 158 const client = new Client((url) => { 159 assertEquals( 160 url, 161 "/xrpc/io.example.query?since=2024-01-02T03%3A04%3A05.000Z", 162 ); 163 return Promise.resolve(Response.json({ value: "ok" })); 164 }); 165 166 const result = await client.call(method, { 167 params: { 168 since: new Date("2024-01-02T03:04:05.000Z"), 169 } as unknown as never, 170 }); 171 172 assertEquals(result.data, { value: "ok" }); 173}); 174 175Deno.test("accepts plain strings for formatted query params", async () => { 176 const method = l.query( 177 "io.example.getRecord", 178 l.params({ 179 repo: l.string({ format: "at-identifier" }), 180 collection: l.string({ format: "nsid" }), 181 rkey: l.string({ format: "record-key" }), 182 uri: l.optional(l.string({ format: "uri" })), 183 }), 184 l.payload(), 185 ); 186 187 const client = new Client((url, init) => { 188 assertEquals( 189 url, 190 "/xrpc/io.example.getRecord?repo=did%3Aplc%3A6hbqm2oftpotwuw7gvvrui3i&collection=app.bsky.feed.post&rkey=3mjlhmszzo22h&uri=https%3A%2F%2Fexample.com%2Fpost%2F1", 191 ); 192 assertEquals(init.method, "get"); 193 return Promise.resolve(new Response(null)); 194 }); 195 196 await client.call(method, { 197 params: { 198 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i", 199 collection: "app.bsky.feed.post", 200 rkey: "3mjlhmszzo22h", 201 uri: "https://example.com/post/1", 202 }, 203 }); 204}); 205 206Deno.test("only matching string literals satisfy formatted params", () => { 207 const method = l.query( 208 "io.example.getRecord", 209 l.params({ 210 repo: l.string({ format: "at-identifier" }), 211 collection: l.string({ format: "nsid" }), 212 rkey: l.string({ format: "record-key" }), 213 }), 214 l.payload(), 215 ); 216 217 type Valid = XrpcCallCompatibleOptions<typeof method, { 218 params: { 219 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i"; 220 collection: "app.bsky.feed.post"; 221 rkey: "3mjlhmszzo22h"; 222 }; 223 }>; 224 type InvalidRepo = XrpcCallCompatibleOptions<typeof method, { 225 params: { 226 repo: "not-a-valid-at-identifier"; 227 collection: "app.bsky.feed.post"; 228 rkey: "3mjlhmszzo22h"; 229 }; 230 }>; 231 type GenericRepo = XrpcCallCompatibleOptions<typeof method, { 232 params: { 233 repo: string; 234 collection: "app.bsky.feed.post"; 235 rkey: "3mjlhmszzo22h"; 236 }; 237 }>; 238 239 type ValidParams = NonNullable<Valid["params"]>; 240 type InvalidRepoParams = NonNullable<InvalidRepo["params"]>; 241 type GenericRepoParams = NonNullable<GenericRepo["params"]>; 242 243 type _validRepo = Expect< 244 IsNever<ValidParams["repo"]> extends false ? true : false 245 >; 246 type _invalidRepo = Expect<IsNever<InvalidRepoParams["repo"]>>; 247 type _genericRepo = Expect<IsNever<GenericRepoParams["repo"]>>; 248}); 249 250Deno.test("calls query with namespace main export", async () => { 251 const main = l.query( 252 "io.example.query", 253 l.params({ limit: l.optional(l.integer()) }), 254 l.jsonPayload({ value: l.string() }), 255 ); 256 const namespace = { main } as const; 257 258 const client = new Client((url, init) => { 259 assertEquals(url, "/xrpc/io.example.query?limit=3"); 260 assertEquals(init.method, "get"); 261 return Promise.resolve(Response.json({ value: "ok" })); 262 }); 263 264 const result = await client.call(namespace, { 265 params: { limit: 3 }, 266 }); 267 268 assertEquals(result.data, { value: "ok" }); 269}); 270 271Deno.test("calls query with namespace Main export", async () => { 272 const Main = l.query( 273 "io.example.query", 274 l.params({ limit: l.optional(l.integer()) }), 275 l.jsonPayload({ value: l.string() }), 276 ); 277 const namespace = { Main } as const; 278 279 const client = new Client((url, init) => { 280 assertEquals(url, "/xrpc/io.example.query?limit=5"); 281 assertEquals(init.method, "get"); 282 return Promise.resolve(Response.json({ value: "ok" })); 283 }); 284 285 const result = await client.call(namespace, { 286 params: { limit: 5 }, 287 }); 288 289 assertEquals(result.data, { value: "ok" }); 290}); 291 292Deno.test("validates request and response when enabled", async () => { 293 const method = l.procedure( 294 "io.example.proc", 295 l.params(), 296 l.jsonPayload({ text: l.string() }), 297 l.jsonPayload({ id: l.string() }), 298 ); 299 300 const client = new Client(() => Promise.resolve(Response.json({ id: 123 }))); 301 302 await assertRejects( 303 async () => { 304 await client.call(method, { 305 body: { text: 1 } as unknown as { text: string }, 306 validateRequest: true, 307 }); 308 }, 309 XRPCError, 310 ); 311 312 await assertRejects( 313 async () => { 314 await client.call(method, { 315 body: { text: "hello" }, 316 validateResponse: true, 317 }); 318 }, 319 XRPCInvalidResponseError, 320 ); 321}); 322 323Deno.test("returns xrpc errors from xrpcSafe", async () => { 324 const method = l.query( 325 "io.example.query", 326 l.params({ limit: l.optional(l.integer()) }), 327 l.jsonPayload({ value: l.string() }), 328 ); 329 330 const client = new Client(() => 331 Promise.resolve( 332 Response.json( 333 { error: "BadRequest", message: "nope" }, 334 { status: 400 }, 335 ), 336 ) 337 ); 338 339 const result = await client.xrpcSafe(method, { 340 params: { limit: 1 }, 341 }); 342 343 assertEquals(result.success, false); 344 345 if (!result.success) { 346 assertEquals(result.success, false); 347 assertEquals(result.error, "BadRequest"); 348 assertEquals(result.message, "nope"); 349 } else { 350 throw new Error(JSON.stringify(result.data)); 351 } 352}); 353 354Deno.test("accepts formatted strings in json request bodies", async () => { 355 const method = l.procedure( 356 "io.example.proc", 357 l.params(), 358 l.jsonPayload({ 359 repo: l.string({ format: "at-identifier" }), 360 rkey: l.string({ format: "record-key" }), 361 createdAt: l.string({ format: "datetime" }), 362 }), 363 l.payload(), 364 ); 365 366 const client = new Client((_url, init) => { 367 assertEquals(init.method, "post"); 368 assertEquals( 369 new Headers(init.headers).get("content-type"), 370 "application/json", 371 ); 372 return Promise.resolve(new Response(null)); 373 }); 374 375 await client.call(method, { 376 body: { 377 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i", 378 rkey: "3mjlhmszzo22h", 379 createdAt: "2024-01-02T03:04:05.000Z", 380 }, 381 }); 382}); 383 384Deno.test("only matching string literals satisfy formatted json bodies", () => { 385 const method = l.procedure( 386 "io.example.proc", 387 l.params(), 388 l.jsonPayload({ 389 createdAt: l.string({ format: "datetime" }), 390 uri: l.string({ format: "at-uri" }), 391 cid: l.string({ format: "cid" }), 392 }), 393 l.payload(), 394 ); 395 396 type Valid = XrpcCallCompatibleOptions<typeof method, { 397 body: { 398 createdAt: "2024-01-02T03:04:05.000Z"; 399 uri: 400 "at://did:plc:6hbqm2oftpotwuw7gvvrui3i/app.bsky.feed.post/3mjlhmszzo22h"; 401 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 402 }; 403 }>; 404 type InvalidDatetime = XrpcCallCompatibleOptions<typeof method, { 405 body: { 406 createdAt: "2024-01-02"; 407 uri: 408 "at://did:plc:6hbqm2oftpotwuw7gvvrui3i/app.bsky.feed.post/3mjlhmszzo22h"; 409 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 410 }; 411 }>; 412 type InvalidUri = XrpcCallCompatibleOptions<typeof method, { 413 body: { 414 createdAt: "2024-01-02T03:04:05.000Z"; 415 uri: "https://example.com/post/1"; 416 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 417 }; 418 }>; 419 420 type ValidBody = NonNullable<Valid["body"]>; 421 type InvalidDatetimeBody = NonNullable<InvalidDatetime["body"]>; 422 type InvalidUriBody = NonNullable<InvalidUri["body"]>; 423 424 type _validBody = Expect< 425 IsNever<ValidBody["createdAt"]> extends false ? true : false 426 >; 427 type _invalidDatetime = Expect<IsNever<InvalidDatetimeBody["createdAt"]>>; 428 type _invalidUri = Expect<IsNever<InvalidUriBody["uri"]>>; 429}); 430 431Deno.test("uses method encoding defaults for wildcard payloads", async () => { 432 const method = l.procedure( 433 "io.example.upload", 434 l.params(), 435 l.payload("image/*"), 436 l.jsonPayload({ ok: l.boolean() }), 437 ); 438 439 const client = new Client((_url, init) => { 440 const headers = new Headers(init.headers); 441 assertEquals(headers.get("content-type"), "image/png"); 442 assertEquals(init.method, "post"); 443 return Promise.resolve(Response.json({ ok: true })); 444 }); 445 446 const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }); 447 const result = await client.call(method, { 448 body: blob, 449 }); 450 451 assertEquals(result.data, { ok: true }); 452}); 453 454Deno.test("preserves specific blob types for text wildcard payloads", async () => { 455 const method = l.procedure( 456 "io.example.upload", 457 l.params(), 458 l.payload("text/*"), 459 l.payload(), 460 ); 461 462 const client = new Client((_url, init) => { 463 const headers = new Headers(init.headers); 464 assertEquals(headers.get("content-type"), "text/csv"); 465 return Promise.resolve(new Response(null)); 466 }); 467 468 await client.call(method, { 469 body: new Blob(["a,b\n1,2"], { type: "text/csv" }) as unknown as never, 470 }); 471}); 472 473Deno.test("infers body content types for any wildcard payloads", async () => { 474 const method = l.procedure( 475 "io.example.upload", 476 l.params(), 477 l.payload("*/*"), 478 l.payload(), 479 ); 480 481 const seen: string[] = []; 482 const client = new Client((_url, init) => { 483 seen.push(new Headers(init.headers).get("content-type") ?? ""); 484 return Promise.resolve(new Response(null)); 485 }); 486 487 await client.call(method, { 488 body: "hello", 489 }); 490 491 await client.call(method, { 492 body: new Blob(["<p>ok</p>"], { type: "text/html" }), 493 }); 494 495 assertEquals(seen, [ 496 "text/plain;charset=UTF-8", 497 "text/html", 498 ]); 499});