@ikatec/digisac-api-sdk 3.0.0 → 4.0.0

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.
Files changed (37) hide show
  1. package/README.md +42 -7
  2. package/dist/apis/cards/types.d.ts +89 -7
  3. package/dist/apis/cards/types.d.ts.map +1 -1
  4. package/dist/apis/pipeline/types.d.ts +67 -3
  5. package/dist/apis/pipeline/types.d.ts.map +1 -1
  6. package/dist/core/BaseApiClient.cjs +25 -31
  7. package/dist/core/BaseApiClient.cjs.map +1 -1
  8. package/dist/core/BaseApiClient.d.ts +2 -11
  9. package/dist/core/BaseApiClient.d.ts.map +1 -1
  10. package/dist/core/BaseApiClient.mjs +27 -31
  11. package/dist/core/BaseApiClient.mjs.map +1 -1
  12. package/dist/core/errors.cjs +192 -0
  13. package/dist/core/errors.cjs.map +1 -0
  14. package/dist/core/errors.d.ts +117 -0
  15. package/dist/core/errors.d.ts.map +1 -0
  16. package/dist/core/errors.mjs +177 -0
  17. package/dist/core/errors.mjs.map +1 -0
  18. package/dist/core/index.cjs +16 -2
  19. package/dist/core/index.d.ts +3 -2
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/core/index.mjs +3 -2
  22. package/dist/incommingWebhooks/index.d.ts +3 -3
  23. package/dist/incommingWebhooks/index.d.ts.map +1 -1
  24. package/dist/index.cjs +16 -2
  25. package/dist/index.mjs +3 -2
  26. package/package.json +1 -1
  27. package/src/apis/cards/types.ts +104 -7
  28. package/src/apis/pipeline/types.ts +71 -3
  29. package/src/core/BaseApiClient.test.ts +221 -7
  30. package/src/core/BaseApiClient.ts +37 -50
  31. package/src/core/BaseCrudApi.test-d.ts +48 -0
  32. package/src/core/BaseCrudApi.test.ts +150 -0
  33. package/src/core/errors.test-d.ts +14 -0
  34. package/src/core/errors.test.ts +389 -0
  35. package/src/core/errors.ts +284 -0
  36. package/src/core/index.ts +18 -2
  37. package/src/incommingWebhooks/index.ts +3 -3
@@ -0,0 +1,48 @@
1
+ import { expectError, expectType } from 'tsd'
2
+ import type { ApiClient } from './ApiClient'
3
+ import { BaseCrudApi } from './BaseCrudApi'
4
+ import type { Paginated } from './types'
5
+
6
+ // ── Test model ──────────────────────────────────────────────────────
7
+
8
+ type Item = { id: string; name: string; tags?: { id: string; label: string }[] }
9
+ type CreateItem = { name: string }
10
+ type UpdateItem = { name?: string }
11
+
12
+ declare const client: ApiClient
13
+ const api = new BaseCrudApi<Item, CreateItem, UpdateItem>(client, '/items')
14
+
15
+ // ── getMany overloads ───────────────────────────────────────────────
16
+
17
+ expectType<Promise<Paginated<Item>>>(api.getMany())
18
+ expectType<Promise<Paginated<Item>>>(api.getMany({ page: 1 }))
19
+ expectType<Promise<Paginated<Item>>>(api.getMany({ paginate: true }))
20
+ expectType<Promise<Item[]>>(api.getMany({ paginate: false }))
21
+ expectType<Promise<Item[]>>(api.getMany({ where: { name: 'x' }, paginate: false }))
22
+
23
+ // ── getOne / getById ────────────────────────────────────────────────
24
+
25
+ expectType<Promise<Item | null>>(api.getOne())
26
+ expectType<Promise<Item | null>>(api.getOne({ where: { name: 'x' } }))
27
+ expectType<Promise<Item>>(api.getById('1'))
28
+ expectType<Promise<Item>>(api.getById('1', { include: ['tags'], attributes: ['id'] }))
29
+
30
+ // getById only accepts include/attributes
31
+ expectError(api.getById('1', { where: { name: 'x' } }))
32
+ expectError(api.getById('1', { attributes: ['missing'] }))
33
+
34
+ // ── create / updateById / deleteById ────────────────────────────────
35
+
36
+ expectType<Promise<Item>>(api.create({ name: 'x' }))
37
+ expectType<Promise<Item>>(api.updateById('1', {}))
38
+ expectType<Promise<Item>>(api.deleteById('1'))
39
+
40
+ expectError(api.create({}))
41
+ expectError(api.create({ name: 1 }))
42
+ expectError(api.updateById('1', { name: 1 }))
43
+
44
+ // ── Query typing ────────────────────────────────────────────────────
45
+
46
+ expectError(api.getMany({ where: { missing: 'x' } }))
47
+ expectError(api.getMany({ order: [['missing', 'ASC']] }))
48
+ expectError(api.getMany({ include: ['name'] }))
@@ -8,6 +8,10 @@ import type { Paginated } from './types'
8
8
  type Item = { id: string; name: string }
9
9
  type CreateItem = { name: string }
10
10
  type UpdateItem = { name?: string }
11
+ type ItemWithTags = Item & {
12
+ owner?: { id: string; name: string }
13
+ tags?: { id: string; label: string }[]
14
+ }
11
15
 
12
16
  // ── Mock client factory ──────────────────────────────────────────────
13
17
 
@@ -23,6 +27,11 @@ function createMockClient(): ApiClient {
23
27
  }
24
28
  }
25
29
 
30
+ /** The URL passed to the nth `client.get` call, with the query string percent-decoded. */
31
+ function calledUrl(client: ApiClient, call = 0): string {
32
+ return decodeURIComponent(vi.mocked(client.get).mock.calls.at(call)?.at(0) as string)
33
+ }
34
+
26
35
  // ── Tests ────────────────────────────────────────────────────────────
27
36
 
28
37
  describe('BaseCrudApi', () => {
@@ -73,6 +82,30 @@ describe('BaseCrudApi', () => {
73
82
  })
74
83
  })
75
84
 
85
+ describe('method binding', () => {
86
+ it('keeps `this` when methods are destructured', async () => {
87
+ vi.mocked(client.get).mockResolvedValue([{ id: '1', name: 'x' }])
88
+ vi.mocked(client.post).mockResolvedValue({ id: '1', name: 'x' })
89
+ vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'x' })
90
+ vi.mocked(client.delete).mockResolvedValue({ id: '1', name: 'x' })
91
+ const { getMany, getOne, getById, create, updateById, deleteById } = api
92
+
93
+ await getMany({ paginate: false })
94
+ await getOne()
95
+ await getById('1')
96
+ await create({ name: 'x' })
97
+ await updateById('1', { name: 'x' })
98
+ await deleteById('1')
99
+
100
+ expect(calledUrl(client, 0)).toBe('/items?paginate=false')
101
+ expect(calledUrl(client, 1)).toBe('/items?limit=1&paginate=false')
102
+ expect(calledUrl(client, 2)).toBe('/items/1')
103
+ expect(client.post).toHaveBeenCalledWith('/items', { name: 'x' }, undefined)
104
+ expect(client.put).toHaveBeenCalledWith('/items/1', { name: 'x' }, undefined)
105
+ expect(client.delete).toHaveBeenCalledWith('/items/1', undefined)
106
+ })
107
+ })
108
+
76
109
  // ── getMany ──────────────────────────────────────────────────────
77
110
 
78
111
  describe('getMany', () => {
@@ -122,6 +155,27 @@ describe('BaseCrudApi', () => {
122
155
  expect(client.get).toHaveBeenCalledWith('/items', headers)
123
156
  })
124
157
 
158
+ it('returns the plain array when paginate is false', async () => {
159
+ const items: Item[] = [{ id: '1', name: 'a' }]
160
+ vi.mocked(client.get).mockResolvedValue(items)
161
+
162
+ const result = await api.getMany({ paginate: false })
163
+ expect(calledUrl(client)).toBe('/items?paginate=false')
164
+ expect(result).toEqual(items)
165
+ })
166
+
167
+ it('omits undefined query values', async () => {
168
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
169
+
170
+ // Explicit undefined is rejected by exactOptionalPropertyTypes but reaches the SDK from JS
171
+ // callers or from consumers compiled without that flag.
172
+ const query = { page: 2, perPage: undefined, where: { name: undefined } } as unknown as {
173
+ page: number
174
+ }
175
+ await api.getMany(query)
176
+ expect(calledUrl(client)).toBe('/items?page=2')
177
+ })
178
+
125
179
  it('forwards headers with query', async () => {
126
180
  vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
127
181
  const headers = { Authorization: 'Bearer token' }
@@ -146,6 +200,29 @@ describe('BaseCrudApi', () => {
146
200
  expect(result).toEqual(item)
147
201
  })
148
202
 
203
+ it('keeps the caller query and forces limit 1 and paginate false', async () => {
204
+ vi.mocked(client.get).mockResolvedValue([])
205
+
206
+ await api.getOne({
207
+ where: { name: 'x' },
208
+ order: [['name', 'DESC']],
209
+ limit: 50,
210
+ paginate: true,
211
+ })
212
+ expect(calledUrl(client)).toBe(
213
+ '/items?where[name]=x&order[0][0]=name&order[0][1]=DESC&limit=1&paginate=false',
214
+ )
215
+ })
216
+
217
+ it('returns only the first item when the API returns more than one', async () => {
218
+ vi.mocked(client.get).mockResolvedValue([
219
+ { id: '1', name: 'a' },
220
+ { id: '2', name: 'b' },
221
+ ])
222
+
223
+ expect(await api.getOne()).toEqual({ id: '1', name: 'a' })
224
+ })
225
+
149
226
  it('returns null when no records match', async () => {
150
227
  vi.mocked(client.get).mockResolvedValue([])
151
228
 
@@ -174,6 +251,21 @@ describe('BaseCrudApi', () => {
174
251
  expect(result).toEqual(item)
175
252
  })
176
253
 
254
+ it('serializes include and attributes into the query string', async () => {
255
+ vi.mocked(client.get).mockResolvedValue({ id: 'abc', name: 'Test' })
256
+
257
+ await api.getById('abc', { attributes: ['id', 'name'] })
258
+ expect(calledUrl(client)).toBe('/items/abc?attributes[0]=id&attributes[1]=name')
259
+ })
260
+
261
+ it('forwards query and headers together', async () => {
262
+ vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
263
+ const headers = { 'X-Tenant': '123' }
264
+
265
+ await api.getById('1', { attributes: ['id'] }, headers)
266
+ expect(client.get).toHaveBeenCalledWith('/items/1?attributes%5B0%5D=id', headers)
267
+ })
268
+
177
269
  it('forwards custom headers', async () => {
178
270
  vi.mocked(client.get).mockResolvedValue({ id: '1', name: 'x' })
179
271
  const headers = { 'X-Tenant': '123' }
@@ -269,6 +361,17 @@ describe('BaseCrudApi', () => {
269
361
  await expect(api.updateById('1', { name: 'x' })).rejects.toThrow('Conflict')
270
362
  })
271
363
 
364
+ it('propagates errors from client.get in getOne', async () => {
365
+ vi.mocked(client.get).mockRejectedValue(new Error('Unauthorized'))
366
+ await expect(api.getOne()).rejects.toThrow('Unauthorized')
367
+ })
368
+
369
+ it('rethrows the client error instance unchanged', async () => {
370
+ const error = Object.assign(new Error('Bad request'), { status: 400 })
371
+ vi.mocked(client.post).mockRejectedValue(error)
372
+ await expect(api.create({ name: 'bad' })).rejects.toBe(error)
373
+ })
374
+
272
375
  it('propagates errors from client.delete in deleteById', async () => {
273
376
  vi.mocked(client.delete).mockRejectedValue(new Error('Forbidden'))
274
377
  await expect(api.deleteById('1')).rejects.toThrow('Forbidden')
@@ -297,6 +400,53 @@ describe('BaseCrudApi', () => {
297
400
  expect(calledUrl).toContain('ASC')
298
401
  })
299
402
 
403
+ it('serializes nested include with where and include', async () => {
404
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
405
+ const tagsApi = new BaseCrudApi<ItemWithTags, CreateItem>(client, '/items')
406
+
407
+ await tagsApi.getMany({
408
+ include: ['owner', { model: 'tags', where: { label: 'vip' }, required: true }],
409
+ })
410
+ expect(calledUrl(client)).toBe(
411
+ '/items?include[0]=owner&include[1][model]=tags&include[1][where][label]=vip&include[1][required]=true',
412
+ )
413
+ })
414
+
415
+ it('serializes $in arrays with indices', async () => {
416
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
417
+
418
+ await api.getMany({ where: { id: { $in: ['a', 'b'] } } })
419
+ expect(calledUrl(client)).toBe('/items?where[id][$in][0]=a&where[id][$in][1]=b')
420
+ })
421
+
422
+ it('serializes $or/$and combinators', async () => {
423
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
424
+
425
+ await api.getMany({ where: { $or: [{ name: 'a' }, { name: 'b' }] } })
426
+ expect(calledUrl(client)).toBe('/items?where[$or][0][name]=a&where[$or][1][name]=b')
427
+ })
428
+
429
+ it('serializes order exactly', async () => {
430
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
431
+
432
+ await api.getMany({
433
+ order: [
434
+ ['name', 'ASC'],
435
+ ['id', 'desc'],
436
+ ],
437
+ })
438
+ expect(calledUrl(client)).toBe(
439
+ '/items?order[0][0]=name&order[0][1]=ASC&order[1][0]=id&order[1][1]=desc',
440
+ )
441
+ })
442
+
443
+ it('serializes booleans and numbers as strings', async () => {
444
+ vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
445
+
446
+ await api.getMany({ paranoid: false, distinct: true, limit: 10, offset: 20 })
447
+ expect(calledUrl(client)).toBe('/items?paranoid=false&distinct=true&limit=10&offset=20')
448
+ })
449
+
300
450
  it('handles empty query object', async () => {
301
451
  vi.mocked(client.get).mockResolvedValue({ data: [], total: 0 })
302
452
 
@@ -0,0 +1,14 @@
1
+ import { expectAssignable, expectType } from 'tsd'
2
+ import type { ApiError, ErrorClassName, FieldError, ValidationError } from './errors'
3
+
4
+ declare const apiError: ApiError
5
+ declare const validationError: ValidationError
6
+
7
+ // ValidationError always carries an array; ApiError may not
8
+ expectType<FieldError[]>(validationError.validationErrors)
9
+ expectType<FieldError[] | null>(apiError.validationErrors)
10
+
11
+ // errorClass accepts the known backend names and any other string
12
+ expectAssignable<ErrorClassName>('PaymentRequired')
13
+ expectAssignable<ErrorClassName>('SomeFutureError')
14
+ expectType<ErrorClassName | undefined>(apiError.errorClass)
@@ -0,0 +1,389 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import {
3
+ ApiConnectionError,
4
+ ApiError,
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ ConflictError,
8
+ DigisacError,
9
+ InternalServerError,
10
+ NotFoundError,
11
+ PaymentRequiredError,
12
+ PermissionDeniedError,
13
+ RateLimitError,
14
+ UnprocessableEntityError,
15
+ ValidationError,
16
+ parseFieldErrors,
17
+ } from './errors'
18
+
19
+ const fromResponse = (status: number, body?: unknown, headers: Record<string, string> = {}) =>
20
+ ApiError.fromResponse(status, 'Status Text', new Headers(headers), body)
21
+
22
+ describe('errors', () => {
23
+ afterEach(() => {
24
+ vi.useRealTimers()
25
+ })
26
+
27
+ // ── parseFieldErrors ─────────────────────────────────────────────
28
+
29
+ describe('parseFieldErrors', () => {
30
+ it('flattens one entry per message with location, field, path and code', () => {
31
+ expect(
32
+ parseFieldErrors({
33
+ body: {
34
+ title: {
35
+ messages: ['Required.', 'Too long.'],
36
+ types: ['required', 'hasLengthLesserThanOrEqual'],
37
+ },
38
+ },
39
+ query: { page: { messages: ['Should be a number.'], types: ['number'] } },
40
+ }),
41
+ ).toEqual([
42
+ {
43
+ location: 'body',
44
+ field: 'title',
45
+ path: 'body.title',
46
+ message: 'Required.',
47
+ code: 'required',
48
+ },
49
+ {
50
+ location: 'body',
51
+ field: 'title',
52
+ path: 'body.title',
53
+ message: 'Too long.',
54
+ code: 'hasLengthLesserThanOrEqual',
55
+ },
56
+ {
57
+ location: 'query',
58
+ field: 'page',
59
+ path: 'query.page',
60
+ message: 'Should be a number.',
61
+ code: 'number',
62
+ },
63
+ ])
64
+ })
65
+
66
+ it('falls back to the first type when there are fewer types than messages', () => {
67
+ expect(
68
+ parseFieldErrors({ body: { name: { messages: ['a', 'b'], types: ['required'] } } }),
69
+ ).toMatchObject([{ code: 'required' }, { code: 'required' }])
70
+ })
71
+
72
+ it('uses an empty code when there are no types', () => {
73
+ expect(parseFieldErrors({ body: { name: { messages: ['Required.'] } } })).toMatchObject([
74
+ { message: 'Required.', code: '' },
75
+ ])
76
+ })
77
+
78
+ it('stringifies non-string messages and types', () => {
79
+ expect(parseFieldErrors({ body: { n: { messages: [42], types: [true] } } })).toMatchObject([
80
+ { message: '42', code: 'true' },
81
+ ])
82
+ })
83
+
84
+ it('skips fields without a messages array', () => {
85
+ expect(
86
+ parseFieldErrors({
87
+ body: {
88
+ a: { messages: 'Required.' },
89
+ b: { types: ['required'] },
90
+ c: 'Required.',
91
+ d: null,
92
+ e: ['Required.'],
93
+ ok: { messages: ['Required.'], types: ['required'] },
94
+ },
95
+ }),
96
+ ).toEqual([
97
+ { location: 'body', field: 'ok', path: 'body.ok', message: 'Required.', code: 'required' },
98
+ ])
99
+ })
100
+
101
+ it('skips locations that are not objects', () => {
102
+ expect(parseFieldErrors({ body: 'invalid', params: [], query: null })).toBeNull()
103
+ })
104
+
105
+ it.each([
106
+ ['undefined', undefined],
107
+ ['null', null],
108
+ ['a string', 'invalid'],
109
+ ['an array', [{ path: 'name', message: 'required' }]],
110
+ ['an empty object', {}],
111
+ ['locations without fields', { body: {} }],
112
+ ])('returns null for %s', (_label, input) => {
113
+ expect(parseFieldErrors(input)).toBeNull()
114
+ })
115
+ })
116
+
117
+ // ── Class hierarchy ──────────────────────────────────────────────
118
+
119
+ describe('DigisacError', () => {
120
+ it('is an Error with name DigisacError', () => {
121
+ const error = new DigisacError('boom')
122
+ expect(error).toBeInstanceOf(Error)
123
+ expect(error).toMatchObject({ name: 'DigisacError', message: 'boom' })
124
+ })
125
+
126
+ it('keeps the cause', () => {
127
+ const cause = new Error('root')
128
+ expect(new DigisacError('boom', { cause }).cause).toBe(cause)
129
+ })
130
+ })
131
+
132
+ describe('ApiConnectionError', () => {
133
+ it('extends DigisacError but not ApiError', () => {
134
+ const error = new ApiConnectionError()
135
+ expect(error).toBeInstanceOf(DigisacError)
136
+ expect(error).not.toBeInstanceOf(ApiError)
137
+ expect(error).toMatchObject({ name: 'ApiConnectionError', message: 'Connection error.' })
138
+ })
139
+
140
+ it('keeps the message and cause', () => {
141
+ const cause = new TypeError('fetch failed')
142
+ const error = new ApiConnectionError('Request failed', { cause })
143
+ expect(error).toMatchObject({ message: 'Request failed', cause })
144
+ })
145
+ })
146
+
147
+ describe('ValidationError constructor', () => {
148
+ it('turns a null validationErrors into an empty array', () => {
149
+ const error = new ValidationError('Invalid', 'ValidationError', 400)
150
+ expect(error.validationErrors).toEqual([])
151
+ expect(error.name).toBe('ValidationError')
152
+ })
153
+ })
154
+
155
+ describe('ApiError constructor', () => {
156
+ it('keeps the positional arguments backward compatible', () => {
157
+ const error = new ApiError('Not found', 'NotFoundHttpError', 404)
158
+ expect(error).toBeInstanceOf(DigisacError)
159
+ expect(error).toMatchObject({
160
+ name: 'ApiError',
161
+ message: 'Not found',
162
+ errorClass: 'NotFoundHttpError',
163
+ status: 404,
164
+ validationErrors: null,
165
+ headers: undefined,
166
+ body: undefined,
167
+ })
168
+ })
169
+
170
+ it('accepts headers and body', () => {
171
+ const headers = new Headers({ 'X-Request-Id': 'req-1' })
172
+ const error = new ApiError('x', undefined, 500, null, { headers, body: 'raw' })
173
+ expect(error.headers).toBe(headers)
174
+ expect(error.body).toBe('raw')
175
+ })
176
+ })
177
+
178
+ // ── ApiError.fromResponse ────────────────────────────────────────
179
+
180
+ describe('ApiError.fromResponse', () => {
181
+ it.each([
182
+ [400, BadRequestError, 'BadRequestError'],
183
+ [401, AuthenticationError, 'AuthenticationError'],
184
+ [402, PaymentRequiredError, 'PaymentRequiredError'],
185
+ [403, PermissionDeniedError, 'PermissionDeniedError'],
186
+ [404, NotFoundError, 'NotFoundError'],
187
+ [409, ConflictError, 'ConflictError'],
188
+ [422, UnprocessableEntityError, 'UnprocessableEntityError'],
189
+ [429, RateLimitError, 'RateLimitError'],
190
+ [500, InternalServerError, 'InternalServerError'],
191
+ [502, InternalServerError, 'InternalServerError'],
192
+ [599, InternalServerError, 'InternalServerError'],
193
+ ])('maps %i to %o', (status, ErrorClass, name) => {
194
+ const error = fromResponse(status)
195
+ expect(error).toBeInstanceOf(ErrorClass)
196
+ expect(error).toBeInstanceOf(ApiError)
197
+ expect(error).toBeInstanceOf(DigisacError)
198
+ expect(error).toMatchObject({ name, status })
199
+ })
200
+
201
+ it.each([405, 408, 410, 418, 499])('maps unlisted status %i to a plain ApiError', (status) => {
202
+ const error = fromResponse(status)
203
+ expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
204
+ expect(error.name).toBe('ApiError')
205
+ })
206
+
207
+ describe('ValidationError', () => {
208
+ it('throws ValidationError for a 400 ValidationError, with its field errors', () => {
209
+ const error = fromResponse(400, {
210
+ error: 'ValidationError',
211
+ errors: { body: { name: { messages: ['Required.'], types: ['required'] } } },
212
+ })
213
+ expect(error).toBeInstanceOf(ValidationError)
214
+ expect(error).toBeInstanceOf(BadRequestError)
215
+ expect(error).toBeInstanceOf(ApiError)
216
+ expect(error).toMatchObject({
217
+ name: 'ValidationError',
218
+ errorClass: 'ValidationError',
219
+ validationErrors: [{ path: 'body.name', code: 'required' }],
220
+ })
221
+ })
222
+
223
+ it('gives ValidationError an empty validationErrors when there are no field details', () => {
224
+ const error = fromResponse(400, { error: 'ValidationError', message: 'Invalid file size.' })
225
+ expect(error).toBeInstanceOf(ValidationError)
226
+ expect(error.validationErrors).toEqual([])
227
+ })
228
+
229
+ it('keeps the status class for other backend names', () => {
230
+ const error = fromResponse(402, { error: 'PaymentRequired' })
231
+ expect(Object.getPrototypeOf(error)).toBe(PaymentRequiredError.prototype)
232
+ })
233
+
234
+ it.each([
235
+ ['ValidationError', 422, UnprocessableEntityError],
236
+ ['ValidationError', 500, InternalServerError],
237
+ ])('uses the status class for %s with status %i', (errorClass, status, ErrorClass) => {
238
+ const error = fromResponse(status, { error: errorClass })
239
+ expect(Object.getPrototypeOf(error)).toBe(ErrorClass.prototype)
240
+ expect(error.errorClass).toBe(errorClass)
241
+ })
242
+
243
+ it.each(['NotFoundHttpError', 'HttpError', 'constructor', 'toString', '__proto__'])(
244
+ 'uses the status class for %s',
245
+ (errorClass) => {
246
+ const error = fromResponse(404, { error: errorClass })
247
+ expect(Object.getPrototypeOf(error)).toBe(NotFoundError.prototype)
248
+ },
249
+ )
250
+ })
251
+
252
+ it('reads message, errorClass and field errors from the backend body', () => {
253
+ const body = {
254
+ error: 'ValidationError',
255
+ message: 'The given data was invalid.',
256
+ status: 400,
257
+ errors: { body: { name: { messages: ['Required.'], types: ['required'] } } },
258
+ }
259
+ const headers = { 'Content-Type': 'application/json' }
260
+ const error = fromResponse(400, body, headers)
261
+ expect(error).toMatchObject({
262
+ message: 'The given data was invalid.',
263
+ errorClass: 'ValidationError',
264
+ validationErrors: [{ path: 'body.name', code: 'required' }],
265
+ body,
266
+ })
267
+ expect(error.headers?.get('Content-Type')).toBe('application/json')
268
+ })
269
+
270
+ it('uses the error name as message when message is missing or empty', () => {
271
+ expect(fromResponse(403, { error: 'AccessDenied' }).message).toBe('AccessDenied')
272
+ expect(fromResponse(403, { error: 'AccessDenied', message: '' }).message).toBe('AccessDenied')
273
+ })
274
+
275
+ it('uses the default message when the body has neither message nor error', () => {
276
+ expect(fromResponse(500, {}).message).toBe('HTTP Error 500: Status Text')
277
+ })
278
+
279
+ it('ignores non-string message and error', () => {
280
+ const error = fromResponse(400, { error: 123, message: { text: 'x' } })
281
+ expect(error).toMatchObject({
282
+ message: 'HTTP Error 400: Status Text',
283
+ errorClass: undefined,
284
+ })
285
+ })
286
+
287
+ it.each([
288
+ ['undefined', undefined],
289
+ ['a string', '<html>bad gateway</html>'],
290
+ ['an array', ['error']],
291
+ ['null', null],
292
+ ])('uses the default message when the body is %s and keeps it as body', (_label, body) => {
293
+ const error = fromResponse(502, body)
294
+ expect(error).toMatchObject({
295
+ message: 'HTTP Error 502: Status Text',
296
+ errorClass: undefined,
297
+ validationErrors: null,
298
+ body,
299
+ })
300
+ })
301
+ })
302
+
303
+ // ── RateLimitError.retryAfter ────────────────────────────────────
304
+
305
+ describe('RateLimitError.retryAfter', () => {
306
+ const retryAfter = (value?: string) =>
307
+ (
308
+ fromResponse(
309
+ 429,
310
+ undefined,
311
+ value === undefined ? {} : { 'Retry-After': value },
312
+ ) as RateLimitError
313
+ ).retryAfter
314
+
315
+ it('reads delay seconds', () => {
316
+ expect(retryAfter('120')).toBe(120)
317
+ expect(retryAfter('0')).toBe(0)
318
+ expect(retryAfter('1.5')).toBe(1.5)
319
+ })
320
+
321
+ it('clamps negative seconds to 0', () => {
322
+ expect(retryAfter('-5')).toBe(0)
323
+ })
324
+
325
+ it('reads an HTTP date as seconds from now, rounded up', () => {
326
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00.500Z') })
327
+ expect(retryAfter('Thu, 01 Jan 2026 00:00:30 GMT')).toBe(30)
328
+ })
329
+
330
+ it('returns 0 for an HTTP date in the past', () => {
331
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
332
+ expect(retryAfter('Wed, 31 Dec 2025 23:59:00 GMT')).toBe(0)
333
+ })
334
+
335
+ it('returns null when the header is missing, empty or invalid', () => {
336
+ expect(retryAfter()).toBeNull()
337
+ expect(retryAfter('')).toBeNull()
338
+ expect(retryAfter('soon')).toBeNull()
339
+ })
340
+
341
+ it('returns null for a whitespace-only header', () => {
342
+ expect(retryAfter(' ')).toBeNull()
343
+ })
344
+
345
+ describe('X-RateLimit-Reset (Digisac backend)', () => {
346
+ const withHeaders = (headers: Record<string, string>) =>
347
+ (fromResponse(429, undefined, headers) as RateLimitError).retryAfter
348
+
349
+ it('reads a Unix time in seconds as seconds from now, rounded up', () => {
350
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00.500Z') })
351
+ const resetsAt = Date.parse('2026-01-01T00:10:00Z') / 1000
352
+ expect(withHeaders({ 'X-RateLimit-Reset': String(resetsAt) })).toBe(600)
353
+ })
354
+
355
+ it('returns 0 for a reset time in the past', () => {
356
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
357
+ const resetsAt = Date.parse('2025-12-31T23:00:00Z') / 1000
358
+ expect(withHeaders({ 'X-RateLimit-Reset': String(resetsAt) })).toBe(0)
359
+ })
360
+
361
+ it('reads small values as delay seconds', () => {
362
+ expect(withHeaders({ 'X-RateLimit-Reset': '45' })).toBe(45)
363
+ expect(withHeaders({ 'X-RateLimit-Reset': '0' })).toBe(0)
364
+ expect(withHeaders({ 'X-RateLimit-Reset': '-3' })).toBe(0)
365
+ })
366
+
367
+ it('matches the header name case-insensitively', () => {
368
+ expect(withHeaders({ 'x-ratelimit-reset': '45' })).toBe(45)
369
+ })
370
+
371
+ it('prefers Retry-After when both headers are present', () => {
372
+ expect(withHeaders({ 'Retry-After': '10', 'X-RateLimit-Reset': '45' })).toBe(10)
373
+ })
374
+
375
+ it('falls back to X-RateLimit-Reset when Retry-After is invalid', () => {
376
+ expect(withHeaders({ 'Retry-After': 'soon', 'X-RateLimit-Reset': '45' })).toBe(45)
377
+ })
378
+
379
+ it('returns null when the header is empty or not a number', () => {
380
+ expect(withHeaders({ 'X-RateLimit-Reset': '' })).toBeNull()
381
+ expect(withHeaders({ 'X-RateLimit-Reset': 'tomorrow' })).toBeNull()
382
+ })
383
+ })
384
+
385
+ it('returns null when the error has no headers', () => {
386
+ expect(new RateLimitError('x', undefined, 429).retryAfter).toBeNull()
387
+ })
388
+ })
389
+ })