@ikatec/digisac-api-sdk 3.1.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.
@@ -1,5 +1,20 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { BaseApiClient, ApiError } from './BaseApiClient'
2
+ import { BaseApiClient } from './BaseApiClient'
3
+ import {
4
+ ApiConnectionError,
5
+ ApiError,
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ ConflictError,
9
+ DigisacError,
10
+ InternalServerError,
11
+ NotFoundError,
12
+ PaymentRequiredError,
13
+ PermissionDeniedError,
14
+ RateLimitError,
15
+ UnprocessableEntityError,
16
+ ValidationError,
17
+ } from './errors'
3
18
 
4
19
  function mockFetch(response: {
5
20
  ok: boolean
@@ -216,21 +231,7 @@ describe('BaseApiClient', () => {
216
231
  })
217
232
  })
218
233
 
219
- it('includes validationErrors from JSON error response', async () => {
220
- const validationErrors = [{ path: 'name', message: 'required', code: 'REQUIRED' }]
221
- mockFetch({
222
- ok: false,
223
- status: 422,
224
- statusText: 'Unprocessable Entity',
225
- headers: { 'Content-Type': 'application/json' },
226
- body: JSON.stringify({ message: 'Bad input', validationErrors }),
227
- })
228
- await expect(client.get('/fail')).rejects.toMatchObject({
229
- validationErrors,
230
- })
231
- })
232
-
233
- it('maps the Digisac field errors (errors.<location>.<field>) into validationErrors', async () => {
234
+ it('maps the backend field errors (errors.<location>.<field>) into validationErrors', async () => {
234
235
  mockFetch({
235
236
  ok: false,
236
237
  status: 400,
@@ -252,23 +253,58 @@ describe('BaseApiClient', () => {
252
253
  },
253
254
  }),
254
255
  })
255
- await expect(client.post('/fail', {})).rejects.toMatchObject({
256
+ const error = await client.post('/fail', {}).catch((e: unknown) => e)
257
+ expect(error).toBeInstanceOf(ValidationError)
258
+ expect(error).toBeInstanceOf(BadRequestError)
259
+ expect(error).toMatchObject({
260
+ message: 'The given data was invalid.',
256
261
  errorClass: 'ValidationError',
257
262
  status: 400,
258
263
  validationErrors: [
259
- { path: 'body.name', message: 'Required.', code: 'required' },
260
- { path: 'body.title', message: 'Required.', code: 'required' },
261
264
  {
265
+ location: 'body',
266
+ field: 'name',
267
+ path: 'body.name',
268
+ message: 'Required.',
269
+ code: 'required',
270
+ },
271
+ {
272
+ location: 'body',
273
+ field: 'title',
274
+ path: 'body.title',
275
+ message: 'Required.',
276
+ code: 'required',
277
+ },
278
+ {
279
+ location: 'body',
280
+ field: 'title',
262
281
  path: 'body.title',
263
282
  message: 'Should contain lesser than or equals 255 characters.',
264
283
  code: 'hasLengthLesserThanOrEqual',
265
284
  },
266
- { path: 'params.contactId', message: 'Should be an UUID v4 string.', code: 'uuid4' },
285
+ {
286
+ location: 'params',
287
+ field: 'contactId',
288
+ path: 'params.contactId',
289
+ message: 'Should be an UUID v4 string.',
290
+ code: 'uuid4',
291
+ },
267
292
  ],
268
293
  })
269
294
  })
270
295
 
271
296
  it('keeps validationErrors null when the error body has no field errors', async () => {
297
+ mockFetch({
298
+ ok: false,
299
+ status: 400,
300
+ statusText: 'Bad Request',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ error: 'BadRequestHttpError', message: 'Já existe', status: 400 }),
303
+ })
304
+ await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
305
+ })
306
+
307
+ it('ignores malformed field errors', async () => {
272
308
  mockFetch({
273
309
  ok: false,
274
310
  status: 400,
@@ -276,13 +312,138 @@ describe('BaseApiClient', () => {
276
312
  headers: { 'Content-Type': 'application/json' },
277
313
  body: JSON.stringify({
278
314
  error: 'BadRequestHttpError',
279
- message: 'Já existe uma funil com esse nome',
280
- status: 400,
315
+ errors: { body: { name: 'Required.', title: { messages: 'Required.' } }, query: [] },
281
316
  }),
282
317
  })
283
318
  await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
284
319
  })
285
320
 
321
+ it.each([
322
+ [400, BadRequestError],
323
+ [401, AuthenticationError],
324
+ [402, PaymentRequiredError],
325
+ [403, PermissionDeniedError],
326
+ [404, NotFoundError],
327
+ [409, ConflictError],
328
+ [422, UnprocessableEntityError],
329
+ [429, RateLimitError],
330
+ [500, InternalServerError],
331
+ [503, InternalServerError],
332
+ ])('throws the matching subclass for status %i', async (status, ErrorClass) => {
333
+ mockFetch({ ok: false, status, statusText: 'Error' })
334
+ const error = await client.get('/fail').catch((e: unknown) => e)
335
+ expect(error).toBeInstanceOf(ErrorClass)
336
+ expect(error).toBeInstanceOf(ApiError)
337
+ expect(error).toBeInstanceOf(DigisacError)
338
+ expect(error).toMatchObject({ name: ErrorClass.name, status })
339
+ })
340
+
341
+ it('throws a plain ApiError for unmapped statuses', async () => {
342
+ mockFetch({ ok: false, status: 418, statusText: "I'm a teapot" })
343
+ const error = await client.get('/fail').catch((e: unknown) => e)
344
+ expect(error).toBeInstanceOf(ApiError)
345
+ expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
346
+ expect(error).toMatchObject({ name: 'ApiError', status: 418 })
347
+ })
348
+
349
+ it('exposes the response headers and the parsed body', async () => {
350
+ const body = { error: 'PaymentRequired', message: 'Limit', status: 402, extra: { limit: 10 } }
351
+ mockFetch({
352
+ ok: false,
353
+ status: 402,
354
+ statusText: 'Payment Required',
355
+ headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-1' },
356
+ body: JSON.stringify(body),
357
+ })
358
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as ApiError
359
+ expect(error.body).toEqual(body)
360
+ expect(error.headers?.get('X-Request-Id')).toBe('req-1')
361
+ })
362
+
363
+ it('exposes a non-JSON error body as text', async () => {
364
+ mockFetch({
365
+ ok: false,
366
+ status: 502,
367
+ statusText: 'Bad Gateway',
368
+ body: '<html>bad gateway</html>',
369
+ })
370
+ await expect(client.get('/fail')).rejects.toMatchObject({
371
+ message: 'HTTP Error 502: Bad Gateway',
372
+ body: '<html>bad gateway</html>',
373
+ })
374
+ })
375
+
376
+ it('reads Retry-After (seconds) on RateLimitError', async () => {
377
+ mockFetch({
378
+ ok: false,
379
+ status: 429,
380
+ statusText: 'Too Many Requests',
381
+ headers: { 'Retry-After': '30' },
382
+ })
383
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
384
+ expect(error.retryAfter).toBe(30)
385
+ })
386
+
387
+ it('reads Retry-After (HTTP date) on RateLimitError', async () => {
388
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
389
+ try {
390
+ mockFetch({
391
+ ok: false,
392
+ status: 429,
393
+ statusText: 'Too Many Requests',
394
+ headers: { 'Retry-After': 'Thu, 01 Jan 2026 00:01:00 GMT' },
395
+ })
396
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
397
+ expect(error.retryAfter).toBe(60)
398
+ } finally {
399
+ vi.useRealTimers()
400
+ }
401
+ })
402
+
403
+ it('reads X-RateLimit-Reset (Unix seconds) on RateLimitError', async () => {
404
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
405
+ try {
406
+ mockFetch({
407
+ ok: false,
408
+ status: 429,
409
+ statusText: 'Too Many Requests',
410
+ headers: { 'X-RateLimit-Reset': String(Date.parse('2026-01-01T01:00:00Z') / 1000) },
411
+ })
412
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
413
+ expect(error.retryAfter).toBe(3600)
414
+ } finally {
415
+ vi.useRealTimers()
416
+ }
417
+ })
418
+
419
+ it('returns null retryAfter when the header is absent', async () => {
420
+ mockFetch({ ok: false, status: 429, statusText: 'Too Many Requests' })
421
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
422
+ expect(error.retryAfter).toBeNull()
423
+ })
424
+
425
+ it('wraps network failures in ApiConnectionError', async () => {
426
+ const cause = new TypeError('fetch failed')
427
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
428
+ const error = await client.get('/fail').catch((e: unknown) => e)
429
+ expect(error).toBeInstanceOf(ApiConnectionError)
430
+ expect(error).toBeInstanceOf(DigisacError)
431
+ expect(error).not.toBeInstanceOf(ApiError)
432
+ expect(error).toMatchObject({
433
+ message: 'Request to GET https://api.example.com/fail failed: fetch failed',
434
+ cause,
435
+ })
436
+ })
437
+
438
+ it('does not report a body serialization failure as a connection error', async () => {
439
+ const spy = vi.spyOn(globalThis, 'fetch')
440
+ const circular: Record<string, unknown> = {}
441
+ circular.self = circular
442
+ const error = await client.post('/fail', circular).catch((e: unknown) => e)
443
+ expect(error).toBeInstanceOf(TypeError)
444
+ expect(spy).not.toHaveBeenCalled()
445
+ })
446
+
286
447
  it('falls back to default message when JSON parsing fails', async () => {
287
448
  mockFetch({
288
449
  ok: false,
@@ -1,65 +1,5 @@
1
1
  import type { ApiClient, HttpMethod } from './ApiClient'
2
-
3
- export interface ValidationError {
4
- path: string
5
- message: string
6
- code: string
7
- }
8
-
9
- /**
10
- * Digisac field errors: `{ errors: { <location>: { <field>: { messages: string[], types: string[] } } } }`,
11
- * where location is `body`, `params`, `query`, `headers` or `cookies` (backend `validate` middleware).
12
- */
13
- type DigisacFieldErrors = Record<string, Record<string, { messages?: unknown; types?: unknown }>>
14
-
15
- const isRecord = (value: unknown): value is Record<string, unknown> =>
16
- typeof value === 'object' && value !== null && !Array.isArray(value)
17
-
18
- /**
19
- * Flattens the Digisac `errors` object into `ValidationError[]` — one entry per message, with
20
- * `path` = `<location>.<field>` (e.g. `body.name`) and `code` = the rule type (e.g. `required`).
21
- * Returns `null` when the body carries no field errors.
22
- */
23
- function parseValidationErrors(errors: unknown): ValidationError[] | null {
24
- if (!isRecord(errors)) {
25
- return null
26
- }
27
-
28
- const parsed: ValidationError[] = []
29
- for (const [location, fields] of Object.entries(errors as DigisacFieldErrors)) {
30
- if (!isRecord(fields)) {
31
- continue
32
- }
33
- for (const [field, detail] of Object.entries(fields)) {
34
- const messages = Array.isArray(detail?.messages) ? detail.messages.map(String) : []
35
- const types = Array.isArray(detail?.types) ? detail.types.map(String) : []
36
- messages.forEach((message, index) => {
37
- parsed.push({ path: `${location}.${field}`, message, code: types[index] ?? types[0] ?? '' })
38
- })
39
- }
40
- }
41
-
42
- return parsed.length ? parsed : null
43
- }
44
-
45
- export class ApiError extends Error {
46
- public readonly errorClass?: string | undefined
47
- public readonly status?: number | undefined
48
- public readonly validationErrors: ValidationError[] | null
49
-
50
- constructor(
51
- message: string,
52
- errorClass?: string,
53
- status?: number,
54
- validationErrors: ValidationError[] | null = null,
55
- ) {
56
- super(message)
57
- this.name = 'ApiError'
58
- this.errorClass = errorClass
59
- this.status = status
60
- this.validationErrors = validationErrors
61
- }
62
- }
2
+ import { ApiConnectionError, ApiError } from './errors'
63
3
 
64
4
  export class BaseApiClient implements ApiClient {
65
5
  protected readonly baseUrl: string
@@ -82,8 +22,7 @@ export class BaseApiClient implements ApiClient {
82
22
  headers?: Record<string, string>,
83
23
  ): Promise<TResponse> {
84
24
  const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`
85
-
86
- const response = await fetch(url, {
25
+ const init: RequestInit = {
87
26
  method,
88
27
  headers: {
89
28
  'Content-Type': 'application/json',
@@ -91,35 +30,29 @@ export class BaseApiClient implements ApiClient {
91
30
  ...headers,
92
31
  },
93
32
  ...(body !== undefined && { body: JSON.stringify(body) }),
94
- })
33
+ }
34
+
35
+ let response: Response
36
+ try {
37
+ response = await fetch(url, init)
38
+ } catch (error) {
39
+ const reason = error instanceof Error ? error.message : String(error)
40
+ throw new ApiConnectionError(`Request to ${method} ${url} failed: ${reason}`, {
41
+ cause: error,
42
+ })
43
+ }
95
44
 
96
45
  return this.handleResponse<TResponse>(response)
97
46
  }
98
47
 
99
48
  protected async handleResponse<TResponse>(response: Response): Promise<TResponse> {
100
49
  if (!response.ok) {
101
- const contentType = response.headers.get('Content-Type')
102
- const isJson = contentType?.includes('application/json') ?? false
103
-
104
- let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`
105
- let errorClass: string | undefined
106
- let validationErrors: ValidationError[] | null = null
107
-
108
- if (isJson) {
109
- try {
110
- const errorText = await response.text()
111
- if (errorText) {
112
- const errorData = JSON.parse(errorText)
113
- errorMessage = errorData.message || errorData.error || errorMessage
114
- errorClass = errorData.error
115
- validationErrors = errorData.validationErrors ?? parseValidationErrors(errorData.errors)
116
- }
117
- } catch {
118
- // If JSON parsing fails, use the default error message
119
- }
120
- }
121
-
122
- throw new ApiError(errorMessage, errorClass, response.status, validationErrors)
50
+ throw ApiError.fromResponse(
51
+ response.status,
52
+ response.statusText,
53
+ response.headers,
54
+ await this.readErrorBody(response),
55
+ )
123
56
  }
124
57
 
125
58
  // 204 No Content never has a body; short-circuit before reading the stream.
@@ -151,6 +84,24 @@ export class BaseApiClient implements ApiClient {
151
84
  }
152
85
  }
153
86
 
87
+ /** Reads an error body: parsed JSON when possible, the raw text otherwise, `undefined` when empty. */
88
+ protected async readErrorBody(response: Response): Promise<unknown> {
89
+ let text: string
90
+ try {
91
+ text = await response.text()
92
+ } catch {
93
+ return undefined
94
+ }
95
+ if (!text) {
96
+ return undefined
97
+ }
98
+ try {
99
+ return JSON.parse(text)
100
+ } catch {
101
+ return text
102
+ }
103
+ }
104
+
154
105
  get<TResponse = unknown>(endpoint: string, headers?: Record<string, string>): Promise<TResponse> {
155
106
  return this.request<TResponse>('GET', endpoint, undefined, headers)
156
107
  }
@@ -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)