@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.
Files changed (139) hide show
  1. package/README.md +71 -9
  2. package/dist/apis/authHistory/AuthHistoryApi.cjs +3 -9
  3. package/dist/apis/authHistory/AuthHistoryApi.cjs.map +1 -1
  4. package/dist/apis/authHistory/AuthHistoryApi.d.ts +3 -4
  5. package/dist/apis/authHistory/AuthHistoryApi.d.ts.map +1 -1
  6. package/dist/apis/authHistory/AuthHistoryApi.mjs +3 -7
  7. package/dist/apis/authHistory/AuthHistoryApi.mjs.map +1 -1
  8. package/dist/apis/clientFeedback/ClientFeedbackApi.cjs +2 -5
  9. package/dist/apis/clientFeedback/ClientFeedbackApi.cjs.map +1 -1
  10. package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts +2 -4
  11. package/dist/apis/clientFeedback/ClientFeedbackApi.d.ts.map +1 -1
  12. package/dist/apis/clientFeedback/ClientFeedbackApi.mjs +3 -5
  13. package/dist/apis/clientFeedback/ClientFeedbackApi.mjs.map +1 -1
  14. package/dist/apis/contacts/ContactsApi.cjs +2 -2
  15. package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
  16. package/dist/apis/contacts/ContactsApi.mjs +2 -2
  17. package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
  18. package/dist/apis/me/MeApi.cjs +2 -5
  19. package/dist/apis/me/MeApi.cjs.map +1 -1
  20. package/dist/apis/me/MeApi.d.ts +2 -4
  21. package/dist/apis/me/MeApi.d.ts.map +1 -1
  22. package/dist/apis/me/MeApi.mjs +3 -5
  23. package/dist/apis/me/MeApi.mjs.map +1 -1
  24. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs +3 -9
  25. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.cjs.map +1 -1
  26. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts +3 -4
  27. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.d.ts.map +1 -1
  28. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs +3 -7
  29. package/dist/apis/serviceAccessManagement/ServiceAccessManagementApi.mjs.map +1 -1
  30. package/dist/apis/stickerUsers/StickerUsersApi.cjs +3 -9
  31. package/dist/apis/stickerUsers/StickerUsersApi.cjs.map +1 -1
  32. package/dist/apis/stickerUsers/StickerUsersApi.d.ts +3 -4
  33. package/dist/apis/stickerUsers/StickerUsersApi.d.ts.map +1 -1
  34. package/dist/apis/stickerUsers/StickerUsersApi.mjs +3 -7
  35. package/dist/apis/stickerUsers/StickerUsersApi.mjs.map +1 -1
  36. package/dist/apis/terms/TermsApi.cjs +3 -12
  37. package/dist/apis/terms/TermsApi.cjs.map +1 -1
  38. package/dist/apis/terms/TermsApi.d.ts +2 -4
  39. package/dist/apis/terms/TermsApi.d.ts.map +1 -1
  40. package/dist/apis/terms/TermsApi.mjs +3 -10
  41. package/dist/apis/terms/TermsApi.mjs.map +1 -1
  42. package/dist/apis/transcripts/TranscriptsApi.cjs +2 -5
  43. package/dist/apis/transcripts/TranscriptsApi.cjs.map +1 -1
  44. package/dist/apis/transcripts/TranscriptsApi.d.ts +2 -4
  45. package/dist/apis/transcripts/TranscriptsApi.d.ts.map +1 -1
  46. package/dist/apis/transcripts/TranscriptsApi.mjs +3 -5
  47. package/dist/apis/transcripts/TranscriptsApi.mjs.map +1 -1
  48. package/dist/core/ApiClient.d.ts +3 -0
  49. package/dist/core/ApiClient.d.ts.map +1 -1
  50. package/dist/core/ArchivableCrudApi.cjs +0 -2
  51. package/dist/core/ArchivableCrudApi.cjs.map +1 -1
  52. package/dist/core/ArchivableCrudApi.d.ts.map +1 -1
  53. package/dist/core/ArchivableCrudApi.mjs +0 -2
  54. package/dist/core/ArchivableCrudApi.mjs.map +1 -1
  55. package/dist/core/BaseApi.cjs +34 -0
  56. package/dist/core/BaseApi.cjs.map +1 -0
  57. package/dist/core/BaseApi.d.ts +20 -0
  58. package/dist/core/BaseApi.d.ts.map +1 -0
  59. package/dist/core/BaseApi.mjs +33 -0
  60. package/dist/core/BaseApi.mjs.map +1 -0
  61. package/dist/core/BaseApiClient.cjs +29 -57
  62. package/dist/core/BaseApiClient.cjs.map +1 -1
  63. package/dist/core/BaseApiClient.d.ts +8 -11
  64. package/dist/core/BaseApiClient.d.ts.map +1 -1
  65. package/dist/core/BaseApiClient.mjs +31 -57
  66. package/dist/core/BaseApiClient.mjs.map +1 -1
  67. package/dist/core/BaseCrudApi.cjs +6 -16
  68. package/dist/core/BaseCrudApi.cjs.map +1 -1
  69. package/dist/core/BaseCrudApi.d.ts +3 -2
  70. package/dist/core/BaseCrudApi.d.ts.map +1 -1
  71. package/dist/core/BaseCrudApi.mjs +6 -14
  72. package/dist/core/BaseCrudApi.mjs.map +1 -1
  73. package/dist/core/bindMethods.cjs +24 -0
  74. package/dist/core/bindMethods.cjs.map +1 -0
  75. package/dist/core/bindMethods.d.ts +7 -0
  76. package/dist/core/bindMethods.d.ts.map +1 -0
  77. package/dist/core/bindMethods.mjs +22 -0
  78. package/dist/core/bindMethods.mjs.map +1 -0
  79. package/dist/core/errors.cjs +192 -0
  80. package/dist/core/errors.cjs.map +1 -0
  81. package/dist/core/errors.d.ts +117 -0
  82. package/dist/core/errors.d.ts.map +1 -0
  83. package/dist/core/errors.mjs +177 -0
  84. package/dist/core/errors.mjs.map +1 -0
  85. package/dist/core/index.cjs +18 -2
  86. package/dist/core/index.d.ts +6 -2
  87. package/dist/core/index.d.ts.map +1 -1
  88. package/dist/core/index.mjs +4 -2
  89. package/dist/core/queryString.cjs +22 -0
  90. package/dist/core/queryString.cjs.map +1 -0
  91. package/dist/core/queryString.d.ts +14 -0
  92. package/dist/core/queryString.d.ts.map +1 -0
  93. package/dist/core/queryString.mjs +18 -0
  94. package/dist/core/queryString.mjs.map +1 -0
  95. package/dist/core/types.d.ts +2 -0
  96. package/dist/core/types.d.ts.map +1 -1
  97. package/dist/core/withQueryFormat.cjs +42 -0
  98. package/dist/core/withQueryFormat.cjs.map +1 -0
  99. package/dist/core/withQueryFormat.d.ts +10 -0
  100. package/dist/core/withQueryFormat.d.ts.map +1 -0
  101. package/dist/core/withQueryFormat.mjs +40 -0
  102. package/dist/core/withQueryFormat.mjs.map +1 -0
  103. package/dist/index.cjs +18 -2
  104. package/dist/index.mjs +4 -2
  105. package/package.json +1 -1
  106. package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
  107. package/src/apis/authHistory/AuthHistoryApi.ts +10 -10
  108. package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
  109. package/src/apis/clientFeedback/ClientFeedbackApi.ts +5 -7
  110. package/src/apis/contacts/ContactsApi.test.ts +1 -1
  111. package/src/apis/contacts/ContactsApi.ts +2 -2
  112. package/src/apis/me/MeApi.ts +2 -8
  113. package/src/apis/messages/MessagesApi.test.ts +2 -2
  114. package/src/apis/queryFormat.test.ts +198 -0
  115. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
  116. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.ts +10 -10
  117. package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
  118. package/src/apis/stickerUsers/StickerUsersApi.ts +7 -10
  119. package/src/apis/terms/TermsApi.ts +3 -12
  120. package/src/apis/transcripts/TranscriptsApi.ts +2 -7
  121. package/src/core/ApiClient.ts +4 -0
  122. package/src/core/ArchivableCrudApi.ts +0 -2
  123. package/src/core/BaseApi.test.ts +164 -0
  124. package/src/core/BaseApi.ts +32 -0
  125. package/src/core/BaseApiClient.test.ts +193 -22
  126. package/src/core/BaseApiClient.ts +44 -85
  127. package/src/core/BaseCrudApi.test-d.ts +57 -0
  128. package/src/core/BaseCrudApi.test.ts +252 -31
  129. package/src/core/BaseCrudApi.ts +14 -14
  130. package/src/core/bindMethods.ts +26 -0
  131. package/src/core/errors.test-d.ts +14 -0
  132. package/src/core/errors.test.ts +389 -0
  133. package/src/core/errors.ts +284 -0
  134. package/src/core/index.ts +21 -2
  135. package/src/core/queryString.test.ts +71 -0
  136. package/src/core/queryString.ts +30 -0
  137. package/src/core/types.ts +5 -0
  138. package/src/core/withQueryFormat.test.ts +136 -0
  139. package/src/core/withQueryFormat.ts +47 -0
@@ -1,15 +1,8 @@
1
- import qs from 'qs'
2
- import type { ApiClient } from '../../core/ApiClient'
1
+ import { BaseApi } from '../../core/BaseApi'
3
2
  import type { ListQuery, Paginated, HasGetMany } from '../../core/types'
4
3
  import type { StickerUser } from './types'
5
4
 
6
- export class StickerUsersApi implements HasGetMany<StickerUser> {
7
- private readonly client: ApiClient
8
-
9
- constructor(client: ApiClient) {
10
- this.client = client
11
- }
12
-
5
+ export class StickerUsersApi extends BaseApi implements HasGetMany<StickerUser> {
13
6
  /**
14
7
  * Returns a paginated list of sticker-user associations with their related file.
15
8
  * @permissions stickers.send
@@ -22,11 +15,15 @@ export class StickerUsersApi implements HasGetMany<StickerUser> {
22
15
  query?: ListQuery<StickerUser> & { paginate?: true },
23
16
  headers?: Record<string, string>,
24
17
  ): Promise<Paginated<StickerUser>>
18
+ getMany(
19
+ query?: ListQuery<StickerUser>,
20
+ headers?: Record<string, string>,
21
+ ): Promise<Paginated<StickerUser> | StickerUser[]>
25
22
  getMany(
26
23
  query?: ListQuery<StickerUser>,
27
24
  headers?: Record<string, string>,
28
25
  ): Promise<Paginated<StickerUser> | StickerUser[]> {
29
- const queryString = query ? `?${qs.stringify(query)}` : ''
26
+ const queryString = this.toQueryString(query)
30
27
  return this.client.get<Paginated<StickerUser> | StickerUser[]>(
31
28
  `/sticker-users${queryString}`,
32
29
  headers,
@@ -1,5 +1,4 @@
1
- import qs from 'qs'
2
- import type { ApiClient } from '../../core/ApiClient'
1
+ import { BaseApi } from '../../core/BaseApi'
3
2
  import type { GetByIdQuery, HasGetById } from '../../core/types'
4
3
  import type { Term } from './types'
5
4
 
@@ -10,20 +9,12 @@ import type { Term } from './types'
10
9
  * `GET /terms/:id/export-pdf` is not exposed: it answers a binary PDF, and `ApiClient` only
11
10
  * handles JSON/text bodies.
12
11
  */
13
- export class TermsApi implements HasGetById<Term> {
14
- protected readonly client: ApiClient
12
+ export class TermsApi extends BaseApi implements HasGetById<Term> {
15
13
  public readonly basePath = '/terms'
16
14
 
17
- constructor(client: ApiClient) {
18
- this.client = client
19
- this.getById = this.getById.bind(this)
20
- this.getNotAgreed = this.getNotAgreed.bind(this)
21
- this.agree = this.agree.bind(this)
22
- }
23
-
24
15
  /** Returns a single term by its ID, scoped to the current account (`null` when not found). */
25
16
  getById(id: string, query?: GetByIdQuery<Term>, headers?: Record<string, string>): Promise<Term> {
26
- const queryString = query ? `?${qs.stringify(query)}` : ''
17
+ const queryString = this.toQueryString(query)
27
18
  return this.client.get<Term>(`${this.basePath}/${id}${queryString}`, headers)
28
19
  }
29
20
 
@@ -1,15 +1,10 @@
1
- import type { ApiClient } from '../../core/ApiClient'
1
+ import { BaseApi } from '../../core/BaseApi'
2
2
  import type { HasGetById } from '../../core/types'
3
3
  import type { TranscriptResponse } from './types'
4
4
 
5
- export class TranscriptsApi implements HasGetById<TranscriptResponse> {
6
- private readonly client: ApiClient
5
+ export class TranscriptsApi extends BaseApi implements HasGetById<TranscriptResponse> {
7
6
  private readonly basePath = '/transcripts'
8
7
 
9
- constructor(client: ApiClient) {
10
- this.client = client
11
- }
12
-
13
8
  /** Triggers audio transcription for a message by dispatching an async job, and marks it as transcribing. */
14
9
  getById(id: string, headers?: Record<string, string>): Promise<TranscriptResponse> {
15
10
  return this.client.get<TranscriptResponse>(`${this.basePath}/${id}`, headers)
@@ -1,6 +1,10 @@
1
+ import type { QueryFormat } from './queryString'
2
+
1
3
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
2
4
 
3
5
  export interface ApiClient {
6
+ /** How list/get queries are serialized in the URL (defaults to `'json'` when absent). */
7
+ readonly queryFormat?: QueryFormat
4
8
  setAccessToken(accessToken: string): this
5
9
  request<TResponse = unknown, TBody = unknown>(
6
10
  method: HttpMethod,
@@ -20,8 +20,6 @@ export class ArchivableCrudApi<
20
20
  > extends BaseCrudApi<TResponse, TCreate, TUpdate, TQuery> {
21
21
  constructor(client: ApiClient, basePath: string) {
22
22
  super(client, basePath)
23
- this.archive = this.archive.bind(this)
24
- this.unarchive = this.unarchive.bind(this)
25
23
  }
26
24
 
27
25
  /**
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { ApiClient } from './ApiClient'
3
+ import { BaseApi } from './BaseApi'
4
+ import { BaseCrudApi } from './BaseCrudApi'
5
+ import { ArchivableCrudApi } from './ArchivableCrudApi'
6
+ import { bindMethods } from './bindMethods'
7
+ import { AuthHistoryApi } from '../apis/authHistory/AuthHistoryApi'
8
+ import { ClientFeedbackApi } from '../apis/clientFeedback/ClientFeedbackApi'
9
+ import { MeApi } from '../apis/me/MeApi'
10
+ import { ServiceAccessManagementApi } from '../apis/serviceAccessManagement/ServiceAccessManagementApi'
11
+ import { StickerUsersApi } from '../apis/stickerUsers/StickerUsersApi'
12
+ import { TermsApi } from '../apis/terms/TermsApi'
13
+ import { TranscriptsApi } from '../apis/transcripts/TranscriptsApi'
14
+
15
+ function createMockClient(): ApiClient {
16
+ return {
17
+ setAccessToken: vi.fn().mockReturnThis(),
18
+ request: vi.fn(),
19
+ get: vi.fn().mockResolvedValue([]),
20
+ post: vi.fn().mockResolvedValue({}),
21
+ put: vi.fn().mockResolvedValue({}),
22
+ patch: vi.fn().mockResolvedValue({}),
23
+ delete: vi.fn().mockResolvedValue({}),
24
+ }
25
+ }
26
+
27
+ // ── bindMethods ──────────────────────────────────────────────────────
28
+
29
+ describe('bindMethods', () => {
30
+ class Parent {
31
+ name = 'instance'
32
+ greet() {
33
+ return `parent:${this.name}`
34
+ }
35
+ shout() {
36
+ return this.greet().toUpperCase()
37
+ }
38
+ }
39
+
40
+ class Child extends Parent {
41
+ getterCalls = 0
42
+ override greet() {
43
+ return `child:${this.name}`
44
+ }
45
+ get computed() {
46
+ this.getterCalls += 1
47
+ return this.name
48
+ }
49
+ }
50
+
51
+ it('binds methods of the whole class chain so they survive destructuring', () => {
52
+ const child = new Child()
53
+ bindMethods(child)
54
+ const { greet, shout } = child
55
+ expect(greet()).toBe('child:instance')
56
+ expect(shout()).toBe('CHILD:INSTANCE')
57
+ })
58
+
59
+ it('binds the most derived implementation', () => {
60
+ const child = new Child()
61
+ bindMethods(child)
62
+ expect(Object.getOwnPropertyDescriptor(child, 'greet')?.value()).toBe('child:instance')
63
+ })
64
+
65
+ it('skips getters without calling them, and constructor', () => {
66
+ const child = new Child()
67
+ bindMethods(child)
68
+ expect(child.getterCalls).toBe(0)
69
+ expect(Object.hasOwn(child, 'computed')).toBe(false)
70
+ expect(Object.hasOwn(child, 'constructor')).toBe(false)
71
+ expect(child.computed).toBe('instance')
72
+ })
73
+
74
+ it('does not bind Object.prototype methods', () => {
75
+ const child = new Child()
76
+ bindMethods(child)
77
+ expect(Object.hasOwn(child, 'toString')).toBe(false)
78
+ expect(Object.hasOwn(child, 'hasOwnProperty')).toBe(false)
79
+ })
80
+ })
81
+
82
+ // ── BaseApi ──────────────────────────────────────────────────────────
83
+
84
+ describe('BaseApi', () => {
85
+ class ItemsApi extends BaseApi {
86
+ list(query?: object) {
87
+ return this.client.get(`/items${this.toQueryString(query)}`)
88
+ }
89
+ }
90
+
91
+ it('exposes the client', () => {
92
+ const client = createMockClient()
93
+ expect(new ItemsApi(client).client).toBe(client)
94
+ })
95
+
96
+ it('binds subclass methods and its own', async () => {
97
+ const client = createMockClient()
98
+ const { list, withQueryFormat } = new ItemsApi(client)
99
+ await list({ page: 1 })
100
+ await withQueryFormat('qs').list({ page: 1 })
101
+ expect(vi.mocked(client.get).mock.calls.map(([url]) => decodeURIComponent(url))).toEqual([
102
+ '/items?query={"page":1}',
103
+ '/items?page=1',
104
+ ])
105
+ })
106
+
107
+ it("serializes queries in the client's queryFormat", async () => {
108
+ const client = Object.assign(createMockClient(), { queryFormat: 'qs' as const })
109
+ await new ItemsApi(client).list({ where: { name: 'x' } })
110
+ expect(decodeURIComponent(vi.mocked(client.get).mock.calls[0]?.[0] as string)).toBe(
111
+ '/items?where[name]=x',
112
+ )
113
+ })
114
+
115
+ it('is the base of BaseCrudApi and ArchivableCrudApi', () => {
116
+ const client = createMockClient()
117
+ expect(new BaseCrudApi(client, '/items')).toBeInstanceOf(BaseApi)
118
+ expect(new ArchivableCrudApi(client, '/items')).toBeInstanceOf(BaseApi)
119
+ })
120
+
121
+ it('binds ArchivableCrudApi methods without manual binding', async () => {
122
+ const client = createMockClient()
123
+ const { archive, unarchive } = new ArchivableCrudApi(client, '/items')
124
+ await archive('1')
125
+ await unarchive('1')
126
+ expect(client.put).toHaveBeenNthCalledWith(1, '/items/1', expect.anything(), undefined)
127
+ expect(client.put).toHaveBeenNthCalledWith(2, '/items/1', { archivedAt: null }, undefined)
128
+ })
129
+ })
130
+
131
+ // ── APIs that don't extend BaseCrudApi ───────────────────────────────
132
+
133
+ describe.each([
134
+ ['AuthHistoryApi', (c: ApiClient) => new AuthHistoryApi(c).getMany],
135
+ ['ClientFeedbackApi', (c: ApiClient) => new ClientFeedbackApi(c).create],
136
+ ['MeApi', (c: ApiClient) => new MeApi(c).getMe],
137
+ ['ServiceAccessManagementApi', (c: ApiClient) => new ServiceAccessManagementApi(c).getMany],
138
+ ['StickerUsersApi', (c: ApiClient) => new StickerUsersApi(c).getMany],
139
+ ['TermsApi', (c: ApiClient) => new TermsApi(c).getNotAgreed],
140
+ ['TranscriptsApi', (c: ApiClient) => new TranscriptsApi(c).getById],
141
+ ])('%s', (_name, destructure) => {
142
+ it('extends BaseApi and keeps methods working when destructured', async () => {
143
+ const client = createMockClient()
144
+ const method = destructure(client) as (...args: unknown[]) => Promise<unknown>
145
+ await expect(method('id-1')).resolves.toBeDefined()
146
+ })
147
+ })
148
+
149
+ it('every API class that does not extend BaseCrudApi extends BaseApi', () => {
150
+ const client = createMockClient()
151
+ for (const api of [
152
+ new AuthHistoryApi(client),
153
+ new ClientFeedbackApi(client),
154
+ new MeApi(client),
155
+ new ServiceAccessManagementApi(client),
156
+ new StickerUsersApi(client),
157
+ new TermsApi(client),
158
+ new TranscriptsApi(client),
159
+ ]) {
160
+ expect(api).toBeInstanceOf(BaseApi)
161
+ expect(api.client).toBe(client)
162
+ expect(api.withQueryFormat('qs')).toBeInstanceOf(api.constructor)
163
+ }
164
+ })
@@ -0,0 +1,32 @@
1
+ import type { ApiClient } from './ApiClient'
2
+ import { bindMethods } from './bindMethods'
3
+ import { toQueryString, type QueryFormat } from './queryString'
4
+ import { cloneWithQueryFormat } from './withQueryFormat'
5
+
6
+ /**
7
+ * Base of every API class: holds the client, binds the methods to the instance (so they can be
8
+ * destructured) and serializes queries in the client's `queryFormat`.
9
+ */
10
+ export class BaseApi {
11
+ public readonly client: ApiClient
12
+
13
+ constructor(client: ApiClient) {
14
+ this.client = client
15
+ bindMethods(this)
16
+ }
17
+
18
+ /**
19
+ * Returns a copy of this API whose requests serialize queries in `format`, overriding the
20
+ * client's `queryFormat`. This instance is not changed.
21
+ *
22
+ * @example await contacts.withQueryFormat('qs').getMany({ where: { name: 'x' } })
23
+ */
24
+ withQueryFormat(format: QueryFormat): this {
25
+ return cloneWithQueryFormat(this, format)
26
+ }
27
+
28
+ /** Serializes a list/get query in the client's `queryFormat` (`''` when there is nothing to send). */
29
+ protected toQueryString(query?: object | null): string {
30
+ return toQueryString(query, this.client.queryFormat)
31
+ }
32
+ }
@@ -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
@@ -39,6 +54,16 @@ describe('BaseApiClient', () => {
39
54
  expect(spy).toHaveBeenCalledWith('https://api.example.com/test', expect.anything())
40
55
  })
41
56
 
57
+ it("defaults queryFormat to 'json'", () => {
58
+ expect(new BaseApiClient('https://api.example.com').queryFormat).toBe('json')
59
+ })
60
+
61
+ it('accepts a queryFormat option', () => {
62
+ expect(
63
+ new BaseApiClient('https://api.example.com', 'tok', { queryFormat: 'qs' }).queryFormat,
64
+ ).toBe('qs')
65
+ })
66
+
42
67
  // ── setAccessToken ───────────────────────────────────────────────
43
68
 
44
69
  it('returns this for chaining', () => {
@@ -216,21 +241,7 @@ describe('BaseApiClient', () => {
216
241
  })
217
242
  })
218
243
 
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 () => {
244
+ it('maps the backend field errors (errors.<location>.<field>) into validationErrors', async () => {
234
245
  mockFetch({
235
246
  ok: false,
236
247
  status: 400,
@@ -252,23 +263,58 @@ describe('BaseApiClient', () => {
252
263
  },
253
264
  }),
254
265
  })
255
- await expect(client.post('/fail', {})).rejects.toMatchObject({
266
+ const error = await client.post('/fail', {}).catch((e: unknown) => e)
267
+ expect(error).toBeInstanceOf(ValidationError)
268
+ expect(error).toBeInstanceOf(BadRequestError)
269
+ expect(error).toMatchObject({
270
+ message: 'The given data was invalid.',
256
271
  errorClass: 'ValidationError',
257
272
  status: 400,
258
273
  validationErrors: [
259
- { path: 'body.name', message: 'Required.', code: 'required' },
260
- { path: 'body.title', message: 'Required.', code: 'required' },
261
274
  {
275
+ location: 'body',
276
+ field: 'name',
277
+ path: 'body.name',
278
+ message: 'Required.',
279
+ code: 'required',
280
+ },
281
+ {
282
+ location: 'body',
283
+ field: 'title',
284
+ path: 'body.title',
285
+ message: 'Required.',
286
+ code: 'required',
287
+ },
288
+ {
289
+ location: 'body',
290
+ field: 'title',
262
291
  path: 'body.title',
263
292
  message: 'Should contain lesser than or equals 255 characters.',
264
293
  code: 'hasLengthLesserThanOrEqual',
265
294
  },
266
- { path: 'params.contactId', message: 'Should be an UUID v4 string.', code: 'uuid4' },
295
+ {
296
+ location: 'params',
297
+ field: 'contactId',
298
+ path: 'params.contactId',
299
+ message: 'Should be an UUID v4 string.',
300
+ code: 'uuid4',
301
+ },
267
302
  ],
268
303
  })
269
304
  })
270
305
 
271
306
  it('keeps validationErrors null when the error body has no field errors', async () => {
307
+ mockFetch({
308
+ ok: false,
309
+ status: 400,
310
+ statusText: 'Bad Request',
311
+ headers: { 'Content-Type': 'application/json' },
312
+ body: JSON.stringify({ error: 'BadRequestHttpError', message: 'Já existe', status: 400 }),
313
+ })
314
+ await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
315
+ })
316
+
317
+ it('ignores malformed field errors', async () => {
272
318
  mockFetch({
273
319
  ok: false,
274
320
  status: 400,
@@ -276,13 +322,138 @@ describe('BaseApiClient', () => {
276
322
  headers: { 'Content-Type': 'application/json' },
277
323
  body: JSON.stringify({
278
324
  error: 'BadRequestHttpError',
279
- message: 'Já existe uma funil com esse nome',
280
- status: 400,
325
+ errors: { body: { name: 'Required.', title: { messages: 'Required.' } }, query: [] },
281
326
  }),
282
327
  })
283
328
  await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
284
329
  })
285
330
 
331
+ it.each([
332
+ [400, BadRequestError],
333
+ [401, AuthenticationError],
334
+ [402, PaymentRequiredError],
335
+ [403, PermissionDeniedError],
336
+ [404, NotFoundError],
337
+ [409, ConflictError],
338
+ [422, UnprocessableEntityError],
339
+ [429, RateLimitError],
340
+ [500, InternalServerError],
341
+ [503, InternalServerError],
342
+ ])('throws the matching subclass for status %i', async (status, ErrorClass) => {
343
+ mockFetch({ ok: false, status, statusText: 'Error' })
344
+ const error = await client.get('/fail').catch((e: unknown) => e)
345
+ expect(error).toBeInstanceOf(ErrorClass)
346
+ expect(error).toBeInstanceOf(ApiError)
347
+ expect(error).toBeInstanceOf(DigisacError)
348
+ expect(error).toMatchObject({ name: ErrorClass.name, status })
349
+ })
350
+
351
+ it('throws a plain ApiError for unmapped statuses', async () => {
352
+ mockFetch({ ok: false, status: 418, statusText: "I'm a teapot" })
353
+ const error = await client.get('/fail').catch((e: unknown) => e)
354
+ expect(error).toBeInstanceOf(ApiError)
355
+ expect(Object.getPrototypeOf(error)).toBe(ApiError.prototype)
356
+ expect(error).toMatchObject({ name: 'ApiError', status: 418 })
357
+ })
358
+
359
+ it('exposes the response headers and the parsed body', async () => {
360
+ const body = { error: 'PaymentRequired', message: 'Limit', status: 402, extra: { limit: 10 } }
361
+ mockFetch({
362
+ ok: false,
363
+ status: 402,
364
+ statusText: 'Payment Required',
365
+ headers: { 'Content-Type': 'application/json', 'X-Request-Id': 'req-1' },
366
+ body: JSON.stringify(body),
367
+ })
368
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as ApiError
369
+ expect(error.body).toEqual(body)
370
+ expect(error.headers?.get('X-Request-Id')).toBe('req-1')
371
+ })
372
+
373
+ it('exposes a non-JSON error body as text', async () => {
374
+ mockFetch({
375
+ ok: false,
376
+ status: 502,
377
+ statusText: 'Bad Gateway',
378
+ body: '<html>bad gateway</html>',
379
+ })
380
+ await expect(client.get('/fail')).rejects.toMatchObject({
381
+ message: 'HTTP Error 502: Bad Gateway',
382
+ body: '<html>bad gateway</html>',
383
+ })
384
+ })
385
+
386
+ it('reads Retry-After (seconds) on RateLimitError', async () => {
387
+ mockFetch({
388
+ ok: false,
389
+ status: 429,
390
+ statusText: 'Too Many Requests',
391
+ headers: { 'Retry-After': '30' },
392
+ })
393
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
394
+ expect(error.retryAfter).toBe(30)
395
+ })
396
+
397
+ it('reads Retry-After (HTTP date) on RateLimitError', async () => {
398
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
399
+ try {
400
+ mockFetch({
401
+ ok: false,
402
+ status: 429,
403
+ statusText: 'Too Many Requests',
404
+ headers: { 'Retry-After': 'Thu, 01 Jan 2026 00:01:00 GMT' },
405
+ })
406
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
407
+ expect(error.retryAfter).toBe(60)
408
+ } finally {
409
+ vi.useRealTimers()
410
+ }
411
+ })
412
+
413
+ it('reads X-RateLimit-Reset (Unix seconds) on RateLimitError', async () => {
414
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
415
+ try {
416
+ mockFetch({
417
+ ok: false,
418
+ status: 429,
419
+ statusText: 'Too Many Requests',
420
+ headers: { 'X-RateLimit-Reset': String(Date.parse('2026-01-01T01:00:00Z') / 1000) },
421
+ })
422
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
423
+ expect(error.retryAfter).toBe(3600)
424
+ } finally {
425
+ vi.useRealTimers()
426
+ }
427
+ })
428
+
429
+ it('returns null retryAfter when the header is absent', async () => {
430
+ mockFetch({ ok: false, status: 429, statusText: 'Too Many Requests' })
431
+ const error = (await client.get('/fail').catch((e: unknown) => e)) as RateLimitError
432
+ expect(error.retryAfter).toBeNull()
433
+ })
434
+
435
+ it('wraps network failures in ApiConnectionError', async () => {
436
+ const cause = new TypeError('fetch failed')
437
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
438
+ const error = await client.get('/fail').catch((e: unknown) => e)
439
+ expect(error).toBeInstanceOf(ApiConnectionError)
440
+ expect(error).toBeInstanceOf(DigisacError)
441
+ expect(error).not.toBeInstanceOf(ApiError)
442
+ expect(error).toMatchObject({
443
+ message: 'Request to GET https://api.example.com/fail failed: fetch failed',
444
+ cause,
445
+ })
446
+ })
447
+
448
+ it('does not report a body serialization failure as a connection error', async () => {
449
+ const spy = vi.spyOn(globalThis, 'fetch')
450
+ const circular: Record<string, unknown> = {}
451
+ circular.self = circular
452
+ const error = await client.post('/fail', circular).catch((e: unknown) => e)
453
+ expect(error).toBeInstanceOf(TypeError)
454
+ expect(spy).not.toHaveBeenCalled()
455
+ })
456
+
286
457
  it('falls back to default message when JSON parsing fails', async () => {
287
458
  mockFetch({
288
459
  ok: false,