@ikatec/digisac-api-sdk 3.1.0 → 4.1.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 +71 -9
- package/dist/apis/authHistory/AuthHistoryApi.cjs +3 -9
- package/dist/apis/authHistory/AuthHistoryApi.cjs.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.d.ts +3 -4
- package/dist/apis/authHistory/AuthHistoryApi.d.ts.map +1 -1
- package/dist/apis/authHistory/AuthHistoryApi.mjs +3 -7
- package/dist/apis/authHistory/AuthHistoryApi.mjs.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.cjs +2 -5
- package/dist/apis/clientFeedback/ClientFeedbackApi.cjs.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts +2 -4
- package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts.map +1 -1
- package/dist/apis/clientFeedback/ClientFeedbackApi.mjs +3 -5
- package/dist/apis/clientFeedback/ClientFeedbackApi.mjs.map +1 -1
- package/dist/apis/contacts/ContactsApi.cjs +2 -2
- package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
- package/dist/apis/contacts/ContactsApi.mjs +2 -2
- package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
- package/dist/apis/me/MeApi.cjs +2 -5
- package/dist/apis/me/MeApi.cjs.map +1 -1
- package/dist/apis/me/MeApi.d.ts +2 -4
- package/dist/apis/me/MeApi.d.ts.map +1 -1
- package/dist/apis/me/MeApi.mjs +3 -5
- package/dist/apis/me/MeApi.mjs.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs +3 -9
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts +3 -4
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts.map +1 -1
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs +3 -7
- package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.cjs +3 -9
- package/dist/apis/stickerUsers/StickerUsersApi.cjs.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.d.ts +3 -4
- package/dist/apis/stickerUsers/StickerUsersApi.d.ts.map +1 -1
- package/dist/apis/stickerUsers/StickerUsersApi.mjs +3 -7
- package/dist/apis/stickerUsers/StickerUsersApi.mjs.map +1 -1
- package/dist/apis/terms/TermsApi.cjs +3 -12
- package/dist/apis/terms/TermsApi.cjs.map +1 -1
- package/dist/apis/terms/TermsApi.d.ts +2 -4
- package/dist/apis/terms/TermsApi.d.ts.map +1 -1
- package/dist/apis/terms/TermsApi.mjs +3 -10
- package/dist/apis/terms/TermsApi.mjs.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.cjs +2 -5
- package/dist/apis/transcripts/TranscriptsApi.cjs.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.d.ts +2 -4
- package/dist/apis/transcripts/TranscriptsApi.d.ts.map +1 -1
- package/dist/apis/transcripts/TranscriptsApi.mjs +3 -5
- package/dist/apis/transcripts/TranscriptsApi.mjs.map +1 -1
- package/dist/core/ApiClient.d.ts +3 -0
- package/dist/core/ApiClient.d.ts.map +1 -1
- package/dist/core/ArchivableCrudApi.cjs +0 -2
- package/dist/core/ArchivableCrudApi.cjs.map +1 -1
- package/dist/core/ArchivableCrudApi.d.ts.map +1 -1
- package/dist/core/ArchivableCrudApi.mjs +0 -2
- package/dist/core/ArchivableCrudApi.mjs.map +1 -1
- package/dist/core/BaseApi.cjs +34 -0
- package/dist/core/BaseApi.cjs.map +1 -0
- package/dist/core/BaseApi.d.ts +20 -0
- package/dist/core/BaseApi.d.ts.map +1 -0
- package/dist/core/BaseApi.mjs +33 -0
- package/dist/core/BaseApi.mjs.map +1 -0
- package/dist/core/BaseApiClient.cjs +29 -57
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts +8 -11
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +31 -57
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/BaseCrudApi.cjs +6 -16
- package/dist/core/BaseCrudApi.cjs.map +1 -1
- package/dist/core/BaseCrudApi.d.ts +3 -2
- package/dist/core/BaseCrudApi.d.ts.map +1 -1
- package/dist/core/BaseCrudApi.mjs +6 -14
- package/dist/core/BaseCrudApi.mjs.map +1 -1
- package/dist/core/bindMethods.cjs +24 -0
- package/dist/core/bindMethods.cjs.map +1 -0
- package/dist/core/bindMethods.d.ts +7 -0
- package/dist/core/bindMethods.d.ts.map +1 -0
- package/dist/core/bindMethods.mjs +22 -0
- package/dist/core/bindMethods.mjs.map +1 -0
- 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 +18 -2
- package/dist/core/index.d.ts +6 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +4 -2
- package/dist/core/queryString.cjs +22 -0
- package/dist/core/queryString.cjs.map +1 -0
- package/dist/core/queryString.d.ts +14 -0
- package/dist/core/queryString.d.ts.map +1 -0
- package/dist/core/queryString.mjs +18 -0
- package/dist/core/queryString.mjs.map +1 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/withQueryFormat.cjs +42 -0
- package/dist/core/withQueryFormat.cjs.map +1 -0
- package/dist/core/withQueryFormat.d.ts +10 -0
- package/dist/core/withQueryFormat.d.ts.map +1 -0
- package/dist/core/withQueryFormat.mjs +40 -0
- package/dist/core/withQueryFormat.mjs.map +1 -0
- package/dist/index.cjs +18 -2
- package/dist/index.mjs +4 -2
- package/package.json +1 -1
- package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
- package/src/apis/authHistory/AuthHistoryApi.ts +10 -10
- package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
- package/src/apis/clientFeedback/ClientFeedbackApi.ts +5 -7
- package/src/apis/contacts/ContactsApi.test.ts +1 -1
- package/src/apis/contacts/ContactsApi.ts +2 -2
- package/src/apis/me/MeApi.ts +2 -8
- package/src/apis/messages/MessagesApi.test.ts +2 -2
- package/src/apis/queryFormat.test.ts +198 -0
- package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
- package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.ts +10 -10
- package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
- package/src/apis/stickerUsers/StickerUsersApi.ts +7 -10
- package/src/apis/terms/TermsApi.ts +3 -12
- package/src/apis/transcripts/TranscriptsApi.ts +2 -7
- package/src/core/ApiClient.ts +4 -0
- package/src/core/ArchivableCrudApi.ts +0 -2
- package/src/core/BaseApi.test.ts +164 -0
- package/src/core/BaseApi.ts +32 -0
- package/src/core/BaseApiClient.test.ts +193 -22
- package/src/core/BaseApiClient.ts +44 -85
- package/src/core/BaseCrudApi.test-d.ts +57 -0
- package/src/core/BaseCrudApi.test.ts +252 -31
- package/src/core/BaseCrudApi.ts +14 -14
- package/src/core/bindMethods.ts +26 -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 +21 -2
- package/src/core/queryString.test.ts +71 -0
- package/src/core/queryString.ts +30 -0
- package/src/core/types.ts +5 -0
- package/src/core/withQueryFormat.test.ts +136 -0
- package/src/core/withQueryFormat.ts +47 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds every method of `instance`'s class chain (up to `Object.prototype`) to `instance`, as own
|
|
3
|
+
* properties, so methods keep working when destructured (`const { getMany } = api`). The most
|
|
4
|
+
* derived implementation wins; getters, setters and `constructor` are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export function bindMethods(instance: object): void {
|
|
7
|
+
const target = instance as Record<PropertyKey, unknown>
|
|
8
|
+
const bound = new Set<PropertyKey>()
|
|
9
|
+
|
|
10
|
+
for (
|
|
11
|
+
let prototype: object | null = Object.getPrototypeOf(instance);
|
|
12
|
+
prototype && prototype !== Object.prototype;
|
|
13
|
+
prototype = Object.getPrototypeOf(prototype)
|
|
14
|
+
) {
|
|
15
|
+
for (const key of Reflect.ownKeys(prototype)) {
|
|
16
|
+
if (key === 'constructor' || bound.has(key)) {
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
const { value } = Object.getOwnPropertyDescriptor(prototype, key) as PropertyDescriptor
|
|
20
|
+
if (typeof value === 'function') {
|
|
21
|
+
target[key] = value.bind(instance)
|
|
22
|
+
bound.add(key)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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
|
+
})
|