@atproto/lex-client 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/xrpc.test.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { assert, describe, expect, it, vi } from 'vitest'
1
+ import { assert, describe, expect, expectTypeOf, it, vi } from 'vitest'
2
+ import { parseCid } from '@atproto/lex-data'
3
+ import { lexToJson } from '@atproto/lex-json'
2
4
  import { l } from '@atproto/lex-schema'
3
5
  import { FetchHandler } from './agent.js'
4
6
  import {
@@ -14,6 +16,16 @@ import { xrpc, xrpcSafe } from './xrpc.js'
14
16
 
15
17
  // Fixtures
16
18
 
19
+ const rawCid = parseCid(
20
+ 'bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4',
21
+ { flavor: 'raw' },
22
+ )
23
+
24
+ const cborCid = parseCid(
25
+ 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
26
+ { flavor: 'cbor' },
27
+ )
28
+
17
29
  const testQuery = l.query(
18
30
  'io.example.testQuery',
19
31
  l.params({ limit: l.optional(l.integer()) }),
@@ -48,11 +60,33 @@ const testNoOutputQuery = l.query(
48
60
  l.payload(),
49
61
  )
50
62
 
63
+ const testQueryWithDefaults = l.query(
64
+ 'io.example.testQueryWithDefaults',
65
+ l.params({ foo: l.optional(l.withDefault(l.string(), 'foo-default')) }),
66
+ l.jsonPayload({
67
+ foo: l.string(),
68
+ bar: l.optional(l.withDefault(l.string(), 'bar-default')),
69
+ }),
70
+ )
71
+
72
+ const testQueryGetBlobRef = l.query(
73
+ 'io.example.testQueryGetBlobRef',
74
+ l.params(),
75
+ l.jsonPayload({
76
+ blobRef: l.blob({
77
+ allowLegacy: false,
78
+ accept: ['image/png'],
79
+ maxSize: 10,
80
+ }),
81
+ }),
82
+ )
83
+
51
84
  describe(xrpc, () => {
52
85
  describe('success paths', () => {
53
86
  it('returns parsed JSON body for a query', async () => {
54
- const fetchHandler: FetchHandler = async () =>
55
- Response.json({ value: 'hello' })
87
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
88
+ return Response.json({ value: 'hello' })
89
+ })
56
90
 
57
91
  const response = await xrpc(fetchHandler, testQuery, {
58
92
  params: { limit: 10 },
@@ -68,8 +102,9 @@ describe(xrpc, () => {
68
102
  })
69
103
 
70
104
  it('returns parsed JSON body for a procedure', async () => {
71
- const fetchHandler: FetchHandler = async () =>
72
- Response.json({ id: 'abc123' })
105
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
106
+ return Response.json({ id: 'abc123' })
107
+ })
73
108
 
74
109
  const response = await xrpc(fetchHandler, testProcedure, {
75
110
  body: { text: 'hello world' },
@@ -83,10 +118,11 @@ describe(xrpc, () => {
83
118
 
84
119
  it('returns binary body for a binary query', async () => {
85
120
  const bytes = new Uint8Array([1, 2, 3, 4])
86
- const fetchHandler: FetchHandler = async () =>
87
- new Response(bytes, {
121
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
122
+ return new Response(bytes, {
88
123
  headers: { 'content-type': 'application/octet-stream' },
89
124
  })
125
+ })
90
126
 
91
127
  const response = await xrpc(fetchHandler, testBinaryQuery)
92
128
 
@@ -99,10 +135,11 @@ describe(xrpc, () => {
99
135
 
100
136
  it('returns binary body for a binary procedure', async () => {
101
137
  const bytes = new Uint8Array([10, 20, 30])
102
- const fetchHandler: FetchHandler = async () =>
103
- new Response(bytes, {
138
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
139
+ return new Response(bytes, {
104
140
  headers: { 'content-type': 'application/octet-stream' },
105
141
  })
142
+ })
106
143
 
107
144
  const response = await xrpc(fetchHandler, testBinaryProcedure, {
108
145
  body: new Uint8Array([99]),
@@ -115,8 +152,9 @@ describe(xrpc, () => {
115
152
  })
116
153
 
117
154
  it('returns no body for a no-output query', async () => {
118
- const fetchHandler: FetchHandler = async () =>
119
- new Response(null, { status: 200 })
155
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
156
+ return new Response(null, { status: 200 })
157
+ })
120
158
 
121
159
  const response = await xrpc(fetchHandler, testNoOutputQuery)
122
160
 
@@ -127,9 +165,9 @@ describe(xrpc, () => {
127
165
  })
128
166
 
129
167
  it('passes query params as URL search params', async () => {
130
- const fetchHandler = vi.fn<FetchHandler>(async () =>
131
- Response.json({ value: 'ok' }),
132
- )
168
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
169
+ return Response.json({ value: 'ok' })
170
+ })
133
171
 
134
172
  await xrpc(fetchHandler, testQuery, { params: { limit: 25 } })
135
173
 
@@ -140,9 +178,9 @@ describe(xrpc, () => {
140
178
  })
141
179
 
142
180
  it('sends POST with JSON body for procedures', async () => {
143
- const fetchHandler = vi.fn<FetchHandler>(async () =>
144
- Response.json({ id: 'new-id' }),
145
- )
181
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
182
+ return Response.json({ id: 'new-id' })
183
+ })
146
184
 
147
185
  await xrpc(fetchHandler, testProcedure, {
148
186
  body: { text: 'test content' },
@@ -157,9 +195,9 @@ describe(xrpc, () => {
157
195
  })
158
196
 
159
197
  it('forwards custom headers', async () => {
160
- const fetchHandler = vi.fn<FetchHandler>(async () =>
161
- Response.json({ value: 'ok' }),
162
- )
198
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
199
+ return Response.json({ value: 'ok' })
200
+ })
163
201
 
164
202
  await xrpc(fetchHandler, testQuery, {
165
203
  params: { limit: 1 },
@@ -174,8 +212,9 @@ describe(xrpc, () => {
174
212
  })
175
213
 
176
214
  it('accepts optional params as omitted', async () => {
177
- const fetchHandler: FetchHandler = async () =>
178
- Response.json({ value: 'ok' })
215
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
216
+ return Response.json({ value: 'ok' })
217
+ })
179
218
 
180
219
  const response = await xrpc(fetchHandler, testQuery)
181
220
 
@@ -187,9 +226,9 @@ describe(xrpc, () => {
187
226
  describe('error handling', () => {
188
227
  describe('fetch errors', () => {
189
228
  it('throws XrpcFetchError when fetchHandler throws', async () => {
190
- const fetchHandler: FetchHandler = async () => {
229
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
191
230
  throw new TypeError('fetch failed')
192
- }
231
+ })
193
232
 
194
233
  await expect(
195
234
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -203,9 +242,9 @@ describe(xrpc, () => {
203
242
  })
204
243
 
205
244
  it('throws XrpcFetchError when fetchHandler rejects', async () => {
206
- const fetchHandler: FetchHandler = async () => {
245
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
207
246
  throw new Error('network timeout')
208
- }
247
+ })
209
248
 
210
249
  await expect(
211
250
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -220,11 +259,12 @@ describe(xrpc, () => {
220
259
 
221
260
  describe('response errors', () => {
222
261
  it('throws XrpcResponseError for 400 with valid error payload', async () => {
223
- const fetchHandler: FetchHandler = async () =>
224
- Response.json(
262
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
263
+ return Response.json(
225
264
  { error: 'TestError', message: 'bad request' },
226
265
  { status: 400 },
227
266
  )
267
+ })
228
268
 
229
269
  await expect(
230
270
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -240,11 +280,12 @@ describe(xrpc, () => {
240
280
  })
241
281
 
242
282
  it('throws XrpcAuthenticationError for 401', async () => {
243
- const fetchHandler: FetchHandler = async () =>
244
- Response.json(
283
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
284
+ return Response.json(
245
285
  { error: 'AuthenticationRequired', message: 'Token expired' },
246
286
  { status: 401 },
247
287
  )
288
+ })
248
289
 
249
290
  await expect(
250
291
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -257,11 +298,12 @@ describe(xrpc, () => {
257
298
  })
258
299
 
259
300
  it('throws XrpcUpstreamError for non-XRPC error response', async () => {
260
- const fetchHandler: FetchHandler = async () =>
261
- new Response('Not Found', {
301
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
302
+ return new Response('Not Found', {
262
303
  status: 404,
263
304
  headers: { 'content-type': 'text/plain' },
264
305
  })
306
+ })
265
307
 
266
308
  await expect(
267
309
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -273,11 +315,12 @@ describe(xrpc, () => {
273
315
  })
274
316
 
275
317
  it('throws XrpcUpstreamError for 500 without valid error payload', async () => {
276
- const fetchHandler: FetchHandler = async () =>
277
- new Response('Internal Server Error', {
318
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
319
+ return new Response('Internal Server Error', {
278
320
  status: 500,
279
321
  headers: { 'content-type': 'text/html' },
280
322
  })
323
+ })
281
324
 
282
325
  await expect(
283
326
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -289,11 +332,12 @@ describe(xrpc, () => {
289
332
  })
290
333
 
291
334
  it('Reflects upstream 5xx errors with valid XRPC payload', async () => {
292
- const fetchHandler: FetchHandler = async () =>
293
- Response.json(
335
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
336
+ return Response.json(
294
337
  { error: 'ServerError', message: 'Something went wrong' },
295
338
  { status: 502 },
296
339
  )
340
+ })
297
341
 
298
342
  await expect(
299
343
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -312,8 +356,9 @@ describe(xrpc, () => {
312
356
  describe('invalid response errors', () => {
313
357
  it('throws XrpcInvalidResponseError when response body fails validation', async () => {
314
358
  // Schema expects { value: string } but we return { value: 123 }
315
- const fetchHandler: FetchHandler = async () =>
316
- Response.json({ value: 123 })
359
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
360
+ return Response.json({ value: 123 })
361
+ })
317
362
 
318
363
  await expect(
319
364
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -326,11 +371,12 @@ describe(xrpc, () => {
326
371
  })
327
372
 
328
373
  it('throws XrpcUpstreamError when response has wrong content-type', async () => {
329
- const fetchHandler: FetchHandler = async () =>
330
- new Response('binary data', {
374
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
375
+ return new Response('binary data', {
331
376
  status: 200,
332
377
  headers: { 'content-type': 'text/plain' },
333
378
  })
379
+ })
334
380
 
335
381
  await expect(
336
382
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -344,8 +390,9 @@ describe(xrpc, () => {
344
390
 
345
391
  describe('content-type header errors', () => {
346
392
  it('throws XrpcInternalError when content-type header is set', async () => {
347
- const fetchHandler: FetchHandler = async () =>
348
- Response.json({ value: 'ok' })
393
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
394
+ return Response.json({ value: 'ok' })
395
+ })
349
396
 
350
397
  await expect(
351
398
  xrpc(fetchHandler, testQuery, {
@@ -362,17 +409,18 @@ describe(xrpc, () => {
362
409
 
363
410
  describe('response payload parsing', () => {
364
411
  it('throws XrpcUpstreamError when error response body cannot be parsed', async () => {
365
- const fetchHandler: FetchHandler = async () =>
366
- new Response('not valid json', {
412
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
413
+ return new Response('not valid json', {
367
414
  status: 400,
368
415
  headers: { 'content-type': 'application/json' },
369
416
  })
417
+ })
370
418
 
371
419
  await expect(
372
420
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
373
421
  ).rejects.toSatisfy((err) => {
374
422
  assert(err instanceof XrpcUpstreamError)
375
- expect(err.message).toBe('Unable to parse response payload')
423
+ expect(err.message).toMatch('Unable to parse response payload')
376
424
  assert(err.cause instanceof Error)
377
425
  expect(err.cause.message).toContain('Unexpected token')
378
426
  return true
@@ -380,17 +428,18 @@ describe(xrpc, () => {
380
428
  })
381
429
 
382
430
  it('throws XrpcUpstreamError when success response body cannot be parsed', async () => {
383
- const fetchHandler: FetchHandler = async () =>
384
- new Response('not valid json', {
431
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
432
+ return new Response('not valid json', {
385
433
  status: 200,
386
434
  headers: { 'content-type': 'application/json' },
387
435
  })
436
+ })
388
437
 
389
438
  await expect(
390
439
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
391
440
  ).rejects.toSatisfy((err) => {
392
441
  assert(err instanceof XrpcUpstreamError)
393
- expect(err.message).toBe('Unable to parse response payload')
442
+ expect(err.message).toMatch('Unable to parse response payload')
394
443
  assert(err.cause instanceof Error)
395
444
  expect(err.cause.message).toContain('Unexpected token')
396
445
  return true
@@ -398,8 +447,9 @@ describe(xrpc, () => {
398
447
  })
399
448
 
400
449
  it('throws XrpcUpstreamError when schema expects no payload but got one', async () => {
401
- const fetchHandler: FetchHandler = async () =>
402
- Response.json({ unexpected: 'data' })
450
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
451
+ return Response.json({ unexpected: 'data' })
452
+ })
403
453
 
404
454
  await expect(xrpc(fetchHandler, testNoOutputQuery)).rejects.toSatisfy(
405
455
  (err) => {
@@ -411,8 +461,9 @@ describe(xrpc, () => {
411
461
  })
412
462
 
413
463
  it('throws XrpcUpstreamError when schema expects payload but response is empty', async () => {
414
- const fetchHandler: FetchHandler = async () =>
415
- new Response(null, { status: 200 })
464
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
465
+ return new Response(null, { status: 200 })
466
+ })
416
467
 
417
468
  await expect(
418
469
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -426,11 +477,12 @@ describe(xrpc, () => {
426
477
 
427
478
  describe('content-type handling', () => {
428
479
  it('parses content-type with charset parameter', async () => {
429
- const fetchHandler: FetchHandler = async () =>
430
- new Response(JSON.stringify({ value: 'hello' }), {
480
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
481
+ return new Response(JSON.stringify({ value: 'hello' }), {
431
482
  status: 200,
432
483
  headers: { 'content-type': 'application/json; charset=utf-8' },
433
484
  })
485
+ })
434
486
 
435
487
  const response = await xrpc(fetchHandler, testQuery, {
436
488
  params: { limit: 10 },
@@ -481,8 +533,9 @@ describe(xrpc, () => {
481
533
 
482
534
  describe('validateRequest', () => {
483
535
  it('rejects invalid query params when enabled', async () => {
484
- const fetchHandler: FetchHandler = async () =>
485
- Response.json({ value: 'ok' })
536
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
537
+ return Response.json({ value: 'ok' })
538
+ })
486
539
 
487
540
  await expect(
488
541
  xrpc(fetchHandler, testQuery, {
@@ -498,8 +551,9 @@ describe(xrpc, () => {
498
551
  })
499
552
 
500
553
  it('rejects invalid procedure body when enabled', async () => {
501
- const fetchHandler: FetchHandler = async () =>
502
- Response.json({ id: 'abc' })
554
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
555
+ return Response.json({ id: 'abc' })
556
+ })
503
557
 
504
558
  await expect(
505
559
  xrpc(fetchHandler, testProcedure, {
@@ -528,8 +582,9 @@ describe(xrpc, () => {
528
582
  })
529
583
 
530
584
  it('succeeds with valid body when enabled', async () => {
531
- const fetchHandler: FetchHandler = async () =>
532
- Response.json({ id: 'valid' })
585
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
586
+ return Response.json({ id: 'valid' })
587
+ })
533
588
 
534
589
  const response = await xrpc(fetchHandler, testProcedure, {
535
590
  body: { text: 'hello' },
@@ -544,8 +599,9 @@ describe(xrpc, () => {
544
599
  describe('validateResponse', () => {
545
600
  it('rejects invalid response body by default', async () => {
546
601
  // Schema expects { value: string } but server returns { value: 123 }
547
- const fetchHandler: FetchHandler = async () =>
548
- Response.json({ value: 123 })
602
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
603
+ return Response.json({ value: 123 })
604
+ })
549
605
 
550
606
  await expect(
551
607
  xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
@@ -558,8 +614,9 @@ describe(xrpc, () => {
558
614
 
559
615
  it('accepts invalid response body when disabled', async () => {
560
616
  // Schema expects { value: string } but server returns { value: 123 }
561
- const fetchHandler: FetchHandler = async () =>
562
- Response.json({ value: 123 })
617
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
618
+ return Response.json({ value: 123 })
619
+ })
563
620
 
564
621
  const response = await xrpc(fetchHandler, testQuery, {
565
622
  params: { limit: 10 },
@@ -571,8 +628,9 @@ describe(xrpc, () => {
571
628
  })
572
629
 
573
630
  it('succeeds with valid response body when enabled', async () => {
574
- const fetchHandler: FetchHandler = async () =>
575
- Response.json({ value: 'hello' })
631
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
632
+ return Response.json({ value: 'hello' })
633
+ })
576
634
 
577
635
  const response = await xrpc(fetchHandler, testQuery, {
578
636
  params: { limit: 10 },
@@ -583,13 +641,174 @@ describe(xrpc, () => {
583
641
  expect(response.body).toEqual({ value: 'hello' })
584
642
  })
585
643
  })
644
+
645
+ describe('strictResponseProcessing', () => {
646
+ // Helper: returns a JSON response containing a float (invalid lex data)
647
+ const validWithFloatHandler: FetchHandler = async () => {
648
+ return Response.json({ value: 'hello', extra: 1.5 }, { status: 200 })
649
+ }
650
+
651
+ // Helper: returns a JSON error response containing a float
652
+ const errorWithFloatHandler: FetchHandler = async () => {
653
+ return Response.json(
654
+ { error: 'TestError', message: 'test-error-description', extra: 1.5 },
655
+ { status: 400 },
656
+ )
657
+ }
658
+
659
+ it('rejects response with invalid lex data by default (strict parsing)', async () => {
660
+ await expect(
661
+ xrpc(validWithFloatHandler, testQuery, { params: { limit: 10 } }),
662
+ ).rejects.toSatisfy((err) => {
663
+ assert(err instanceof XrpcUpstreamError)
664
+ expect(err.message).toMatch('Unable to parse response payload')
665
+ expect(err.cause).toBeInstanceOf(TypeError)
666
+ return true
667
+ })
668
+ })
669
+
670
+ it('accepts response with invalid lex data when strict processing is disabled', async () => {
671
+ const response = await xrpc(validWithFloatHandler, testQuery, {
672
+ params: { limit: 10 },
673
+ strictResponseProcessing: false,
674
+ })
675
+
676
+ expect(response.success).toBe(true)
677
+ expect(response.body).toEqual({ value: 'hello', extra: 1.5 })
678
+ })
679
+
680
+ it('rejects response with invalid lex data when strict processing is explicitly enabled', async () => {
681
+ await expect(
682
+ xrpc(validWithFloatHandler, testQuery, {
683
+ strictResponseProcessing: true,
684
+ }),
685
+ ).rejects.toSatisfy((err) => {
686
+ assert(err instanceof XrpcUpstreamError)
687
+ expect(err.message).toMatch('Unable to parse response payload')
688
+ expect(err.cause).toBeInstanceOf(TypeError)
689
+ return true
690
+ })
691
+ })
692
+
693
+ it('rejects error response with invalid lex data by default', async () => {
694
+ await expect(xrpc(errorWithFloatHandler, testQuery)).rejects.toSatisfy(
695
+ (err) => {
696
+ assert(err instanceof XrpcUpstreamError)
697
+ expect(err.message).toMatch('Unable to parse response payload')
698
+ return true
699
+ },
700
+ )
701
+ })
702
+
703
+ it('parses error response with invalid lex data when strict processing is disabled', async () => {
704
+ await expect(
705
+ xrpc(errorWithFloatHandler, testQuery, {
706
+ params: { limit: 10 },
707
+ strictResponseProcessing: false,
708
+ }),
709
+ ).rejects.toSatisfy((err) => {
710
+ // Error response is still an error, but it should be parsed successfully
711
+ assert(err instanceof XrpcResponseError)
712
+ expect(err.status).toBe(400)
713
+ expect(err.payload.body).toEqual({
714
+ error: 'TestError',
715
+ message: 'test-error-description',
716
+ extra: 1.5,
717
+ })
718
+ return true
719
+ })
720
+ })
721
+
722
+ it('with strictResponseProcessing: false and validateResponse: true, schema validation still runs', async () => {
723
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
724
+ return Response.json({ value: 3, unknownValue: 1.5 }, { status: 200 })
725
+ })
726
+
727
+ await expect(
728
+ xrpc(fetchHandler, testQuery, {
729
+ params: { limit: 10 },
730
+ strictResponseProcessing: false,
731
+ validateResponse: true,
732
+ }),
733
+ ).rejects.toSatisfy((err) => {
734
+ assert(err instanceof XrpcInvalidResponseError)
735
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
736
+ return true
737
+ })
738
+ })
739
+
740
+ it('with strictResponseProcessing: false and validateResponse: false, schema validation is skipped', async () => {
741
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
742
+ return Response.json(
743
+ {
744
+ // Invalid value
745
+ value: 3,
746
+ // Non-strict Lex Data values:
747
+ unknownValue: 1.2,
748
+ foo: { $bytes: 3 },
749
+ },
750
+ { status: 200 },
751
+ )
752
+ })
753
+
754
+ const response = await xrpc(fetchHandler, testQuery, {
755
+ params: { limit: 10 },
756
+ strictResponseProcessing: false,
757
+ validateResponse: false,
758
+ })
759
+
760
+ expect(response.success).toBe(true)
761
+ expect(response.body).toEqual({
762
+ value: 3,
763
+ unknownValue: 1.2,
764
+ foo: { $bytes: 3 },
765
+ })
766
+
767
+ // @NOTE "validateResponse: false" basically acts as type casting
768
+ expectTypeOf(response.body).toMatchObjectType<{
769
+ value: string
770
+ }>()
771
+ })
772
+
773
+ it('with strictResponseProcessing: true and validateResponse: false, strict parsing still applies', async () => {
774
+ await expect(
775
+ xrpc(validWithFloatHandler, testQuery, {
776
+ params: { limit: 10 },
777
+ strictResponseProcessing: true,
778
+ validateResponse: false,
779
+ }),
780
+ ).rejects.toSatisfy((err) => {
781
+ assert(err instanceof XrpcUpstreamError)
782
+ expect(err.message).toMatch('Unable to parse response payload')
783
+ return true
784
+ })
785
+ })
786
+
787
+ it('does not affect binary responses', async () => {
788
+ const bytes = new Uint8Array([1, 2, 3])
789
+
790
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
791
+ return new Response(bytes, {
792
+ headers: { 'content-type': 'application/octet-stream' },
793
+ })
794
+ })
795
+
796
+ const response = await xrpc(fetchHandler, testBinaryQuery, {
797
+ strictResponseProcessing: false,
798
+ })
799
+
800
+ expect(response.success).toBe(true)
801
+ expect(response.body).toEqual(bytes)
802
+ })
803
+ })
586
804
  })
587
805
 
588
806
  describe(xrpcSafe, () => {
589
807
  describe('success paths', () => {
590
808
  it('returns successful result for a JSON query', async () => {
591
- const fetchHandler: FetchHandler = async () =>
592
- Response.json({ value: 'hello' })
809
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
810
+ return Response.json({ value: 'hello' })
811
+ })
593
812
 
594
813
  const result = await xrpcSafe(fetchHandler, testQuery, {
595
814
  params: { limit: 5 },
@@ -603,8 +822,9 @@ describe(xrpcSafe, () => {
603
822
  })
604
823
 
605
824
  it('returns successful result for a JSON procedure', async () => {
606
- const fetchHandler: FetchHandler = async () =>
607
- Response.json({ id: 'new-id' })
825
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
826
+ return Response.json({ id: 'new-id' })
827
+ })
608
828
 
609
829
  const result = await xrpcSafe(fetchHandler, testProcedure, {
610
830
  body: { text: 'hello' },
@@ -616,10 +836,11 @@ describe(xrpcSafe, () => {
616
836
 
617
837
  it('returns successful result for a binary query', async () => {
618
838
  const bytes = new Uint8Array([5, 6, 7])
619
- const fetchHandler: FetchHandler = async () =>
620
- new Response(bytes, {
839
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
840
+ return new Response(bytes, {
621
841
  headers: { 'content-type': 'application/octet-stream' },
622
842
  })
843
+ })
623
844
 
624
845
  const result = await xrpcSafe(fetchHandler, testBinaryQuery)
625
846
 
@@ -631,10 +852,11 @@ describe(xrpcSafe, () => {
631
852
 
632
853
  it('returns successful result for a binary procedure', async () => {
633
854
  const bytes = new Uint8Array([42])
634
- const fetchHandler: FetchHandler = async () =>
635
- new Response(bytes, {
855
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
856
+ return new Response(bytes, {
636
857
  headers: { 'content-type': 'application/octet-stream' },
637
858
  })
859
+ })
638
860
 
639
861
  const result = await xrpcSafe(fetchHandler, testBinaryProcedure, {
640
862
  body: new Uint8Array([1, 2]),
@@ -646,8 +868,9 @@ describe(xrpcSafe, () => {
646
868
  })
647
869
 
648
870
  it('returns successful result for a no-output query', async () => {
649
- const fetchHandler: FetchHandler = async () =>
650
- new Response(null, { status: 200 })
871
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
872
+ return new Response(null, { status: 200 })
873
+ })
651
874
 
652
875
  const result = await xrpcSafe(fetchHandler, testNoOutputQuery)
653
876
 
@@ -660,9 +883,9 @@ describe(xrpcSafe, () => {
660
883
  describe('error handling', () => {
661
884
  describe('fetch errors', () => {
662
885
  it('returns XrpcFetchError when fetchHandler throws', async () => {
663
- const fetchHandler: FetchHandler = async () => {
886
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
664
887
  throw new TypeError('fetch failed')
665
- }
888
+ })
666
889
 
667
890
  const result = await xrpcSafe(fetchHandler, testQuery, {
668
891
  params: { limit: 10 },
@@ -674,9 +897,9 @@ describe(xrpcSafe, () => {
674
897
  })
675
898
 
676
899
  it('returns XrpcFetchError when fetchHandler rejects', async () => {
677
- const fetchHandler: FetchHandler = async () => {
900
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
678
901
  throw new Error('network timeout')
679
- }
902
+ })
680
903
 
681
904
  const result = await xrpcSafe(fetchHandler, testQuery, {
682
905
  params: { limit: 10 },
@@ -806,8 +1029,9 @@ describe(xrpcSafe, () => {
806
1029
 
807
1030
  describe('validateRequest', () => {
808
1031
  it('returns XrpcInternalError for invalid query params when enabled', async () => {
809
- const fetchHandler: FetchHandler = async () =>
810
- Response.json({ value: 'ok' })
1032
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1033
+ return Response.json({ value: 'ok' })
1034
+ })
811
1035
 
812
1036
  const result = await xrpcSafe(fetchHandler, testQuery, {
813
1037
  // @ts-expect-error intentionally passing invalid params
@@ -821,8 +1045,9 @@ describe(xrpcSafe, () => {
821
1045
  })
822
1046
 
823
1047
  it('returns XrpcInternalError for invalid body when enabled', async () => {
824
- const fetchHandler: FetchHandler = async () =>
825
- Response.json({ id: 'abc' })
1048
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1049
+ return Response.json({ id: 'abc' })
1050
+ })
826
1051
 
827
1052
  const result = await xrpcSafe(fetchHandler, testProcedure, {
828
1053
  // @ts-expect-error intentionally passing invalid body
@@ -848,8 +1073,9 @@ describe(xrpcSafe, () => {
848
1073
  })
849
1074
 
850
1075
  it('succeeds with valid body when enabled', async () => {
851
- const fetchHandler: FetchHandler = async () =>
852
- Response.json({ id: 'valid' })
1076
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1077
+ return Response.json({ id: 'valid' })
1078
+ })
853
1079
 
854
1080
  const result = await xrpcSafe(fetchHandler, testProcedure, {
855
1081
  body: { text: 'hello' },
@@ -863,8 +1089,9 @@ describe(xrpcSafe, () => {
863
1089
 
864
1090
  describe('validateResponse', () => {
865
1091
  it('returns XrpcInvalidResponseError for invalid body by default', async () => {
866
- const fetchHandler: FetchHandler = async () =>
867
- Response.json({ value: 123 })
1092
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1093
+ return Response.json({ value: 123 })
1094
+ })
868
1095
 
869
1096
  const result = await xrpcSafe(fetchHandler, testQuery, {
870
1097
  params: { limit: 10 },
@@ -876,8 +1103,9 @@ describe(xrpcSafe, () => {
876
1103
  })
877
1104
 
878
1105
  it('accepts invalid response body when disabled', async () => {
879
- const fetchHandler: FetchHandler = async () =>
880
- Response.json({ value: 123 })
1106
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1107
+ return Response.json({ value: 123 })
1108
+ })
881
1109
 
882
1110
  const result = await xrpcSafe(fetchHandler, testQuery, {
883
1111
  params: { limit: 10 },
@@ -889,8 +1117,9 @@ describe(xrpcSafe, () => {
889
1117
  })
890
1118
 
891
1119
  it('succeeds with valid response body when enabled', async () => {
892
- const fetchHandler: FetchHandler = async () =>
893
- Response.json({ value: 'hello' })
1120
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1121
+ return Response.json({ value: 'hello' })
1122
+ })
894
1123
 
895
1124
  const result = await xrpcSafe(fetchHandler, testQuery, {
896
1125
  params: { limit: 10 },
@@ -900,5 +1129,325 @@ describe(xrpcSafe, () => {
900
1129
  assert(result.success)
901
1130
  expect(result.body).toEqual({ value: 'hello' })
902
1131
  })
1132
+
1133
+ it('applies defaults', async () => {
1134
+ const fetchHandler = vi.fn<FetchHandler>(async (path) => {
1135
+ const url = new URL(path, 'http://localhost')
1136
+ const foo = url.searchParams.get('foo')
1137
+
1138
+ // default applied while building the request
1139
+ expect(foo).toBe('foo-default')
1140
+
1141
+ return Response.json({ foo: 'foo-value' })
1142
+ })
1143
+
1144
+ const result = await xrpcSafe(fetchHandler, testQueryWithDefaults, {
1145
+ params: {},
1146
+ validateResponse: true,
1147
+ })
1148
+
1149
+ expect(fetchHandler).toHaveBeenCalled()
1150
+ assert(result.success)
1151
+ expect(result.body).toEqual({
1152
+ bar: 'bar-default', // default applied while parsing the response
1153
+ foo: 'foo-value',
1154
+ })
1155
+ })
1156
+ })
1157
+
1158
+ describe('blob constraints', () => {
1159
+ it('rejects invalid blob refs', async () => {
1160
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1161
+ // missing properties jere
1162
+ return Response.json({ blobRef: { $type: 'blob' } })
1163
+ })
1164
+
1165
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef)
1166
+ assert(!result.success)
1167
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1168
+ expect(result.message).toMatch('Unable to parse response payload')
1169
+ assert(result.cause instanceof TypeError)
1170
+ expect(result.cause.message).toBe('Invalid blob object')
1171
+ })
1172
+
1173
+ it('rejects blob-refs with cbor data CIDs', async () => {
1174
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1175
+ return Response.json(
1176
+ lexToJson({
1177
+ blobRef: {
1178
+ $type: 'blob',
1179
+ // ref should be a "raw" CID to be strictly valid
1180
+ ref: cborCid,
1181
+ mimeType: 'image/png',
1182
+ size: 1,
1183
+ },
1184
+ }),
1185
+ )
1186
+ })
1187
+
1188
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef)
1189
+ assert(!result.success)
1190
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1191
+ expect(result.message).toMatch('Unable to parse response payload')
1192
+ assert(result.cause instanceof TypeError)
1193
+ expect(result.cause.message).toBe('Invalid blob object')
1194
+ })
1195
+
1196
+ it('enforces blob mime-type constraint by default', async () => {
1197
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1198
+ return Response.json(
1199
+ lexToJson({
1200
+ blobRef: {
1201
+ $type: 'blob',
1202
+ ref: rawCid,
1203
+ mimeType: 'invalid/mime',
1204
+ size: 10,
1205
+ },
1206
+ }),
1207
+ )
1208
+ })
1209
+
1210
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef)
1211
+ assert(!result.success)
1212
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1213
+ expect(result.message).toBe(
1214
+ 'Invalid response: Expected "image/png" (got "invalid/mime") at $.blobRef.mimeType',
1215
+ )
1216
+ })
1217
+
1218
+ it('enforces blob size constraint by default', async () => {
1219
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1220
+ return Response.json(
1221
+ lexToJson({
1222
+ blobRef: {
1223
+ $type: 'blob',
1224
+ ref: rawCid,
1225
+ mimeType: 'image/png',
1226
+ size: 100,
1227
+ },
1228
+ }),
1229
+ )
1230
+ })
1231
+
1232
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef)
1233
+ assert(!result.success)
1234
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1235
+ expect(result.message).toBe(
1236
+ 'Invalid response: blob too big (maximum 10, got 100) at $.blobRef',
1237
+ )
1238
+ })
1239
+
1240
+ it('ignores blob constraints in non-strict mode', async () => {
1241
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1242
+ return Response.json(
1243
+ lexToJson({
1244
+ blobRef: {
1245
+ $type: 'blob',
1246
+ ref: rawCid,
1247
+ mimeType: 'invalid/mime',
1248
+ size: 100,
1249
+ },
1250
+ }),
1251
+ )
1252
+ })
1253
+
1254
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef, {
1255
+ strictResponseProcessing: false,
1256
+ })
1257
+
1258
+ assert(result.success)
1259
+ expectTypeOf(result.body).toMatchObjectType<{ blobRef: l.BlobRef }>()
1260
+ expect(result.body).toEqual({
1261
+ blobRef: {
1262
+ $type: 'blob',
1263
+ ref: rawCid,
1264
+ mimeType: 'invalid/mime',
1265
+ size: 100,
1266
+ },
1267
+ })
1268
+ })
1269
+
1270
+ it('transforms legacy blobs in non-strict mode', async () => {
1271
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1272
+ return Response.json({
1273
+ blobRef: {
1274
+ cid: rawCid.toString(),
1275
+ mimeType: 'invalid/mime',
1276
+ },
1277
+ })
1278
+ })
1279
+
1280
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef, {
1281
+ strictResponseProcessing: false,
1282
+ })
1283
+
1284
+ assert(result.success)
1285
+ expectTypeOf(result.body).toMatchObjectType<{ blobRef: l.BlobRef }>()
1286
+ expect(result.body).toEqual({
1287
+ blobRef: {
1288
+ $type: 'blob',
1289
+ ref: rawCid,
1290
+ mimeType: 'invalid/mime',
1291
+ size: -1,
1292
+ },
1293
+ })
1294
+ })
1295
+
1296
+ it('allows blob-refs with negative size in non-strict mode', async () => {
1297
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1298
+ return Response.json({
1299
+ blobRef: {
1300
+ $type: 'blob',
1301
+ ref: { $link: rawCid.toString() },
1302
+ mimeType: 'invalid/mime',
1303
+ size: -1,
1304
+ },
1305
+ })
1306
+ })
1307
+
1308
+ const result = await xrpcSafe(fetchHandler, testQueryGetBlobRef, {
1309
+ strictResponseProcessing: false,
1310
+ })
1311
+
1312
+ assert(result.success)
1313
+ expectTypeOf(result.body).toMatchObjectType<{ blobRef: l.BlobRef }>()
1314
+ expect(result.body).toEqual({
1315
+ blobRef: {
1316
+ $type: 'blob',
1317
+ ref: rawCid,
1318
+ mimeType: 'invalid/mime',
1319
+ size: -1,
1320
+ },
1321
+ })
1322
+ })
1323
+ })
1324
+
1325
+ describe('strictResponseProcessing', () => {
1326
+ const jsonResponseWithFloat: FetchHandler = async () => {
1327
+ return Response.json({ value: 'hello', extra: 1.5 }, { status: 200 })
1328
+ }
1329
+
1330
+ const jsonErrorResponseWithFloat: FetchHandler = async () => {
1331
+ return Response.json(
1332
+ { error: 'TestError', message: 'test-error-description', extra: 1.5 },
1333
+ { status: 400 },
1334
+ )
1335
+ }
1336
+
1337
+ it('returns error for invalid lex data by default (strict parsing)', async () => {
1338
+ const fetchHandler = vi.fn<FetchHandler>(jsonResponseWithFloat)
1339
+
1340
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1341
+ params: { limit: 10 },
1342
+ })
1343
+
1344
+ assert(!result.success)
1345
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1346
+ expect(result.message).toMatch('Unable to parse response payload')
1347
+ assert(result.cause instanceof TypeError)
1348
+ expect(result.cause.message).toBe('Invalid non-integer number: 1.5')
1349
+ })
1350
+
1351
+ it('accepts response with invalid lex data when strict processing is disabled', async () => {
1352
+ const fetchHandler = vi.fn<FetchHandler>(jsonResponseWithFloat)
1353
+
1354
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1355
+ params: { limit: 10 },
1356
+ strictResponseProcessing: false,
1357
+ })
1358
+
1359
+ assert(result.success)
1360
+ expect(result.body).toEqual({ value: 'hello', extra: 1.5 })
1361
+ })
1362
+
1363
+ it('returns error for invalid lex data when strict processing is explicitly enabled', async () => {
1364
+ const fetchHandler = vi.fn<FetchHandler>(jsonResponseWithFloat)
1365
+
1366
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1367
+ params: { limit: 10 },
1368
+ strictResponseProcessing: true,
1369
+ })
1370
+
1371
+ assert(!result.success)
1372
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1373
+ expect(result.message).toMatch('Unable to parse response payload')
1374
+ assert(result.cause instanceof TypeError)
1375
+ expect(result.cause.message).toBe('Invalid non-integer number: 1.5')
1376
+ })
1377
+
1378
+ it('returns error for error response with invalid lex data by default', async () => {
1379
+ const fetchHandler = vi.fn<FetchHandler>(jsonErrorResponseWithFloat)
1380
+
1381
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1382
+ params: { limit: 10 },
1383
+ })
1384
+
1385
+ assert(!result.success)
1386
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1387
+ expect(result.message).toMatch('Unable to parse response payload')
1388
+ assert(result.cause instanceof TypeError)
1389
+ expect(result.cause.message).toBe('Invalid non-integer number: 1.5')
1390
+ })
1391
+
1392
+ it('parses error response with invalid lex data when strict processing is disabled', async () => {
1393
+ const fetchHandler = vi.fn<FetchHandler>(jsonErrorResponseWithFloat)
1394
+
1395
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1396
+ params: { limit: 10 },
1397
+ strictResponseProcessing: false,
1398
+ })
1399
+
1400
+ assert(!result.success)
1401
+ assert(result instanceof XrpcResponseError)
1402
+ expect(result.status).toBe(400)
1403
+ expect(result.message).toBe('test-error-description')
1404
+ })
1405
+
1406
+ it('with strictResponseProcessing: false and validateResponse: true, schema validation still runs', async () => {
1407
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1408
+ return Response.json({ value: 1.5 }, { status: 200 })
1409
+ })
1410
+
1411
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1412
+ params: { limit: 10 },
1413
+ strictResponseProcessing: false,
1414
+ validateResponse: true,
1415
+ })
1416
+
1417
+ assert(!result.success)
1418
+ expect(result).toBeInstanceOf(XrpcInvalidResponseError)
1419
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1420
+ })
1421
+
1422
+ it('with strictResponseProcessing: false and validateResponse: false, schema validation is skipped', async () => {
1423
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1424
+ return Response.json({ value: 1.5 }, { status: 200 })
1425
+ })
1426
+
1427
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1428
+ params: { limit: 10 },
1429
+ strictResponseProcessing: false,
1430
+ validateResponse: false,
1431
+ })
1432
+
1433
+ assert(result.success)
1434
+ expect(result.body).toEqual({ value: 1.5 })
1435
+ })
1436
+
1437
+ it('with strictResponseProcessing: true and validateResponse: false, strict parsing still applies', async () => {
1438
+ const fetchHandler = vi.fn<FetchHandler>(jsonResponseWithFloat)
1439
+
1440
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1441
+ params: { limit: 10 },
1442
+ strictResponseProcessing: true,
1443
+ validateResponse: false,
1444
+ })
1445
+
1446
+ assert(!result.success)
1447
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1448
+ expect(result.message).toMatch('Unable to parse response payload')
1449
+ assert(result.cause instanceof TypeError)
1450
+ expect(result.cause.message).toBe('Invalid non-integer number: 1.5')
1451
+ })
903
1452
  })
904
1453
  })