@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.
- package/README.md +42 -7
- package/dist/apis/cards/types.d.ts +89 -7
- package/dist/apis/cards/types.d.ts.map +1 -1
- package/dist/apis/pipeline/types.d.ts +67 -3
- package/dist/apis/pipeline/types.d.ts.map +1 -1
- package/dist/core/BaseApiClient.cjs +25 -31
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +2 -11
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +27 -31
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/errors.cjs +192 -0
- package/dist/core/errors.cjs.map +1 -0
- package/dist/core/errors.d.ts +117 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.mjs +177 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/index.cjs +16 -2
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +3 -2
- package/dist/incommingWebhooks/index.d.ts +3 -3
- package/dist/incommingWebhooks/index.d.ts.map +1 -1
- package/dist/index.cjs +16 -2
- package/dist/index.mjs +3 -2
- package/package.json +1 -1
- package/src/apis/cards/types.ts +104 -7
- package/src/apis/pipeline/types.ts +71 -3
- package/src/core/BaseApiClient.test.ts +221 -7
- package/src/core/BaseApiClient.ts +37 -50
- package/src/core/BaseCrudApi.test-d.ts +48 -0
- package/src/core/BaseCrudApi.test.ts +150 -0
- package/src/core/errors.test-d.ts +14 -0
- package/src/core/errors.test.ts +389 -0
- package/src/core/errors.ts +284 -0
- package/src/core/index.ts +18 -2
- 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
|
+
})
|