@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,216 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ type Agent,
4
+ type AgentConfig,
5
+ type FetchHandler,
6
+ buildAgent,
7
+ isAgent,
8
+ } from './agent.js'
9
+
10
+ // ============================================================================
11
+ // isAgent
12
+ // ============================================================================
13
+
14
+ describe(isAgent, () => {
15
+ it('returns true for a valid agent with did', () => {
16
+ const agent: Agent = {
17
+ did: 'did:plc:example',
18
+ fetchHandler: async () => new Response(),
19
+ }
20
+ expect(isAgent(agent)).toBe(true)
21
+ })
22
+
23
+ it('returns true for an agent without did', () => {
24
+ const agent = { fetchHandler: async () => new Response() }
25
+ expect(isAgent(agent)).toBe(true)
26
+ })
27
+
28
+ it('returns true when did is undefined', () => {
29
+ const agent = { did: undefined, fetchHandler: async () => new Response() }
30
+ expect(isAgent(agent)).toBe(true)
31
+ })
32
+
33
+ it('returns false for null', () => {
34
+ expect(isAgent(null)).toBe(false)
35
+ })
36
+
37
+ it('returns false for non-objects', () => {
38
+ expect(isAgent('string')).toBe(false)
39
+ expect(isAgent(42)).toBe(false)
40
+ expect(isAgent(undefined)).toBe(false)
41
+ })
42
+
43
+ it('returns false when fetchHandler is not a function', () => {
44
+ expect(isAgent({ fetchHandler: 'not-a-function' })).toBe(false)
45
+ })
46
+
47
+ it('returns false when did is not a string', () => {
48
+ expect(isAgent({ did: 42, fetchHandler: async () => new Response() })).toBe(
49
+ false,
50
+ )
51
+ })
52
+ })
53
+
54
+ // ============================================================================
55
+ // buildAgent
56
+ // ============================================================================
57
+
58
+ describe(buildAgent, () => {
59
+ describe('from Agent', () => {
60
+ it('returns the same agent instance', () => {
61
+ const agent: Agent = {
62
+ did: 'did:plc:example',
63
+ fetchHandler: async () => new Response(),
64
+ }
65
+ expect(buildAgent(agent)).toBe(agent)
66
+ })
67
+ })
68
+
69
+ describe('from FetchHandler', () => {
70
+ it('wraps a function as an agent', () => {
71
+ const handler: FetchHandler = async () => new Response()
72
+ const agent = buildAgent(handler)
73
+
74
+ expect(agent.did).toBeUndefined()
75
+ expect(typeof agent.fetchHandler).toBe('function')
76
+ })
77
+ })
78
+
79
+ describe('from string URL', () => {
80
+ it('creates an agent that prepends the service URL', async () => {
81
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
82
+ Response.json({ ok: true }),
83
+ )
84
+ const agent = buildAgent({
85
+ service: 'https://example.com',
86
+ fetch: fetchFn,
87
+ })
88
+
89
+ await agent.fetchHandler('/xrpc/io.example.test', { method: 'GET' })
90
+
91
+ expect(fetchFn).toHaveBeenCalledOnce()
92
+ const [url, init] = fetchFn.mock.calls[0]
93
+ expect(url).toEqual(
94
+ new URL('/xrpc/io.example.test', 'https://example.com'),
95
+ )
96
+ expect(init?.method).toBe('GET')
97
+ })
98
+
99
+ it('has undefined did', () => {
100
+ const agent = buildAgent('https://example.com')
101
+ expect(agent.did).toBeUndefined()
102
+ })
103
+ })
104
+
105
+ describe('from URL instance', () => {
106
+ it('creates an agent with the URL as service', async () => {
107
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
108
+ Response.json({ ok: true }),
109
+ )
110
+ const agent = buildAgent({
111
+ service: new URL('https://example.com'),
112
+ fetch: fetchFn,
113
+ })
114
+
115
+ await agent.fetchHandler('/xrpc/io.example.test', { method: 'GET' })
116
+
117
+ expect(fetchFn).toHaveBeenCalledOnce()
118
+ const [url] = fetchFn.mock.calls[0]
119
+ expect(url).toEqual(
120
+ new URL('/xrpc/io.example.test', 'https://example.com'),
121
+ )
122
+ })
123
+ })
124
+
125
+ describe('from AgentConfig', () => {
126
+ it('exposes did from config', () => {
127
+ const agent = buildAgent({
128
+ did: 'did:plc:test123',
129
+ service: 'https://example.com',
130
+ })
131
+ expect(agent.did).toBe('did:plc:test123')
132
+ })
133
+
134
+ it('reflects did changes on the config object', () => {
135
+ const config: AgentConfig = {
136
+ did: 'did:plc:original',
137
+ service: 'https://example.com',
138
+ }
139
+ const agent = buildAgent(config)
140
+ expect(agent.did).toBe('did:plc:original')
141
+
142
+ config.did = 'did:plc:updated'
143
+ expect(agent.did).toBe('did:plc:updated')
144
+ })
145
+
146
+ it('throws TypeError when fetch is not available', () => {
147
+ const originalFetch = globalThis.fetch
148
+ try {
149
+ // @ts-expect-error removing fetch to simulate missing environment
150
+ globalThis.fetch = undefined
151
+ expect(() => buildAgent({ service: 'https://example.com' })).toThrow(
152
+ TypeError,
153
+ )
154
+ } finally {
155
+ globalThis.fetch = originalFetch
156
+ }
157
+ })
158
+ })
159
+
160
+ describe('headers', () => {
161
+ it('sends config headers when no request headers', async () => {
162
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
163
+ Response.json({}),
164
+ )
165
+ const agent = buildAgent({
166
+ service: 'https://example.com',
167
+ headers: { Authorization: 'Bearer token123' },
168
+ fetch: fetchFn,
169
+ })
170
+
171
+ await agent.fetchHandler('/xrpc/test', { method: 'GET' })
172
+
173
+ const [, init] = fetchFn.mock.calls[0]
174
+ expect(init?.headers).toEqual({ Authorization: 'Bearer token123' })
175
+ })
176
+
177
+ it('sends request headers when no config headers', async () => {
178
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
179
+ Response.json({}),
180
+ )
181
+ const agent = buildAgent({
182
+ service: 'https://example.com',
183
+ fetch: fetchFn,
184
+ })
185
+
186
+ await agent.fetchHandler('/xrpc/test', {
187
+ method: 'GET',
188
+ headers: { 'X-Custom': 'value' },
189
+ })
190
+
191
+ const [, init] = fetchFn.mock.calls[0]
192
+ expect(init?.headers).toEqual({ 'X-Custom': 'value' })
193
+ })
194
+
195
+ it('merges config and request headers, with request taking priority', async () => {
196
+ const fetchFn = vi.fn<typeof globalThis.fetch>(async () =>
197
+ Response.json({}),
198
+ )
199
+ const agent = buildAgent({
200
+ service: 'https://example.com',
201
+ headers: { Authorization: 'Bearer default', 'X-Default': 'yes' },
202
+ fetch: fetchFn,
203
+ })
204
+
205
+ await agent.fetchHandler('/xrpc/test', {
206
+ method: 'GET',
207
+ headers: { Authorization: 'Bearer override' },
208
+ })
209
+
210
+ const [, init] = fetchFn.mock.calls[0]
211
+ const headers = new Headers(init?.headers)
212
+ expect(headers.get('Authorization')).toBe('Bearer override')
213
+ expect(headers.get('X-Default')).toBe('yes')
214
+ })
215
+ })
216
+ })
package/src/agent.ts CHANGED
@@ -101,7 +101,7 @@ export type AgentConfig = {
101
101
  *
102
102
  * Can be a full {@link AgentConfig} object, or a simple service URL string/{@link URL}.
103
103
  */
104
- export type AgentOptions = AgentConfig | string | URL
104
+ export type AgentOptions = AgentConfig | FetchHandler | string | URL
105
105
 
106
106
  /**
107
107
  * Creates an {@link Agent} from various input types.
@@ -132,12 +132,14 @@ export function buildAgent<O extends Agent | AgentOptions>(
132
132
  options: O,
133
133
  ): O extends Agent ? O : Agent
134
134
  export function buildAgent(options: Agent | AgentOptions): Agent {
135
- if (isAgent(options)) return options
136
-
137
135
  const config: Agent | AgentConfig =
138
- typeof options === 'string' || options instanceof URL
139
- ? { did: undefined, service: options }
140
- : options
136
+ typeof options === 'function'
137
+ ? { did: undefined, fetchHandler: options }
138
+ : typeof options === 'string' || options instanceof URL
139
+ ? { did: undefined, service: options }
140
+ : options
141
+
142
+ if (isAgent(config)) return config
141
143
 
142
144
  const { service, fetch = globalThis.fetch } = config
143
145
 
package/src/client.ts CHANGED
@@ -20,10 +20,24 @@ import {
20
20
  import { Agent, AgentOptions, buildAgent } from './agent.js'
21
21
  import { XrpcFailure } from './errors.js'
22
22
  import { com } from './lexicons/index.js'
23
- import { XrpcResponse, XrpcResponseBody } from './response.js'
24
- import { BinaryBodyInit, CallOptions, Service } from './types.js'
25
- import { buildAtprotoHeaders } from './util.js'
26
- import { XrpcOptions, XrpcRequestParams, xrpc, xrpcSafe } from './xrpc.js'
23
+ import {
24
+ XrpcResponse,
25
+ XrpcResponseBody,
26
+ XrpcResponseOptions,
27
+ } from './response.js'
28
+ import { BinaryBodyInit, Service } from './types.js'
29
+ import {
30
+ XrpcRequestHeadersOptions,
31
+ applyDefaults,
32
+ buildXrpcRequestHeaders,
33
+ } from './util.js'
34
+ import {
35
+ XrpcOptions,
36
+ XrpcRequestParams,
37
+ XrpcRequestProcessingOptions,
38
+ xrpc,
39
+ xrpcSafe,
40
+ } from './xrpc.js'
27
41
 
28
42
  export type {
29
43
  AtIdentifierString,
@@ -58,13 +72,13 @@ export type {
58
72
  * }
59
73
  * ```
60
74
  */
61
- export type ClientOptions = {
62
- /** Labeler DIDs to include in requests for content moderation. */
63
- labelers?: Iterable<DidString>
64
- /** Custom headers to include in all requests made by this client. */
65
- headers?: HeadersInit
66
- /** Service proxy identifier for routing requests through a specific service. */
67
- service?: Service
75
+ export type ClientOptions = XrpcRequestHeadersOptions &
76
+ Pick<XrpcRequestProcessingOptions, 'validateRequest'> &
77
+ XrpcResponseOptions
78
+
79
+ export type ActionOptions = {
80
+ /** AbortSignal to cancel the request. */
81
+ signal?: AbortSignal
68
82
  }
69
83
 
70
84
  /**
@@ -87,7 +101,7 @@ export type ClientOptions = {
87
101
  export type Action<I = any, O = any> = (
88
102
  client: Client,
89
103
  input: I,
90
- options: CallOptions,
104
+ options: ActionOptions,
91
105
  ) => O | Promise<O>
92
106
 
93
107
  /**
@@ -109,7 +123,10 @@ export type InferActionOutput<A extends Action> =
109
123
  *
110
124
  * @see {@link Client.createRecord}
111
125
  */
112
- export type CreateRecordOptions = CallOptions & {
126
+ export type CreateRecordOptions = Omit<
127
+ XrpcOptions<typeof com.atproto.repo.createRecord.main>,
128
+ 'body'
129
+ > & {
113
130
  /** Repository identifier (DID or handle). Defaults to authenticated user's DID. */
114
131
  repo?: AtIdentifierString
115
132
  /** Compare-and-swap on the repo commit. If specified, must match current commit. */
@@ -123,7 +140,10 @@ export type CreateRecordOptions = CallOptions & {
123
140
  *
124
141
  * @see {@link Client.deleteRecord}
125
142
  */
126
- export type DeleteRecordOptions = CallOptions & {
143
+ export type DeleteRecordOptions = Omit<
144
+ XrpcOptions<typeof com.atproto.repo.deleteRecord.main>,
145
+ 'params'
146
+ > & {
127
147
  /** Repository identifier (DID or handle). Defaults to authenticated user's DID. */
128
148
  repo?: AtIdentifierString
129
149
  /** Compare-and-swap on the repo commit. If specified, must match current commit. */
@@ -137,7 +157,10 @@ export type DeleteRecordOptions = CallOptions & {
137
157
  *
138
158
  * @see {@link Client.getRecord}
139
159
  */
140
- export type GetRecordOptions = CallOptions & {
160
+ export type GetRecordOptions = Omit<
161
+ XrpcOptions<typeof com.atproto.repo.getRecord.main>,
162
+ 'params'
163
+ > & {
141
164
  /** Repository identifier (DID or handle). Defaults to authenticated user's DID. */
142
165
  repo?: AtIdentifierString
143
166
  }
@@ -147,7 +170,10 @@ export type GetRecordOptions = CallOptions & {
147
170
  *
148
171
  * @see {@link Client.putRecord}
149
172
  */
150
- export type PutRecordOptions = CallOptions & {
173
+ export type PutRecordOptions = Omit<
174
+ XrpcOptions<typeof com.atproto.repo.putRecord.main>,
175
+ 'body'
176
+ > & {
151
177
  /** Repository identifier (DID or handle). Defaults to authenticated user's DID. */
152
178
  repo?: AtIdentifierString
153
179
  /** Compare-and-swap on the repo commit. If specified, must match current commit. */
@@ -163,7 +189,10 @@ export type PutRecordOptions = CallOptions & {
163
189
  *
164
190
  * @see {@link Client.listRecords}
165
191
  */
166
- export type ListRecordsOptions = CallOptions & {
192
+ export type ListRecordsOptions = Omit<
193
+ XrpcOptions<typeof com.atproto.repo.listRecords.main>,
194
+ 'params'
195
+ > & {
167
196
  /** Repository identifier (DID or handle). Defaults to authenticated user's DID. */
168
197
  repo?: AtIdentifierString
169
198
  /** Maximum number of records to return. */
@@ -174,6 +203,16 @@ export type ListRecordsOptions = CallOptions & {
174
203
  reverse?: boolean
175
204
  }
176
205
 
206
+ export type UploadBlobOptions = Omit<
207
+ XrpcOptions<typeof com.atproto.repo.uploadBlob.main>,
208
+ 'body'
209
+ >
210
+
211
+ export type GetBlobOptions = Omit<
212
+ XrpcOptions<typeof com.atproto.sync.getBlob.main>,
213
+ 'params'
214
+ >
215
+
177
216
  export type RecordKeyOptions<
178
217
  T extends RecordSchema,
179
218
  AlsoOptionalWhenRecordKeyIs extends LexiconRecordKey = never,
@@ -315,11 +354,22 @@ export class Client implements Agent {
315
354
  /** Set of labeler DIDs specific to this client instance. */
316
355
  public readonly labelers: Set<DidString>
317
356
 
357
+ public readonly xrpcDefaults: {
358
+ readonly validateRequest: boolean
359
+ readonly validateResponse: boolean
360
+ readonly strictResponseProcessing: boolean
361
+ }
362
+
318
363
  constructor(agent: Agent | AgentOptions, options: ClientOptions = {}) {
319
364
  this.agent = buildAgent(agent)
320
365
  this.service = options.service
321
366
  this.labelers = new Set(options.labelers)
322
367
  this.headers = new Headers(options.headers)
368
+ this.xrpcDefaults = Object.freeze({
369
+ validateRequest: options.validateRequest ?? false,
370
+ validateResponse: options.validateResponse ?? true,
371
+ strictResponseProcessing: options.strictResponseProcessing ?? true,
372
+ })
323
373
  }
324
374
 
325
375
  /**
@@ -392,7 +442,7 @@ export class Client implements Agent {
392
442
  path: `/${string}`,
393
443
  init: RequestInit,
394
444
  ): Promise<Response> {
395
- const headers = buildAtprotoHeaders({
445
+ const headers = buildXrpcRequestHeaders({
396
446
  headers: init.headers,
397
447
  service: this.service,
398
448
  labelers: [
@@ -454,7 +504,7 @@ export class Client implements Agent {
454
504
  ns: Main<M>,
455
505
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
456
506
  ): Promise<XrpcResponse<M>> {
457
- return xrpc(this, ns, options)
507
+ return xrpc(this, ns, applyDefaults(options, this.xrpcDefaults))
458
508
  }
459
509
 
460
510
  /**
@@ -493,7 +543,7 @@ export class Client implements Agent {
493
543
  ns: Main<M>,
494
544
  options: XrpcOptions<M> = {} as XrpcOptions<M>,
495
545
  ): Promise<XrpcResponse<M> | XrpcFailure<M>> {
496
- return xrpcSafe(this, ns, options)
546
+ return xrpcSafe(this, ns, applyDefaults(options, this.xrpcDefaults))
497
547
  }
498
548
 
499
549
  /**
@@ -649,14 +699,8 @@ export class Client implements Agent {
649
699
  * console.log(response.body.blob) // Use this ref in records
650
700
  * ```
651
701
  */
652
- async uploadBlob(
653
- body: BinaryBodyInit,
654
- options?: CallOptions & { encoding?: `${string}/${string}` },
655
- ) {
656
- return this.xrpc(com.atproto.repo.uploadBlob.main, {
657
- ...options,
658
- body,
659
- })
702
+ async uploadBlob(body: BinaryBodyInit, options?: UploadBlobOptions) {
703
+ return this.xrpc(com.atproto.repo.uploadBlob.main, { ...options, body })
660
704
  }
661
705
 
662
706
  /**
@@ -666,7 +710,7 @@ export class Client implements Agent {
666
710
  * @param cid - The CID of the blob
667
711
  * @param options - Call options
668
712
  */
669
- async getBlob(did: DidString, cid: CidString, options?: CallOptions) {
713
+ async getBlob(did: DidString, cid: CidString, options?: GetBlobOptions) {
670
714
  return this.xrpc(com.atproto.sync.getBlob.main, {
671
715
  ...options,
672
716
  params: { did, cid },
@@ -723,7 +767,13 @@ export class Client implements Agent {
723
767
  : T extends Query
724
768
  ? XrpcRequestParams<T>
725
769
  : never,
726
- options?: CallOptions,
770
+ options?: T extends Action
771
+ ? ActionOptions
772
+ : T extends Procedure
773
+ ? Omit<XrpcOptions<T>, 'body'>
774
+ : T extends Query
775
+ ? Omit<XrpcOptions<T>, 'params'>
776
+ : never,
727
777
  ): Promise<
728
778
  T extends Action
729
779
  ? InferActionOutput<T>
@@ -736,7 +786,7 @@ export class Client implements Agent {
736
786
  public async call(
737
787
  ns: Main<Action> | Main<Procedure> | Main<Query>,
738
788
  arg?: LexValue | Params,
739
- options: CallOptions = {},
789
+ options: ActionOptions = {},
740
790
  ): Promise<unknown> {
741
791
  const method = getMain(ns)
742
792
 
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { IssueInvalidType, LexValidationError, l } from '@atproto/lex-schema'
3
3
  import {
4
4
  XrpcAuthenticationError,
5
+ XrpcFetchError,
5
6
  XrpcInternalError,
6
7
  XrpcInvalidResponseError,
7
8
  XrpcResponseError,
@@ -13,14 +14,14 @@ import {
13
14
  const testQuery = l.query(
14
15
  'io.example.test',
15
16
  l.params(),
16
- l.payload('application/json', l.object({ value: l.string() })),
17
+ l.jsonPayload({ value: l.string() }),
17
18
  ['TestError', 'AnotherError'],
18
19
  )
19
20
 
20
21
  const testQueryNoErrors = l.query(
21
22
  'io.example.noErrors',
22
23
  l.params(),
23
- l.payload('application/json', l.object({ value: l.string() })),
24
+ l.jsonPayload({ value: l.string() }),
24
25
  )
25
26
 
26
27
  // ============================================================================
@@ -42,6 +43,7 @@ describe(XrpcResponseError, () => {
42
43
 
43
44
  it('exposes status from the response', () => {
44
45
  const err = createResponseError(404, 'NotFound')
46
+ expect(err.reason).toBe(err)
45
47
  expect(err.status).toBe(404)
46
48
  })
47
49
 
@@ -54,6 +56,7 @@ describe(XrpcResponseError, () => {
54
56
  encoding: 'application/json',
55
57
  body: { error: 'TestError' },
56
58
  })
59
+ expect(err.reason).toBe(err)
57
60
  expect(err.headers.get('X-Test')).toBe('value')
58
61
  })
59
62
 
@@ -63,7 +66,7 @@ describe(XrpcResponseError, () => {
63
66
  })
64
67
 
65
68
  describe('toDownstreamError', () => {
66
- it('returns 502 for 5xx upstream errors', () => {
69
+ it('returns 502 for upstream 500 errors', () => {
67
70
  const err = createResponseError(
68
71
  500,
69
72
  'InternalServerError',
@@ -78,6 +81,17 @@ describe(XrpcResponseError, () => {
78
81
  })
79
82
  })
80
83
 
84
+ it('preserves original status for non-500 5xx errors', () => {
85
+ const err = createResponseError(503, 'ServiceUnavailable', 'Try later')
86
+ const downstream = err.toDownstreamError()
87
+
88
+ expect(downstream.status).toBe(503)
89
+ expect(downstream.body).toEqual({
90
+ error: 'ServiceUnavailable',
91
+ message: 'Try later',
92
+ })
93
+ })
94
+
81
95
  it('preserves original status for 4xx errors', () => {
82
96
  const err = createResponseError(404, 'NotFound', 'Record not found')
83
97
  const downstream = err.toDownstreamError()
@@ -159,6 +173,7 @@ describe(XrpcAuthenticationError, () => {
159
173
  encoding: 'application/json',
160
174
  body: { error: 'AuthenticationRequired' },
161
175
  })
176
+ expect(err.reason).toBe(err)
162
177
  expect(err.wwwAuthenticate).toHaveProperty('Bearer')
163
178
  })
164
179
 
@@ -195,6 +210,7 @@ describe(XrpcUpstreamError, () => {
195
210
  it('has error code UpstreamFailure', () => {
196
211
  const response = new Response(null, { status: 200 })
197
212
  const err = new XrpcUpstreamError(testQuery, response)
213
+ expect(err.reason).toBe(err)
198
214
  expect(err.error).toBe('UpstreamFailure')
199
215
  })
200
216
 
@@ -236,6 +252,7 @@ describe(XrpcInvalidResponseError, () => {
236
252
  )
237
253
 
238
254
  expect(err).toBeInstanceOf(XrpcUpstreamError)
255
+ expect(err.reason).toBe(err)
239
256
  expect(err.error).toBe('UpstreamFailure')
240
257
  expect(err.cause).toBe(validationError)
241
258
  })
@@ -277,14 +294,10 @@ describe(XrpcInvalidResponseError, () => {
277
294
  describe(XrpcInternalError, () => {
278
295
  it('has error code InternalServerError', () => {
279
296
  const err = new XrpcInternalError(testQuery)
297
+ expect(err.reason).toBe(err)
280
298
  expect(err.error).toBe('InternalServerError')
281
299
  })
282
300
 
283
- it('is always retryable', () => {
284
- const err = new XrpcInternalError(testQuery)
285
- expect(err.shouldRetry()).toBe(true)
286
- })
287
-
288
301
  it('toJSON does not expose internal details', () => {
289
302
  const err = new XrpcInternalError(
290
303
  testQuery,
@@ -305,6 +318,62 @@ describe(XrpcInternalError, () => {
305
318
  expect(downstream.body.error).toBe('InternalServerError')
306
319
  expect(downstream.body.message).toBe('Internal Server Error')
307
320
  })
321
+
322
+ it('is not retryable', () => {
323
+ const err = new XrpcInternalError(testQuery, 'something broke')
324
+ expect(err.shouldRetry()).toBe(false)
325
+ })
326
+ })
327
+
328
+ // ============================================================================
329
+ // XrpcFetchError
330
+ // ============================================================================
331
+
332
+ describe(XrpcFetchError, () => {
333
+ it('extends XrpcInternalError', () => {
334
+ const err = new XrpcFetchError(testQuery, new TypeError('fetch failed'))
335
+ expect(err).toBeInstanceOf(XrpcInternalError)
336
+ expect(err.error).toBe('InternalServerError')
337
+ })
338
+
339
+ it('uses cause message when cause is an Error', () => {
340
+ const cause = new TypeError('Failed to fetch')
341
+ const err = new XrpcFetchError(testQuery, cause)
342
+ expect(err.message).toBe('Unexpected fetchHandler() error: Failed to fetch')
343
+ expect(err.cause).toBe(cause)
344
+ })
345
+
346
+ it('uses fallback message when cause is not an Error', () => {
347
+ const err = new XrpcFetchError(testQuery, 'string cause')
348
+ expect(err.message).toBe('Unexpected fetchHandler() error: string cause')
349
+ expect(err.cause).toBe('string cause')
350
+ })
351
+
352
+ it('is retryable', () => {
353
+ const err = new XrpcFetchError(testQuery, new Error('network timeout'))
354
+ expect(err.shouldRetry()).toBe(true)
355
+ })
356
+
357
+ it('toJSON does not expose internal details', () => {
358
+ const err = new XrpcFetchError(
359
+ testQuery,
360
+ new Error('ECONNREFUSED 10.0.0.1:443'),
361
+ )
362
+ const json = err.toJSON()
363
+
364
+ expect(json.error).toBe('InternalServerError')
365
+ expect(json.message).toBe('Failed to perform upstream request')
366
+ expect(json.message).not.toContain('ECONNREFUSED')
367
+ })
368
+
369
+ it('toDownstreamError returns 502', () => {
370
+ const err = new XrpcFetchError(testQuery, new Error('DNS lookup failed'))
371
+ const downstream = err.toDownstreamError()
372
+
373
+ expect(downstream.status).toBe(502)
374
+ expect(downstream.body.error).toBe('InternalServerError')
375
+ expect(downstream.body.message).toBe('Failed to perform upstream request')
376
+ })
308
377
  })
309
378
 
310
379
  // ============================================================================