@atproto/lex-client 0.0.15 → 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.
@@ -0,0 +1,1453 @@
1
+ import { assert, describe, expect, expectTypeOf, it, vi } from 'vitest'
2
+ import { parseCid } from '@atproto/lex-data'
3
+ import { lexToJson } from '@atproto/lex-json'
4
+ import { l } from '@atproto/lex-schema'
5
+ import { FetchHandler } from './agent.js'
6
+ import {
7
+ XrpcAuthenticationError,
8
+ XrpcFetchError,
9
+ XrpcInternalError,
10
+ XrpcInvalidResponseError,
11
+ XrpcResponseError,
12
+ XrpcUpstreamError,
13
+ } from './errors.js'
14
+ import { XrpcResponse } from './response.js'
15
+ import { xrpc, xrpcSafe } from './xrpc.js'
16
+
17
+ // Fixtures
18
+
19
+ const rawCid = parseCid(
20
+ 'bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4',
21
+ { flavor: 'raw' },
22
+ )
23
+
24
+ const cborCid = parseCid(
25
+ 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
26
+ { flavor: 'cbor' },
27
+ )
28
+
29
+ const testQuery = l.query(
30
+ 'io.example.testQuery',
31
+ l.params({ limit: l.optional(l.integer()) }),
32
+ l.jsonPayload({ value: l.string() }),
33
+ ['TestError'],
34
+ )
35
+
36
+ const testProcedure = l.procedure(
37
+ 'io.example.testProcedure',
38
+ l.params(),
39
+ l.jsonPayload({ text: l.string() }),
40
+ l.jsonPayload({ id: l.string() }),
41
+ ['ProcedureError'],
42
+ )
43
+
44
+ const testBinaryQuery = l.query(
45
+ 'io.example.testBinaryQuery',
46
+ l.params(),
47
+ l.payload('application/octet-stream', undefined),
48
+ )
49
+
50
+ const testBinaryProcedure = l.procedure(
51
+ 'io.example.testBinaryProcedure',
52
+ l.params(),
53
+ l.payload('image/*', undefined),
54
+ l.payload('application/octet-stream', undefined),
55
+ )
56
+
57
+ const testNoOutputQuery = l.query(
58
+ 'io.example.testNoOutputQuery',
59
+ l.params(),
60
+ l.payload(),
61
+ )
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
+
84
+ describe(xrpc, () => {
85
+ describe('success paths', () => {
86
+ it('returns parsed JSON body for a query', async () => {
87
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
88
+ return Response.json({ value: 'hello' })
89
+ })
90
+
91
+ const response = await xrpc(fetchHandler, testQuery, {
92
+ params: { limit: 10 },
93
+ })
94
+
95
+ expect(response).toBeInstanceOf(XrpcResponse)
96
+ expect(response.success).toBe(true)
97
+ expect(response.status).toBe(200)
98
+ expect(response.body).toEqual({ value: 'hello' })
99
+ expect(response.encoding).toBe('application/json')
100
+ expect(response.isParsed).toBe(true)
101
+ expect(response.value).toBe(response)
102
+ })
103
+
104
+ it('returns parsed JSON body for a procedure', async () => {
105
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
106
+ return Response.json({ id: 'abc123' })
107
+ })
108
+
109
+ const response = await xrpc(fetchHandler, testProcedure, {
110
+ body: { text: 'hello world' },
111
+ })
112
+
113
+ expect(response.success).toBe(true)
114
+ expect(response.status).toBe(200)
115
+ expect(response.body).toEqual({ id: 'abc123' })
116
+ expect(response.encoding).toBe('application/json')
117
+ })
118
+
119
+ it('returns binary body for a binary query', async () => {
120
+ const bytes = new Uint8Array([1, 2, 3, 4])
121
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
122
+ return new Response(bytes, {
123
+ headers: { 'content-type': 'application/octet-stream' },
124
+ })
125
+ })
126
+
127
+ const response = await xrpc(fetchHandler, testBinaryQuery)
128
+
129
+ expect(response.success).toBe(true)
130
+ expect(response.body).toBeInstanceOf(Uint8Array)
131
+ expect(response.body).toEqual(bytes)
132
+ expect(response.encoding).toBe('application/octet-stream')
133
+ expect(response.isParsed).toBe(false)
134
+ })
135
+
136
+ it('returns binary body for a binary procedure', async () => {
137
+ const bytes = new Uint8Array([10, 20, 30])
138
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
139
+ return new Response(bytes, {
140
+ headers: { 'content-type': 'application/octet-stream' },
141
+ })
142
+ })
143
+
144
+ const response = await xrpc(fetchHandler, testBinaryProcedure, {
145
+ body: new Uint8Array([99]),
146
+ encoding: 'image/png',
147
+ })
148
+
149
+ expect(response.success).toBe(true)
150
+ expect(response.body).toBeInstanceOf(Uint8Array)
151
+ expect(response.body).toEqual(bytes)
152
+ })
153
+
154
+ it('returns no body for a no-output query', async () => {
155
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
156
+ return new Response(null, { status: 200 })
157
+ })
158
+
159
+ const response = await xrpc(fetchHandler, testNoOutputQuery)
160
+
161
+ expect(response.success).toBe(true)
162
+ expect(response.status).toBe(200)
163
+ expect(response.body).toBeUndefined()
164
+ expect(response.encoding).toBeUndefined()
165
+ })
166
+
167
+ it('passes query params as URL search params', async () => {
168
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
169
+ return Response.json({ value: 'ok' })
170
+ })
171
+
172
+ await xrpc(fetchHandler, testQuery, { params: { limit: 25 } })
173
+
174
+ expect(fetchHandler).toHaveBeenCalledOnce()
175
+ const [path] = fetchHandler.mock.calls[0]
176
+ expect(path).toContain('/xrpc/io.example.testQuery')
177
+ expect(path).toContain('limit=25')
178
+ })
179
+
180
+ it('sends POST with JSON body for procedures', async () => {
181
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
182
+ return Response.json({ id: 'new-id' })
183
+ })
184
+
185
+ await xrpc(fetchHandler, testProcedure, {
186
+ body: { text: 'test content' },
187
+ })
188
+
189
+ expect(fetchHandler).toHaveBeenCalledOnce()
190
+ const [, init] = fetchHandler.mock.calls[0]
191
+ expect(init.method).toBe('POST')
192
+ expect(new Headers(init.headers).get('content-type')).toBe(
193
+ 'application/json',
194
+ )
195
+ })
196
+
197
+ it('forwards custom headers', async () => {
198
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
199
+ return Response.json({ value: 'ok' })
200
+ })
201
+
202
+ await xrpc(fetchHandler, testQuery, {
203
+ params: { limit: 1 },
204
+ headers: { authorization: 'Bearer token123' },
205
+ })
206
+
207
+ expect(fetchHandler).toHaveBeenCalledOnce()
208
+ const [, init] = fetchHandler.mock.calls[0]
209
+ expect(new Headers(init.headers).get('authorization')).toBe(
210
+ 'Bearer token123',
211
+ )
212
+ })
213
+
214
+ it('accepts optional params as omitted', async () => {
215
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
216
+ return Response.json({ value: 'ok' })
217
+ })
218
+
219
+ const response = await xrpc(fetchHandler, testQuery)
220
+
221
+ expect(response.success).toBe(true)
222
+ expect(response.body).toEqual({ value: 'ok' })
223
+ })
224
+ })
225
+
226
+ describe('error handling', () => {
227
+ describe('fetch errors', () => {
228
+ it('throws XrpcFetchError when fetchHandler throws', async () => {
229
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
230
+ throw new TypeError('fetch failed')
231
+ })
232
+
233
+ await expect(
234
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
235
+ ).rejects.toSatisfy((err) => {
236
+ assert(err instanceof XrpcFetchError)
237
+ expect(err).toBeInstanceOf(XrpcInternalError)
238
+ expect(err.cause).toBeInstanceOf(TypeError)
239
+ expect(err.message).toContain('fetch failed')
240
+ return true
241
+ })
242
+ })
243
+
244
+ it('throws XrpcFetchError when fetchHandler rejects', async () => {
245
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
246
+ throw new Error('network timeout')
247
+ })
248
+
249
+ await expect(
250
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
251
+ ).rejects.toSatisfy((err) => {
252
+ assert(err instanceof XrpcFetchError)
253
+ expect(err.message).toContain('network timeout')
254
+ expect(err.shouldRetry()).toBe(true)
255
+ return true
256
+ })
257
+ })
258
+ })
259
+
260
+ describe('response errors', () => {
261
+ it('throws XrpcResponseError for 400 with valid error payload', async () => {
262
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
263
+ return Response.json(
264
+ { error: 'TestError', message: 'bad request' },
265
+ { status: 400 },
266
+ )
267
+ })
268
+
269
+ await expect(
270
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
271
+ ).rejects.toSatisfy((err) => {
272
+ assert(err instanceof XrpcResponseError)
273
+ expect(err.status).toBe(400)
274
+ expect(err.body).toEqual({
275
+ error: 'TestError',
276
+ message: 'bad request',
277
+ })
278
+ return true
279
+ })
280
+ })
281
+
282
+ it('throws XrpcAuthenticationError for 401', async () => {
283
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
284
+ return Response.json(
285
+ { error: 'AuthenticationRequired', message: 'Token expired' },
286
+ { status: 401 },
287
+ )
288
+ })
289
+
290
+ await expect(
291
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
292
+ ).rejects.toSatisfy((err) => {
293
+ assert(err instanceof XrpcAuthenticationError)
294
+ expect(err.status).toBe(401)
295
+ expect(err.message).toBe('Token expired')
296
+ return true
297
+ })
298
+ })
299
+
300
+ it('throws XrpcUpstreamError for non-XRPC error response', async () => {
301
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
302
+ return new Response('Not Found', {
303
+ status: 404,
304
+ headers: { 'content-type': 'text/plain' },
305
+ })
306
+ })
307
+
308
+ await expect(
309
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
310
+ ).rejects.toSatisfy((err) => {
311
+ assert(err instanceof XrpcUpstreamError)
312
+ expect(err.message).toBe('Invalid response payload')
313
+ return true
314
+ })
315
+ })
316
+
317
+ it('throws XrpcUpstreamError for 500 without valid error payload', async () => {
318
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
319
+ return new Response('Internal Server Error', {
320
+ status: 500,
321
+ headers: { 'content-type': 'text/html' },
322
+ })
323
+ })
324
+
325
+ await expect(
326
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
327
+ ).rejects.toSatisfy((err) => {
328
+ assert(err instanceof XrpcUpstreamError)
329
+ expect(err.message).toBe('Upstream server encountered an error')
330
+ return true
331
+ })
332
+ })
333
+
334
+ it('Reflects upstream 5xx errors with valid XRPC payload', async () => {
335
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
336
+ return Response.json(
337
+ { error: 'ServerError', message: 'Something went wrong' },
338
+ { status: 502 },
339
+ )
340
+ })
341
+
342
+ await expect(
343
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
344
+ ).rejects.toSatisfy((err) => {
345
+ assert(err instanceof XrpcResponseError)
346
+ expect(err.status).toBe(502)
347
+ expect(err.body).toEqual({
348
+ error: 'ServerError',
349
+ message: 'Something went wrong',
350
+ })
351
+ return true
352
+ })
353
+ })
354
+ })
355
+
356
+ describe('invalid response errors', () => {
357
+ it('throws XrpcInvalidResponseError when response body fails validation', async () => {
358
+ // Schema expects { value: string } but we return { value: 123 }
359
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
360
+ return Response.json({ value: 123 })
361
+ })
362
+
363
+ await expect(
364
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
365
+ ).rejects.toSatisfy((err) => {
366
+ assert(err instanceof XrpcInvalidResponseError)
367
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
368
+ expect(err.cause).toBeInstanceOf(Error)
369
+ return true
370
+ })
371
+ })
372
+
373
+ it('throws XrpcUpstreamError when response has wrong content-type', async () => {
374
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
375
+ return new Response('binary data', {
376
+ status: 200,
377
+ headers: { 'content-type': 'text/plain' },
378
+ })
379
+ })
380
+
381
+ await expect(
382
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
383
+ ).rejects.toSatisfy((err) => {
384
+ assert(err instanceof XrpcUpstreamError)
385
+ expect(err.message).toContain('application/json')
386
+ return true
387
+ })
388
+ })
389
+ })
390
+
391
+ describe('content-type header errors', () => {
392
+ it('throws XrpcInternalError when content-type header is set', async () => {
393
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
394
+ return Response.json({ value: 'ok' })
395
+ })
396
+
397
+ await expect(
398
+ xrpc(fetchHandler, testQuery, {
399
+ params: { limit: 10 },
400
+ headers: { 'content-type': 'application/json' },
401
+ }),
402
+ ).rejects.toSatisfy((err) => {
403
+ assert(err instanceof XrpcInternalError)
404
+ expect(err.cause).toBeInstanceOf(TypeError)
405
+ return true
406
+ })
407
+ })
408
+ })
409
+
410
+ describe('response payload parsing', () => {
411
+ it('throws XrpcUpstreamError when error response body cannot be parsed', async () => {
412
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
413
+ return new Response('not valid json', {
414
+ status: 400,
415
+ headers: { 'content-type': 'application/json' },
416
+ })
417
+ })
418
+
419
+ await expect(
420
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
421
+ ).rejects.toSatisfy((err) => {
422
+ assert(err instanceof XrpcUpstreamError)
423
+ expect(err.message).toMatch('Unable to parse response payload')
424
+ assert(err.cause instanceof Error)
425
+ expect(err.cause.message).toContain('Unexpected token')
426
+ return true
427
+ })
428
+ })
429
+
430
+ it('throws XrpcUpstreamError when success response body cannot be parsed', async () => {
431
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
432
+ return new Response('not valid json', {
433
+ status: 200,
434
+ headers: { 'content-type': 'application/json' },
435
+ })
436
+ })
437
+
438
+ await expect(
439
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
440
+ ).rejects.toSatisfy((err) => {
441
+ assert(err instanceof XrpcUpstreamError)
442
+ expect(err.message).toMatch('Unable to parse response payload')
443
+ assert(err.cause instanceof Error)
444
+ expect(err.cause.message).toContain('Unexpected token')
445
+ return true
446
+ })
447
+ })
448
+
449
+ it('throws XrpcUpstreamError when schema expects no payload but got one', async () => {
450
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
451
+ return Response.json({ unexpected: 'data' })
452
+ })
453
+
454
+ await expect(xrpc(fetchHandler, testNoOutputQuery)).rejects.toSatisfy(
455
+ (err) => {
456
+ assert(err instanceof XrpcUpstreamError)
457
+ expect(err.message).toContain('no body')
458
+ return true
459
+ },
460
+ )
461
+ })
462
+
463
+ it('throws XrpcUpstreamError when schema expects payload but response is empty', async () => {
464
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
465
+ return new Response(null, { status: 200 })
466
+ })
467
+
468
+ await expect(
469
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
470
+ ).rejects.toSatisfy((err) => {
471
+ assert(err instanceof XrpcUpstreamError)
472
+ expect(err.message).toContain('non-empty response')
473
+ return true
474
+ })
475
+ })
476
+ })
477
+
478
+ describe('content-type handling', () => {
479
+ it('parses content-type with charset parameter', async () => {
480
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
481
+ return new Response(JSON.stringify({ value: 'hello' }), {
482
+ status: 200,
483
+ headers: { 'content-type': 'application/json; charset=utf-8' },
484
+ })
485
+ })
486
+
487
+ const response = await xrpc(fetchHandler, testQuery, {
488
+ params: { limit: 10 },
489
+ })
490
+
491
+ expect(response.success).toBe(true)
492
+ expect(response.body).toEqual({ value: 'hello' })
493
+ })
494
+
495
+ it('handles response with no content-type and empty body as no payload', async () => {
496
+ const fetchHandler: FetchHandler = async () =>
497
+ new Response(new ArrayBuffer(0), { status: 200 })
498
+
499
+ const response = await xrpc(fetchHandler, testNoOutputQuery)
500
+
501
+ expect(response.success).toBe(true)
502
+ expect(response.body).toBeUndefined()
503
+ })
504
+
505
+ it('treats response with no content-type but non-empty body as binary', async () => {
506
+ const bytes = new Uint8Array([1, 2, 3])
507
+ const fetchHandler: FetchHandler = async () =>
508
+ new Response(bytes, { status: 200 })
509
+
510
+ const response = await xrpc(fetchHandler, testBinaryQuery)
511
+
512
+ expect(response.success).toBe(true)
513
+ expect(response.body).toBeInstanceOf(Uint8Array)
514
+ expect(response.body).toEqual(bytes)
515
+ })
516
+ })
517
+
518
+ describe('non-2xx non-4xx/5xx responses', () => {
519
+ it('throws XrpcUpstreamError for 3xx status codes', async () => {
520
+ const fetchHandler: FetchHandler = async () =>
521
+ Response.json({ value: 'redirect' }, { status: 302 })
522
+
523
+ await expect(
524
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
525
+ ).rejects.toSatisfy((err) => {
526
+ assert(err instanceof XrpcUpstreamError)
527
+ expect(err.message).toBe('Invalid response status code')
528
+ return true
529
+ })
530
+ })
531
+ })
532
+ })
533
+
534
+ describe('validateRequest', () => {
535
+ it('rejects invalid query params when enabled', async () => {
536
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
537
+ return Response.json({ value: 'ok' })
538
+ })
539
+
540
+ await expect(
541
+ xrpc(fetchHandler, testQuery, {
542
+ // @ts-expect-error intentionally passing invalid params
543
+ params: { limit: 'not-a-number' },
544
+ validateRequest: true,
545
+ }),
546
+ ).rejects.toSatisfy((err) => {
547
+ assert(err instanceof XrpcInternalError)
548
+ expect(err).not.toBeInstanceOf(XrpcFetchError)
549
+ return true
550
+ })
551
+ })
552
+
553
+ it('rejects invalid procedure body when enabled', async () => {
554
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
555
+ return Response.json({ id: 'abc' })
556
+ })
557
+
558
+ await expect(
559
+ xrpc(fetchHandler, testProcedure, {
560
+ // @ts-expect-error intentionally passing invalid body
561
+ body: { text: 123 },
562
+ validateRequest: true,
563
+ }),
564
+ ).rejects.toSatisfy((err) => {
565
+ assert(err instanceof XrpcInternalError)
566
+ expect(err).not.toBeInstanceOf(XrpcFetchError)
567
+ return true
568
+ })
569
+ })
570
+
571
+ it('skips body validation by default (invalid body sent as-is)', async () => {
572
+ const fetchHandler: FetchHandler = async () => Response.json({ id: 'ok' })
573
+
574
+ // Invalid body ({ text: 123 }) is not validated client-side
575
+ const response = await xrpc(fetchHandler, testProcedure, {
576
+ // @ts-expect-error intentionally passing invalid body
577
+ body: { text: 123 },
578
+ })
579
+
580
+ expect(response.success).toBe(true)
581
+ expect(response.body).toEqual({ id: 'ok' })
582
+ })
583
+
584
+ it('succeeds with valid body when enabled', async () => {
585
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
586
+ return Response.json({ id: 'valid' })
587
+ })
588
+
589
+ const response = await xrpc(fetchHandler, testProcedure, {
590
+ body: { text: 'hello' },
591
+ validateRequest: true,
592
+ })
593
+
594
+ expect(response.success).toBe(true)
595
+ expect(response.body).toEqual({ id: 'valid' })
596
+ })
597
+ })
598
+
599
+ describe('validateResponse', () => {
600
+ it('rejects invalid response body by default', async () => {
601
+ // Schema expects { value: string } but server returns { value: 123 }
602
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
603
+ return Response.json({ value: 123 })
604
+ })
605
+
606
+ await expect(
607
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
608
+ ).rejects.toSatisfy((err) => {
609
+ assert(err instanceof XrpcInvalidResponseError)
610
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
611
+ return true
612
+ })
613
+ })
614
+
615
+ it('accepts invalid response body when disabled', async () => {
616
+ // Schema expects { value: string } but server returns { value: 123 }
617
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
618
+ return Response.json({ value: 123 })
619
+ })
620
+
621
+ const response = await xrpc(fetchHandler, testQuery, {
622
+ params: { limit: 10 },
623
+ validateResponse: false,
624
+ })
625
+
626
+ expect(response.success).toBe(true)
627
+ expect(response.body).toEqual({ value: 123 })
628
+ })
629
+
630
+ it('succeeds with valid response body when enabled', async () => {
631
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
632
+ return Response.json({ value: 'hello' })
633
+ })
634
+
635
+ const response = await xrpc(fetchHandler, testQuery, {
636
+ params: { limit: 10 },
637
+ validateResponse: true,
638
+ })
639
+
640
+ expect(response.success).toBe(true)
641
+ expect(response.body).toEqual({ value: 'hello' })
642
+ })
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
+ })
804
+ })
805
+
806
+ describe(xrpcSafe, () => {
807
+ describe('success paths', () => {
808
+ it('returns successful result for a JSON query', async () => {
809
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
810
+ return Response.json({ value: 'hello' })
811
+ })
812
+
813
+ const result = await xrpcSafe(fetchHandler, testQuery, {
814
+ params: { limit: 5 },
815
+ })
816
+
817
+ assert(result.success)
818
+ expect(result).toBeInstanceOf(XrpcResponse)
819
+ expect(result.body).toEqual({ value: 'hello' })
820
+ expect(result.encoding).toBe('application/json')
821
+ expect(result.value).toBe(result)
822
+ })
823
+
824
+ it('returns successful result for a JSON procedure', async () => {
825
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
826
+ return Response.json({ id: 'new-id' })
827
+ })
828
+
829
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
830
+ body: { text: 'hello' },
831
+ })
832
+
833
+ assert(result.success)
834
+ expect(result.body).toEqual({ id: 'new-id' })
835
+ })
836
+
837
+ it('returns successful result for a binary query', async () => {
838
+ const bytes = new Uint8Array([5, 6, 7])
839
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
840
+ return new Response(bytes, {
841
+ headers: { 'content-type': 'application/octet-stream' },
842
+ })
843
+ })
844
+
845
+ const result = await xrpcSafe(fetchHandler, testBinaryQuery)
846
+
847
+ assert(result.success)
848
+ expect(result.body).toBeInstanceOf(Uint8Array)
849
+ expect(result.body).toEqual(bytes)
850
+ expect(result.isParsed).toBe(false)
851
+ })
852
+
853
+ it('returns successful result for a binary procedure', async () => {
854
+ const bytes = new Uint8Array([42])
855
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
856
+ return new Response(bytes, {
857
+ headers: { 'content-type': 'application/octet-stream' },
858
+ })
859
+ })
860
+
861
+ const result = await xrpcSafe(fetchHandler, testBinaryProcedure, {
862
+ body: new Uint8Array([1, 2]),
863
+ encoding: 'image/jpeg',
864
+ })
865
+
866
+ assert(result.success)
867
+ expect(result.body).toEqual(bytes)
868
+ })
869
+
870
+ it('returns successful result for a no-output query', async () => {
871
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
872
+ return new Response(null, { status: 200 })
873
+ })
874
+
875
+ const result = await xrpcSafe(fetchHandler, testNoOutputQuery)
876
+
877
+ assert(result.success)
878
+ expect(result.body).toBeUndefined()
879
+ expect(result.encoding).toBeUndefined()
880
+ })
881
+ })
882
+
883
+ describe('error handling', () => {
884
+ describe('fetch errors', () => {
885
+ it('returns XrpcFetchError when fetchHandler throws', async () => {
886
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
887
+ throw new TypeError('fetch failed')
888
+ })
889
+
890
+ const result = await xrpcSafe(fetchHandler, testQuery, {
891
+ params: { limit: 10 },
892
+ })
893
+
894
+ assert(!result.success)
895
+ expect(result).toBeInstanceOf(XrpcFetchError)
896
+ expect(result).toBeInstanceOf(XrpcInternalError)
897
+ })
898
+
899
+ it('returns XrpcFetchError when fetchHandler rejects', async () => {
900
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
901
+ throw new Error('network timeout')
902
+ })
903
+
904
+ const result = await xrpcSafe(fetchHandler, testQuery, {
905
+ params: { limit: 10 },
906
+ })
907
+
908
+ assert(!result.success)
909
+ expect(result).toBeInstanceOf(XrpcFetchError)
910
+ expect(result.message).toContain('network timeout')
911
+ })
912
+ })
913
+
914
+ describe('response errors', () => {
915
+ it('returns XrpcResponseError for 400 with valid error payload', async () => {
916
+ const fetchHandler: FetchHandler = async () =>
917
+ Response.json(
918
+ { error: 'TestError', message: 'bad request' },
919
+ { status: 400 },
920
+ )
921
+
922
+ const result = await xrpcSafe(fetchHandler, testQuery, {
923
+ params: { limit: 10 },
924
+ })
925
+
926
+ assert(!result.success)
927
+ assert(result instanceof XrpcResponseError)
928
+ expect(result.status).toBe(400)
929
+ expect(result.body).toEqual({
930
+ error: 'TestError',
931
+ message: 'bad request',
932
+ })
933
+ })
934
+
935
+ it('returns XrpcAuthenticationError for 401', async () => {
936
+ const fetchHandler: FetchHandler = async () =>
937
+ Response.json(
938
+ { error: 'AuthenticationRequired', message: 'Token expired' },
939
+ { status: 401 },
940
+ )
941
+
942
+ const result = await xrpcSafe(fetchHandler, testQuery, {
943
+ params: { limit: 10 },
944
+ })
945
+
946
+ assert(!result.success)
947
+ assert(result instanceof XrpcResponseError)
948
+ expect(result).toBeInstanceOf(XrpcAuthenticationError)
949
+ expect(result.status).toBe(401)
950
+ })
951
+
952
+ it('returns XrpcUpstreamError for non-XRPC error response', async () => {
953
+ const fetchHandler: FetchHandler = async () =>
954
+ new Response('Not Found', {
955
+ status: 404,
956
+ headers: { 'content-type': 'text/plain' },
957
+ })
958
+
959
+ const result = await xrpcSafe(fetchHandler, testQuery, {
960
+ params: { limit: 10 },
961
+ })
962
+
963
+ assert(!result.success)
964
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
965
+ })
966
+
967
+ it('returns XrpcUpstreamError for 500 without valid error payload', async () => {
968
+ const fetchHandler: FetchHandler = async () =>
969
+ new Response('Internal Server Error', {
970
+ status: 500,
971
+ headers: { 'content-type': 'text/html' },
972
+ })
973
+
974
+ const result = await xrpcSafe(fetchHandler, testQuery, {
975
+ params: { limit: 10 },
976
+ })
977
+
978
+ assert(!result.success)
979
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
980
+ })
981
+ })
982
+
983
+ describe('invalid response errors', () => {
984
+ it('returns XrpcInvalidResponseError when response body fails validation', async () => {
985
+ const fetchHandler: FetchHandler = async () =>
986
+ Response.json({ value: 123 })
987
+
988
+ const result = await xrpcSafe(fetchHandler, testQuery, {
989
+ params: { limit: 10 },
990
+ })
991
+
992
+ assert(!result.success)
993
+ expect(result).toBeInstanceOf(XrpcInvalidResponseError)
994
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
995
+ })
996
+
997
+ it('returns XrpcUpstreamError when response has wrong content-type', async () => {
998
+ const fetchHandler: FetchHandler = async () =>
999
+ new Response('binary data', {
1000
+ status: 200,
1001
+ headers: { 'content-type': 'text/plain' },
1002
+ })
1003
+
1004
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1005
+ params: { limit: 10 },
1006
+ })
1007
+
1008
+ assert(!result.success)
1009
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1010
+ })
1011
+ })
1012
+
1013
+ describe('content-type header errors', () => {
1014
+ it('returns XrpcInternalError when content-type header is set', async () => {
1015
+ const fetchHandler: FetchHandler = async () =>
1016
+ Response.json({ value: 'ok' })
1017
+
1018
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1019
+ params: { limit: 10 },
1020
+ headers: { 'content-type': 'application/json' },
1021
+ })
1022
+
1023
+ assert(!result.success)
1024
+ expect(result).toBeInstanceOf(XrpcInternalError)
1025
+ expect(result.cause).toBeInstanceOf(TypeError)
1026
+ })
1027
+ })
1028
+ })
1029
+
1030
+ describe('validateRequest', () => {
1031
+ it('returns XrpcInternalError for invalid query params when enabled', async () => {
1032
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1033
+ return Response.json({ value: 'ok' })
1034
+ })
1035
+
1036
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1037
+ // @ts-expect-error intentionally passing invalid params
1038
+ params: { limit: 'not-a-number' },
1039
+ validateRequest: true,
1040
+ })
1041
+
1042
+ assert(!result.success)
1043
+ expect(result).toBeInstanceOf(XrpcInternalError)
1044
+ expect(result).not.toBeInstanceOf(XrpcFetchError)
1045
+ })
1046
+
1047
+ it('returns XrpcInternalError for invalid body when enabled', async () => {
1048
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1049
+ return Response.json({ id: 'abc' })
1050
+ })
1051
+
1052
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
1053
+ // @ts-expect-error intentionally passing invalid body
1054
+ body: { text: 123 },
1055
+ validateRequest: true,
1056
+ })
1057
+
1058
+ assert(!result.success)
1059
+ expect(result).toBeInstanceOf(XrpcInternalError)
1060
+ expect(result).not.toBeInstanceOf(XrpcFetchError)
1061
+ })
1062
+
1063
+ it('skips body validation by default (invalid body sent as-is)', async () => {
1064
+ const fetchHandler: FetchHandler = async () => Response.json({ id: 'ok' })
1065
+
1066
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
1067
+ // @ts-expect-error intentionally passing invalid body
1068
+ body: { text: 123 },
1069
+ })
1070
+
1071
+ assert(result.success)
1072
+ expect(result.body).toEqual({ id: 'ok' })
1073
+ })
1074
+
1075
+ it('succeeds with valid body when enabled', async () => {
1076
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1077
+ return Response.json({ id: 'valid' })
1078
+ })
1079
+
1080
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
1081
+ body: { text: 'hello' },
1082
+ validateRequest: true,
1083
+ })
1084
+
1085
+ assert(result.success)
1086
+ expect(result.body).toEqual({ id: 'valid' })
1087
+ })
1088
+ })
1089
+
1090
+ describe('validateResponse', () => {
1091
+ it('returns XrpcInvalidResponseError for invalid body by default', async () => {
1092
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1093
+ return Response.json({ value: 123 })
1094
+ })
1095
+
1096
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1097
+ params: { limit: 10 },
1098
+ })
1099
+
1100
+ assert(!result.success)
1101
+ expect(result).toBeInstanceOf(XrpcInvalidResponseError)
1102
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
1103
+ })
1104
+
1105
+ it('accepts invalid response body when disabled', async () => {
1106
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1107
+ return Response.json({ value: 123 })
1108
+ })
1109
+
1110
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1111
+ params: { limit: 10 },
1112
+ validateResponse: false,
1113
+ })
1114
+
1115
+ assert(result.success)
1116
+ expect(result.body).toEqual({ value: 123 })
1117
+ })
1118
+
1119
+ it('succeeds with valid response body when enabled', async () => {
1120
+ const fetchHandler = vi.fn<FetchHandler>(async () => {
1121
+ return Response.json({ value: 'hello' })
1122
+ })
1123
+
1124
+ const result = await xrpcSafe(fetchHandler, testQuery, {
1125
+ params: { limit: 10 },
1126
+ validateResponse: true,
1127
+ })
1128
+
1129
+ assert(result.success)
1130
+ expect(result.body).toEqual({ value: 'hello' })
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
+ })
1452
+ })
1453
+ })