@atproto/lex-client 0.0.15 → 0.0.16

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,904 @@
1
+ import { assert, describe, expect, it, vi } from 'vitest'
2
+ import { l } from '@atproto/lex-schema'
3
+ import { FetchHandler } from './agent.js'
4
+ import {
5
+ XrpcAuthenticationError,
6
+ XrpcFetchError,
7
+ XrpcInternalError,
8
+ XrpcInvalidResponseError,
9
+ XrpcResponseError,
10
+ XrpcUpstreamError,
11
+ } from './errors.js'
12
+ import { XrpcResponse } from './response.js'
13
+ import { xrpc, xrpcSafe } from './xrpc.js'
14
+
15
+ // Fixtures
16
+
17
+ const testQuery = l.query(
18
+ 'io.example.testQuery',
19
+ l.params({ limit: l.optional(l.integer()) }),
20
+ l.jsonPayload({ value: l.string() }),
21
+ ['TestError'],
22
+ )
23
+
24
+ const testProcedure = l.procedure(
25
+ 'io.example.testProcedure',
26
+ l.params(),
27
+ l.jsonPayload({ text: l.string() }),
28
+ l.jsonPayload({ id: l.string() }),
29
+ ['ProcedureError'],
30
+ )
31
+
32
+ const testBinaryQuery = l.query(
33
+ 'io.example.testBinaryQuery',
34
+ l.params(),
35
+ l.payload('application/octet-stream', undefined),
36
+ )
37
+
38
+ const testBinaryProcedure = l.procedure(
39
+ 'io.example.testBinaryProcedure',
40
+ l.params(),
41
+ l.payload('image/*', undefined),
42
+ l.payload('application/octet-stream', undefined),
43
+ )
44
+
45
+ const testNoOutputQuery = l.query(
46
+ 'io.example.testNoOutputQuery',
47
+ l.params(),
48
+ l.payload(),
49
+ )
50
+
51
+ describe(xrpc, () => {
52
+ describe('success paths', () => {
53
+ it('returns parsed JSON body for a query', async () => {
54
+ const fetchHandler: FetchHandler = async () =>
55
+ Response.json({ value: 'hello' })
56
+
57
+ const response = await xrpc(fetchHandler, testQuery, {
58
+ params: { limit: 10 },
59
+ })
60
+
61
+ expect(response).toBeInstanceOf(XrpcResponse)
62
+ expect(response.success).toBe(true)
63
+ expect(response.status).toBe(200)
64
+ expect(response.body).toEqual({ value: 'hello' })
65
+ expect(response.encoding).toBe('application/json')
66
+ expect(response.isParsed).toBe(true)
67
+ expect(response.value).toBe(response)
68
+ })
69
+
70
+ it('returns parsed JSON body for a procedure', async () => {
71
+ const fetchHandler: FetchHandler = async () =>
72
+ Response.json({ id: 'abc123' })
73
+
74
+ const response = await xrpc(fetchHandler, testProcedure, {
75
+ body: { text: 'hello world' },
76
+ })
77
+
78
+ expect(response.success).toBe(true)
79
+ expect(response.status).toBe(200)
80
+ expect(response.body).toEqual({ id: 'abc123' })
81
+ expect(response.encoding).toBe('application/json')
82
+ })
83
+
84
+ it('returns binary body for a binary query', async () => {
85
+ const bytes = new Uint8Array([1, 2, 3, 4])
86
+ const fetchHandler: FetchHandler = async () =>
87
+ new Response(bytes, {
88
+ headers: { 'content-type': 'application/octet-stream' },
89
+ })
90
+
91
+ const response = await xrpc(fetchHandler, testBinaryQuery)
92
+
93
+ expect(response.success).toBe(true)
94
+ expect(response.body).toBeInstanceOf(Uint8Array)
95
+ expect(response.body).toEqual(bytes)
96
+ expect(response.encoding).toBe('application/octet-stream')
97
+ expect(response.isParsed).toBe(false)
98
+ })
99
+
100
+ it('returns binary body for a binary procedure', async () => {
101
+ const bytes = new Uint8Array([10, 20, 30])
102
+ const fetchHandler: FetchHandler = async () =>
103
+ new Response(bytes, {
104
+ headers: { 'content-type': 'application/octet-stream' },
105
+ })
106
+
107
+ const response = await xrpc(fetchHandler, testBinaryProcedure, {
108
+ body: new Uint8Array([99]),
109
+ encoding: 'image/png',
110
+ })
111
+
112
+ expect(response.success).toBe(true)
113
+ expect(response.body).toBeInstanceOf(Uint8Array)
114
+ expect(response.body).toEqual(bytes)
115
+ })
116
+
117
+ it('returns no body for a no-output query', async () => {
118
+ const fetchHandler: FetchHandler = async () =>
119
+ new Response(null, { status: 200 })
120
+
121
+ const response = await xrpc(fetchHandler, testNoOutputQuery)
122
+
123
+ expect(response.success).toBe(true)
124
+ expect(response.status).toBe(200)
125
+ expect(response.body).toBeUndefined()
126
+ expect(response.encoding).toBeUndefined()
127
+ })
128
+
129
+ it('passes query params as URL search params', async () => {
130
+ const fetchHandler = vi.fn<FetchHandler>(async () =>
131
+ Response.json({ value: 'ok' }),
132
+ )
133
+
134
+ await xrpc(fetchHandler, testQuery, { params: { limit: 25 } })
135
+
136
+ expect(fetchHandler).toHaveBeenCalledOnce()
137
+ const [path] = fetchHandler.mock.calls[0]
138
+ expect(path).toContain('/xrpc/io.example.testQuery')
139
+ expect(path).toContain('limit=25')
140
+ })
141
+
142
+ it('sends POST with JSON body for procedures', async () => {
143
+ const fetchHandler = vi.fn<FetchHandler>(async () =>
144
+ Response.json({ id: 'new-id' }),
145
+ )
146
+
147
+ await xrpc(fetchHandler, testProcedure, {
148
+ body: { text: 'test content' },
149
+ })
150
+
151
+ expect(fetchHandler).toHaveBeenCalledOnce()
152
+ const [, init] = fetchHandler.mock.calls[0]
153
+ expect(init.method).toBe('POST')
154
+ expect(new Headers(init.headers).get('content-type')).toBe(
155
+ 'application/json',
156
+ )
157
+ })
158
+
159
+ it('forwards custom headers', async () => {
160
+ const fetchHandler = vi.fn<FetchHandler>(async () =>
161
+ Response.json({ value: 'ok' }),
162
+ )
163
+
164
+ await xrpc(fetchHandler, testQuery, {
165
+ params: { limit: 1 },
166
+ headers: { authorization: 'Bearer token123' },
167
+ })
168
+
169
+ expect(fetchHandler).toHaveBeenCalledOnce()
170
+ const [, init] = fetchHandler.mock.calls[0]
171
+ expect(new Headers(init.headers).get('authorization')).toBe(
172
+ 'Bearer token123',
173
+ )
174
+ })
175
+
176
+ it('accepts optional params as omitted', async () => {
177
+ const fetchHandler: FetchHandler = async () =>
178
+ Response.json({ value: 'ok' })
179
+
180
+ const response = await xrpc(fetchHandler, testQuery)
181
+
182
+ expect(response.success).toBe(true)
183
+ expect(response.body).toEqual({ value: 'ok' })
184
+ })
185
+ })
186
+
187
+ describe('error handling', () => {
188
+ describe('fetch errors', () => {
189
+ it('throws XrpcFetchError when fetchHandler throws', async () => {
190
+ const fetchHandler: FetchHandler = async () => {
191
+ throw new TypeError('fetch failed')
192
+ }
193
+
194
+ await expect(
195
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
196
+ ).rejects.toSatisfy((err) => {
197
+ assert(err instanceof XrpcFetchError)
198
+ expect(err).toBeInstanceOf(XrpcInternalError)
199
+ expect(err.cause).toBeInstanceOf(TypeError)
200
+ expect(err.message).toContain('fetch failed')
201
+ return true
202
+ })
203
+ })
204
+
205
+ it('throws XrpcFetchError when fetchHandler rejects', async () => {
206
+ const fetchHandler: FetchHandler = async () => {
207
+ throw new Error('network timeout')
208
+ }
209
+
210
+ await expect(
211
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
212
+ ).rejects.toSatisfy((err) => {
213
+ assert(err instanceof XrpcFetchError)
214
+ expect(err.message).toContain('network timeout')
215
+ expect(err.shouldRetry()).toBe(true)
216
+ return true
217
+ })
218
+ })
219
+ })
220
+
221
+ describe('response errors', () => {
222
+ it('throws XrpcResponseError for 400 with valid error payload', async () => {
223
+ const fetchHandler: FetchHandler = async () =>
224
+ Response.json(
225
+ { error: 'TestError', message: 'bad request' },
226
+ { status: 400 },
227
+ )
228
+
229
+ await expect(
230
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
231
+ ).rejects.toSatisfy((err) => {
232
+ assert(err instanceof XrpcResponseError)
233
+ expect(err.status).toBe(400)
234
+ expect(err.body).toEqual({
235
+ error: 'TestError',
236
+ message: 'bad request',
237
+ })
238
+ return true
239
+ })
240
+ })
241
+
242
+ it('throws XrpcAuthenticationError for 401', async () => {
243
+ const fetchHandler: FetchHandler = async () =>
244
+ Response.json(
245
+ { error: 'AuthenticationRequired', message: 'Token expired' },
246
+ { status: 401 },
247
+ )
248
+
249
+ await expect(
250
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
251
+ ).rejects.toSatisfy((err) => {
252
+ assert(err instanceof XrpcAuthenticationError)
253
+ expect(err.status).toBe(401)
254
+ expect(err.message).toBe('Token expired')
255
+ return true
256
+ })
257
+ })
258
+
259
+ it('throws XrpcUpstreamError for non-XRPC error response', async () => {
260
+ const fetchHandler: FetchHandler = async () =>
261
+ new Response('Not Found', {
262
+ status: 404,
263
+ headers: { 'content-type': 'text/plain' },
264
+ })
265
+
266
+ await expect(
267
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
268
+ ).rejects.toSatisfy((err) => {
269
+ assert(err instanceof XrpcUpstreamError)
270
+ expect(err.message).toBe('Invalid response payload')
271
+ return true
272
+ })
273
+ })
274
+
275
+ it('throws XrpcUpstreamError for 500 without valid error payload', async () => {
276
+ const fetchHandler: FetchHandler = async () =>
277
+ new Response('Internal Server Error', {
278
+ status: 500,
279
+ headers: { 'content-type': 'text/html' },
280
+ })
281
+
282
+ await expect(
283
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
284
+ ).rejects.toSatisfy((err) => {
285
+ assert(err instanceof XrpcUpstreamError)
286
+ expect(err.message).toBe('Upstream server encountered an error')
287
+ return true
288
+ })
289
+ })
290
+
291
+ it('Reflects upstream 5xx errors with valid XRPC payload', async () => {
292
+ const fetchHandler: FetchHandler = async () =>
293
+ Response.json(
294
+ { error: 'ServerError', message: 'Something went wrong' },
295
+ { status: 502 },
296
+ )
297
+
298
+ await expect(
299
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
300
+ ).rejects.toSatisfy((err) => {
301
+ assert(err instanceof XrpcResponseError)
302
+ expect(err.status).toBe(502)
303
+ expect(err.body).toEqual({
304
+ error: 'ServerError',
305
+ message: 'Something went wrong',
306
+ })
307
+ return true
308
+ })
309
+ })
310
+ })
311
+
312
+ describe('invalid response errors', () => {
313
+ it('throws XrpcInvalidResponseError when response body fails validation', async () => {
314
+ // Schema expects { value: string } but we return { value: 123 }
315
+ const fetchHandler: FetchHandler = async () =>
316
+ Response.json({ value: 123 })
317
+
318
+ await expect(
319
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
320
+ ).rejects.toSatisfy((err) => {
321
+ assert(err instanceof XrpcInvalidResponseError)
322
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
323
+ expect(err.cause).toBeInstanceOf(Error)
324
+ return true
325
+ })
326
+ })
327
+
328
+ it('throws XrpcUpstreamError when response has wrong content-type', async () => {
329
+ const fetchHandler: FetchHandler = async () =>
330
+ new Response('binary data', {
331
+ status: 200,
332
+ headers: { 'content-type': 'text/plain' },
333
+ })
334
+
335
+ await expect(
336
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
337
+ ).rejects.toSatisfy((err) => {
338
+ assert(err instanceof XrpcUpstreamError)
339
+ expect(err.message).toContain('application/json')
340
+ return true
341
+ })
342
+ })
343
+ })
344
+
345
+ describe('content-type header errors', () => {
346
+ it('throws XrpcInternalError when content-type header is set', async () => {
347
+ const fetchHandler: FetchHandler = async () =>
348
+ Response.json({ value: 'ok' })
349
+
350
+ await expect(
351
+ xrpc(fetchHandler, testQuery, {
352
+ params: { limit: 10 },
353
+ headers: { 'content-type': 'application/json' },
354
+ }),
355
+ ).rejects.toSatisfy((err) => {
356
+ assert(err instanceof XrpcInternalError)
357
+ expect(err.cause).toBeInstanceOf(TypeError)
358
+ return true
359
+ })
360
+ })
361
+ })
362
+
363
+ describe('response payload parsing', () => {
364
+ it('throws XrpcUpstreamError when error response body cannot be parsed', async () => {
365
+ const fetchHandler: FetchHandler = async () =>
366
+ new Response('not valid json', {
367
+ status: 400,
368
+ headers: { 'content-type': 'application/json' },
369
+ })
370
+
371
+ await expect(
372
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
373
+ ).rejects.toSatisfy((err) => {
374
+ assert(err instanceof XrpcUpstreamError)
375
+ expect(err.message).toBe('Unable to parse response payload')
376
+ assert(err.cause instanceof Error)
377
+ expect(err.cause.message).toContain('Unexpected token')
378
+ return true
379
+ })
380
+ })
381
+
382
+ it('throws XrpcUpstreamError when success response body cannot be parsed', async () => {
383
+ const fetchHandler: FetchHandler = async () =>
384
+ new Response('not valid json', {
385
+ status: 200,
386
+ headers: { 'content-type': 'application/json' },
387
+ })
388
+
389
+ await expect(
390
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
391
+ ).rejects.toSatisfy((err) => {
392
+ assert(err instanceof XrpcUpstreamError)
393
+ expect(err.message).toBe('Unable to parse response payload')
394
+ assert(err.cause instanceof Error)
395
+ expect(err.cause.message).toContain('Unexpected token')
396
+ return true
397
+ })
398
+ })
399
+
400
+ it('throws XrpcUpstreamError when schema expects no payload but got one', async () => {
401
+ const fetchHandler: FetchHandler = async () =>
402
+ Response.json({ unexpected: 'data' })
403
+
404
+ await expect(xrpc(fetchHandler, testNoOutputQuery)).rejects.toSatisfy(
405
+ (err) => {
406
+ assert(err instanceof XrpcUpstreamError)
407
+ expect(err.message).toContain('no body')
408
+ return true
409
+ },
410
+ )
411
+ })
412
+
413
+ it('throws XrpcUpstreamError when schema expects payload but response is empty', async () => {
414
+ const fetchHandler: FetchHandler = async () =>
415
+ new Response(null, { status: 200 })
416
+
417
+ await expect(
418
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
419
+ ).rejects.toSatisfy((err) => {
420
+ assert(err instanceof XrpcUpstreamError)
421
+ expect(err.message).toContain('non-empty response')
422
+ return true
423
+ })
424
+ })
425
+ })
426
+
427
+ describe('content-type handling', () => {
428
+ it('parses content-type with charset parameter', async () => {
429
+ const fetchHandler: FetchHandler = async () =>
430
+ new Response(JSON.stringify({ value: 'hello' }), {
431
+ status: 200,
432
+ headers: { 'content-type': 'application/json; charset=utf-8' },
433
+ })
434
+
435
+ const response = await xrpc(fetchHandler, testQuery, {
436
+ params: { limit: 10 },
437
+ })
438
+
439
+ expect(response.success).toBe(true)
440
+ expect(response.body).toEqual({ value: 'hello' })
441
+ })
442
+
443
+ it('handles response with no content-type and empty body as no payload', async () => {
444
+ const fetchHandler: FetchHandler = async () =>
445
+ new Response(new ArrayBuffer(0), { status: 200 })
446
+
447
+ const response = await xrpc(fetchHandler, testNoOutputQuery)
448
+
449
+ expect(response.success).toBe(true)
450
+ expect(response.body).toBeUndefined()
451
+ })
452
+
453
+ it('treats response with no content-type but non-empty body as binary', async () => {
454
+ const bytes = new Uint8Array([1, 2, 3])
455
+ const fetchHandler: FetchHandler = async () =>
456
+ new Response(bytes, { status: 200 })
457
+
458
+ const response = await xrpc(fetchHandler, testBinaryQuery)
459
+
460
+ expect(response.success).toBe(true)
461
+ expect(response.body).toBeInstanceOf(Uint8Array)
462
+ expect(response.body).toEqual(bytes)
463
+ })
464
+ })
465
+
466
+ describe('non-2xx non-4xx/5xx responses', () => {
467
+ it('throws XrpcUpstreamError for 3xx status codes', async () => {
468
+ const fetchHandler: FetchHandler = async () =>
469
+ Response.json({ value: 'redirect' }, { status: 302 })
470
+
471
+ await expect(
472
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
473
+ ).rejects.toSatisfy((err) => {
474
+ assert(err instanceof XrpcUpstreamError)
475
+ expect(err.message).toBe('Invalid response status code')
476
+ return true
477
+ })
478
+ })
479
+ })
480
+ })
481
+
482
+ describe('validateRequest', () => {
483
+ it('rejects invalid query params when enabled', async () => {
484
+ const fetchHandler: FetchHandler = async () =>
485
+ Response.json({ value: 'ok' })
486
+
487
+ await expect(
488
+ xrpc(fetchHandler, testQuery, {
489
+ // @ts-expect-error intentionally passing invalid params
490
+ params: { limit: 'not-a-number' },
491
+ validateRequest: true,
492
+ }),
493
+ ).rejects.toSatisfy((err) => {
494
+ assert(err instanceof XrpcInternalError)
495
+ expect(err).not.toBeInstanceOf(XrpcFetchError)
496
+ return true
497
+ })
498
+ })
499
+
500
+ it('rejects invalid procedure body when enabled', async () => {
501
+ const fetchHandler: FetchHandler = async () =>
502
+ Response.json({ id: 'abc' })
503
+
504
+ await expect(
505
+ xrpc(fetchHandler, testProcedure, {
506
+ // @ts-expect-error intentionally passing invalid body
507
+ body: { text: 123 },
508
+ validateRequest: true,
509
+ }),
510
+ ).rejects.toSatisfy((err) => {
511
+ assert(err instanceof XrpcInternalError)
512
+ expect(err).not.toBeInstanceOf(XrpcFetchError)
513
+ return true
514
+ })
515
+ })
516
+
517
+ it('skips body validation by default (invalid body sent as-is)', async () => {
518
+ const fetchHandler: FetchHandler = async () => Response.json({ id: 'ok' })
519
+
520
+ // Invalid body ({ text: 123 }) is not validated client-side
521
+ const response = await xrpc(fetchHandler, testProcedure, {
522
+ // @ts-expect-error intentionally passing invalid body
523
+ body: { text: 123 },
524
+ })
525
+
526
+ expect(response.success).toBe(true)
527
+ expect(response.body).toEqual({ id: 'ok' })
528
+ })
529
+
530
+ it('succeeds with valid body when enabled', async () => {
531
+ const fetchHandler: FetchHandler = async () =>
532
+ Response.json({ id: 'valid' })
533
+
534
+ const response = await xrpc(fetchHandler, testProcedure, {
535
+ body: { text: 'hello' },
536
+ validateRequest: true,
537
+ })
538
+
539
+ expect(response.success).toBe(true)
540
+ expect(response.body).toEqual({ id: 'valid' })
541
+ })
542
+ })
543
+
544
+ describe('validateResponse', () => {
545
+ it('rejects invalid response body by default', async () => {
546
+ // Schema expects { value: string } but server returns { value: 123 }
547
+ const fetchHandler: FetchHandler = async () =>
548
+ Response.json({ value: 123 })
549
+
550
+ await expect(
551
+ xrpc(fetchHandler, testQuery, { params: { limit: 10 } }),
552
+ ).rejects.toSatisfy((err) => {
553
+ assert(err instanceof XrpcInvalidResponseError)
554
+ expect(err).toBeInstanceOf(XrpcUpstreamError)
555
+ return true
556
+ })
557
+ })
558
+
559
+ it('accepts invalid response body when disabled', async () => {
560
+ // Schema expects { value: string } but server returns { value: 123 }
561
+ const fetchHandler: FetchHandler = async () =>
562
+ Response.json({ value: 123 })
563
+
564
+ const response = await xrpc(fetchHandler, testQuery, {
565
+ params: { limit: 10 },
566
+ validateResponse: false,
567
+ })
568
+
569
+ expect(response.success).toBe(true)
570
+ expect(response.body).toEqual({ value: 123 })
571
+ })
572
+
573
+ it('succeeds with valid response body when enabled', async () => {
574
+ const fetchHandler: FetchHandler = async () =>
575
+ Response.json({ value: 'hello' })
576
+
577
+ const response = await xrpc(fetchHandler, testQuery, {
578
+ params: { limit: 10 },
579
+ validateResponse: true,
580
+ })
581
+
582
+ expect(response.success).toBe(true)
583
+ expect(response.body).toEqual({ value: 'hello' })
584
+ })
585
+ })
586
+ })
587
+
588
+ describe(xrpcSafe, () => {
589
+ describe('success paths', () => {
590
+ it('returns successful result for a JSON query', async () => {
591
+ const fetchHandler: FetchHandler = async () =>
592
+ Response.json({ value: 'hello' })
593
+
594
+ const result = await xrpcSafe(fetchHandler, testQuery, {
595
+ params: { limit: 5 },
596
+ })
597
+
598
+ assert(result.success)
599
+ expect(result).toBeInstanceOf(XrpcResponse)
600
+ expect(result.body).toEqual({ value: 'hello' })
601
+ expect(result.encoding).toBe('application/json')
602
+ expect(result.value).toBe(result)
603
+ })
604
+
605
+ it('returns successful result for a JSON procedure', async () => {
606
+ const fetchHandler: FetchHandler = async () =>
607
+ Response.json({ id: 'new-id' })
608
+
609
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
610
+ body: { text: 'hello' },
611
+ })
612
+
613
+ assert(result.success)
614
+ expect(result.body).toEqual({ id: 'new-id' })
615
+ })
616
+
617
+ it('returns successful result for a binary query', async () => {
618
+ const bytes = new Uint8Array([5, 6, 7])
619
+ const fetchHandler: FetchHandler = async () =>
620
+ new Response(bytes, {
621
+ headers: { 'content-type': 'application/octet-stream' },
622
+ })
623
+
624
+ const result = await xrpcSafe(fetchHandler, testBinaryQuery)
625
+
626
+ assert(result.success)
627
+ expect(result.body).toBeInstanceOf(Uint8Array)
628
+ expect(result.body).toEqual(bytes)
629
+ expect(result.isParsed).toBe(false)
630
+ })
631
+
632
+ it('returns successful result for a binary procedure', async () => {
633
+ const bytes = new Uint8Array([42])
634
+ const fetchHandler: FetchHandler = async () =>
635
+ new Response(bytes, {
636
+ headers: { 'content-type': 'application/octet-stream' },
637
+ })
638
+
639
+ const result = await xrpcSafe(fetchHandler, testBinaryProcedure, {
640
+ body: new Uint8Array([1, 2]),
641
+ encoding: 'image/jpeg',
642
+ })
643
+
644
+ assert(result.success)
645
+ expect(result.body).toEqual(bytes)
646
+ })
647
+
648
+ it('returns successful result for a no-output query', async () => {
649
+ const fetchHandler: FetchHandler = async () =>
650
+ new Response(null, { status: 200 })
651
+
652
+ const result = await xrpcSafe(fetchHandler, testNoOutputQuery)
653
+
654
+ assert(result.success)
655
+ expect(result.body).toBeUndefined()
656
+ expect(result.encoding).toBeUndefined()
657
+ })
658
+ })
659
+
660
+ describe('error handling', () => {
661
+ describe('fetch errors', () => {
662
+ it('returns XrpcFetchError when fetchHandler throws', async () => {
663
+ const fetchHandler: FetchHandler = async () => {
664
+ throw new TypeError('fetch failed')
665
+ }
666
+
667
+ const result = await xrpcSafe(fetchHandler, testQuery, {
668
+ params: { limit: 10 },
669
+ })
670
+
671
+ assert(!result.success)
672
+ expect(result).toBeInstanceOf(XrpcFetchError)
673
+ expect(result).toBeInstanceOf(XrpcInternalError)
674
+ })
675
+
676
+ it('returns XrpcFetchError when fetchHandler rejects', async () => {
677
+ const fetchHandler: FetchHandler = async () => {
678
+ throw new Error('network timeout')
679
+ }
680
+
681
+ const result = await xrpcSafe(fetchHandler, testQuery, {
682
+ params: { limit: 10 },
683
+ })
684
+
685
+ assert(!result.success)
686
+ expect(result).toBeInstanceOf(XrpcFetchError)
687
+ expect(result.message).toContain('network timeout')
688
+ })
689
+ })
690
+
691
+ describe('response errors', () => {
692
+ it('returns XrpcResponseError for 400 with valid error payload', async () => {
693
+ const fetchHandler: FetchHandler = async () =>
694
+ Response.json(
695
+ { error: 'TestError', message: 'bad request' },
696
+ { status: 400 },
697
+ )
698
+
699
+ const result = await xrpcSafe(fetchHandler, testQuery, {
700
+ params: { limit: 10 },
701
+ })
702
+
703
+ assert(!result.success)
704
+ assert(result instanceof XrpcResponseError)
705
+ expect(result.status).toBe(400)
706
+ expect(result.body).toEqual({
707
+ error: 'TestError',
708
+ message: 'bad request',
709
+ })
710
+ })
711
+
712
+ it('returns XrpcAuthenticationError for 401', async () => {
713
+ const fetchHandler: FetchHandler = async () =>
714
+ Response.json(
715
+ { error: 'AuthenticationRequired', message: 'Token expired' },
716
+ { status: 401 },
717
+ )
718
+
719
+ const result = await xrpcSafe(fetchHandler, testQuery, {
720
+ params: { limit: 10 },
721
+ })
722
+
723
+ assert(!result.success)
724
+ assert(result instanceof XrpcResponseError)
725
+ expect(result).toBeInstanceOf(XrpcAuthenticationError)
726
+ expect(result.status).toBe(401)
727
+ })
728
+
729
+ it('returns XrpcUpstreamError for non-XRPC error response', async () => {
730
+ const fetchHandler: FetchHandler = async () =>
731
+ new Response('Not Found', {
732
+ status: 404,
733
+ headers: { 'content-type': 'text/plain' },
734
+ })
735
+
736
+ const result = await xrpcSafe(fetchHandler, testQuery, {
737
+ params: { limit: 10 },
738
+ })
739
+
740
+ assert(!result.success)
741
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
742
+ })
743
+
744
+ it('returns XrpcUpstreamError for 500 without valid error payload', async () => {
745
+ const fetchHandler: FetchHandler = async () =>
746
+ new Response('Internal Server Error', {
747
+ status: 500,
748
+ headers: { 'content-type': 'text/html' },
749
+ })
750
+
751
+ const result = await xrpcSafe(fetchHandler, testQuery, {
752
+ params: { limit: 10 },
753
+ })
754
+
755
+ assert(!result.success)
756
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
757
+ })
758
+ })
759
+
760
+ describe('invalid response errors', () => {
761
+ it('returns XrpcInvalidResponseError when response body fails validation', async () => {
762
+ const fetchHandler: FetchHandler = async () =>
763
+ Response.json({ value: 123 })
764
+
765
+ const result = await xrpcSafe(fetchHandler, testQuery, {
766
+ params: { limit: 10 },
767
+ })
768
+
769
+ assert(!result.success)
770
+ expect(result).toBeInstanceOf(XrpcInvalidResponseError)
771
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
772
+ })
773
+
774
+ it('returns XrpcUpstreamError when response has wrong content-type', async () => {
775
+ const fetchHandler: FetchHandler = async () =>
776
+ new Response('binary data', {
777
+ status: 200,
778
+ headers: { 'content-type': 'text/plain' },
779
+ })
780
+
781
+ const result = await xrpcSafe(fetchHandler, testQuery, {
782
+ params: { limit: 10 },
783
+ })
784
+
785
+ assert(!result.success)
786
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
787
+ })
788
+ })
789
+
790
+ describe('content-type header errors', () => {
791
+ it('returns XrpcInternalError when content-type header is set', async () => {
792
+ const fetchHandler: FetchHandler = async () =>
793
+ Response.json({ value: 'ok' })
794
+
795
+ const result = await xrpcSafe(fetchHandler, testQuery, {
796
+ params: { limit: 10 },
797
+ headers: { 'content-type': 'application/json' },
798
+ })
799
+
800
+ assert(!result.success)
801
+ expect(result).toBeInstanceOf(XrpcInternalError)
802
+ expect(result.cause).toBeInstanceOf(TypeError)
803
+ })
804
+ })
805
+ })
806
+
807
+ describe('validateRequest', () => {
808
+ it('returns XrpcInternalError for invalid query params when enabled', async () => {
809
+ const fetchHandler: FetchHandler = async () =>
810
+ Response.json({ value: 'ok' })
811
+
812
+ const result = await xrpcSafe(fetchHandler, testQuery, {
813
+ // @ts-expect-error intentionally passing invalid params
814
+ params: { limit: 'not-a-number' },
815
+ validateRequest: true,
816
+ })
817
+
818
+ assert(!result.success)
819
+ expect(result).toBeInstanceOf(XrpcInternalError)
820
+ expect(result).not.toBeInstanceOf(XrpcFetchError)
821
+ })
822
+
823
+ it('returns XrpcInternalError for invalid body when enabled', async () => {
824
+ const fetchHandler: FetchHandler = async () =>
825
+ Response.json({ id: 'abc' })
826
+
827
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
828
+ // @ts-expect-error intentionally passing invalid body
829
+ body: { text: 123 },
830
+ validateRequest: true,
831
+ })
832
+
833
+ assert(!result.success)
834
+ expect(result).toBeInstanceOf(XrpcInternalError)
835
+ expect(result).not.toBeInstanceOf(XrpcFetchError)
836
+ })
837
+
838
+ it('skips body validation by default (invalid body sent as-is)', async () => {
839
+ const fetchHandler: FetchHandler = async () => Response.json({ id: 'ok' })
840
+
841
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
842
+ // @ts-expect-error intentionally passing invalid body
843
+ body: { text: 123 },
844
+ })
845
+
846
+ assert(result.success)
847
+ expect(result.body).toEqual({ id: 'ok' })
848
+ })
849
+
850
+ it('succeeds with valid body when enabled', async () => {
851
+ const fetchHandler: FetchHandler = async () =>
852
+ Response.json({ id: 'valid' })
853
+
854
+ const result = await xrpcSafe(fetchHandler, testProcedure, {
855
+ body: { text: 'hello' },
856
+ validateRequest: true,
857
+ })
858
+
859
+ assert(result.success)
860
+ expect(result.body).toEqual({ id: 'valid' })
861
+ })
862
+ })
863
+
864
+ describe('validateResponse', () => {
865
+ it('returns XrpcInvalidResponseError for invalid body by default', async () => {
866
+ const fetchHandler: FetchHandler = async () =>
867
+ Response.json({ value: 123 })
868
+
869
+ const result = await xrpcSafe(fetchHandler, testQuery, {
870
+ params: { limit: 10 },
871
+ })
872
+
873
+ assert(!result.success)
874
+ expect(result).toBeInstanceOf(XrpcInvalidResponseError)
875
+ expect(result).toBeInstanceOf(XrpcUpstreamError)
876
+ })
877
+
878
+ it('accepts invalid response body when disabled', async () => {
879
+ const fetchHandler: FetchHandler = async () =>
880
+ Response.json({ value: 123 })
881
+
882
+ const result = await xrpcSafe(fetchHandler, testQuery, {
883
+ params: { limit: 10 },
884
+ validateResponse: false,
885
+ })
886
+
887
+ assert(result.success)
888
+ expect(result.body).toEqual({ value: 123 })
889
+ })
890
+
891
+ it('succeeds with valid response body when enabled', async () => {
892
+ const fetchHandler: FetchHandler = async () =>
893
+ Response.json({ value: 'hello' })
894
+
895
+ const result = await xrpcSafe(fetchHandler, testQuery, {
896
+ params: { limit: 10 },
897
+ validateResponse: true,
898
+ })
899
+
900
+ assert(result.success)
901
+ expect(result.body).toEqual({ value: 'hello' })
902
+ })
903
+ })
904
+ })