@taruvi/sdk 1.3.2 → 1.3.4-beta.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 (63) hide show
  1. package/.kiro/settings/lsp.json +198 -0
  2. package/MODULE_NAMING_CHANGES.md +81 -0
  3. package/PARAMETER_NAMING_CHANGES.md +106 -0
  4. package/README.md +4 -4
  5. package/package.json +10 -4
  6. package/src/client.ts +4 -4
  7. package/src/index.ts +48 -35
  8. package/src/lib/analytics/types.ts +8 -0
  9. package/src/lib/{App → app}/types.ts +14 -6
  10. package/src/lib/auth/AuthClient.ts +8 -7
  11. package/src/lib/auth/types.ts +108 -6
  12. package/src/lib/{Database → database}/DatabaseClient.ts +15 -10
  13. package/src/lib/{Database → database}/types.ts +4 -7
  14. package/src/lib/functions/types.ts +25 -0
  15. package/src/lib/{Graphs → graphs}/GraphClient.ts +9 -8
  16. package/src/lib/{Graphs → graphs}/types.ts +12 -5
  17. package/src/lib/policy/PolicyClient.ts +79 -0
  18. package/src/lib/policy/types.ts +40 -0
  19. package/src/lib/secrets/SecretsClient.ts +75 -0
  20. package/src/lib/secrets/types.ts +59 -0
  21. package/src/lib/settings/types.ts +9 -0
  22. package/src/lib/{Storage → storage}/StorageClient.ts +27 -9
  23. package/src/lib/storage/types.ts +86 -0
  24. package/src/lib/users/UserClient.ts +55 -0
  25. package/src/lib/users/types.ts +104 -0
  26. package/src/lib-internal/errors/ErrorClient.ts +98 -8
  27. package/src/lib-internal/errors/index.ts +3 -25
  28. package/src/lib-internal/errors/types.ts +18 -95
  29. package/src/lib-internal/http/HttpClient.ts +83 -45
  30. package/src/lib-internal/routes/UserRoutes.ts +3 -1
  31. package/src/lib-internal/token/TokenClient.ts +1 -1
  32. package/src/types.ts +20 -1
  33. package/src/utils/utils.ts +5 -1
  34. package/tests/fixtures/mockClient.ts +19 -0
  35. package/tests/unit/analytics/AnalyticsClient.test.ts +84 -0
  36. package/tests/unit/app/AppClient.test.ts +114 -0
  37. package/tests/unit/auth/AuthClient.test.ts +131 -0
  38. package/tests/unit/client/Client.test.ts +70 -0
  39. package/tests/unit/database/DatabaseClient.test.ts +304 -0
  40. package/tests/unit/edge-cases/robustness.test.ts +259 -0
  41. package/tests/unit/errors/errors.test.ts +209 -0
  42. package/tests/unit/functions/FunctionsClient.test.ts +99 -0
  43. package/tests/unit/graphs/GraphClient.test.ts +329 -0
  44. package/tests/unit/policy/PolicyClient.test.ts +184 -0
  45. package/tests/unit/secrets/SecretsClient.test.ts +146 -0
  46. package/tests/unit/settings/SettingsClient.test.ts +50 -0
  47. package/tests/unit/storage/StorageClient.test.ts +251 -0
  48. package/tests/unit/users/UserClient.test.ts +150 -0
  49. package/vitest.config.ts +7 -0
  50. package/src/lib/Analytics/types.ts +0 -7
  51. package/src/lib/Function/types.ts +0 -17
  52. package/src/lib/Policy/PolicyClient.ts +0 -29
  53. package/src/lib/Policy/types.ts +0 -24
  54. package/src/lib/Secrets/SecretsClient.ts +0 -39
  55. package/src/lib/Secrets/types.ts +0 -17
  56. package/src/lib/Settings/types.ts +0 -4
  57. package/src/lib/Storage/types.ts +0 -41
  58. package/src/lib/user/UserClient.ts +0 -52
  59. package/src/lib/user/types.ts +0 -111
  60. /package/src/lib/{Analytics → analytics}/AnalyticsClient.ts +0 -0
  61. /package/src/lib/{App → app}/AppClient.ts +0 -0
  62. /package/src/lib/{Function → functions}/FunctionsClient.ts +0 -0
  63. /package/src/lib/{Settings → settings}/SettingsClient.ts +0 -0
@@ -0,0 +1,304 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { Database } from '../../../src/lib/database/DatabaseClient.js'
3
+ import { Client } from '../../../src/client.js'
4
+ import type { DatabaseResponse, DatabaseSingleResponse } from '../../../src/lib/database/types.js'
5
+
6
+ // Mock the Client
7
+ const mockHttpClient = {
8
+ get: vi.fn(),
9
+ post: vi.fn(),
10
+ patch: vi.fn(),
11
+ delete: vi.fn()
12
+ }
13
+
14
+ const mockClient = {
15
+ getConfig: () => ({ apiKey: 'test-key', appSlug: 'test-app', apiUrl: 'https://api.test.com' }),
16
+ httpClient: mockHttpClient
17
+ } as unknown as Client
18
+
19
+ describe('Database', () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks()
22
+ })
23
+
24
+ describe('from()', () => {
25
+ it('returns new Database instance with table name', () => {
26
+ const db = new Database(mockClient)
27
+ const result = db.from('accounts')
28
+ expect(result).toBeInstanceOf(Database)
29
+ })
30
+ })
31
+
32
+ describe('filter()', () => {
33
+ it('eq operator uses field name without suffix', async () => {
34
+ mockHttpClient.get.mockResolvedValue([])
35
+ await new Database(mockClient).from('accounts').filter('status', 'eq', 'active').execute()
36
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status=active'))
37
+ })
38
+
39
+ it('gt operator appends __gt suffix', async () => {
40
+ mockHttpClient.get.mockResolvedValue([])
41
+ await new Database(mockClient).from('accounts').filter('age', 'gt', 18).execute()
42
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gt=18'))
43
+ })
44
+
45
+ it('gte operator appends __gte suffix', async () => {
46
+ mockHttpClient.get.mockResolvedValue([])
47
+ await new Database(mockClient).from('accounts').filter('age', 'gte', 18).execute()
48
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__gte=18'))
49
+ })
50
+
51
+ it('lt operator appends __lt suffix', async () => {
52
+ mockHttpClient.get.mockResolvedValue([])
53
+ await new Database(mockClient).from('accounts').filter('age', 'lt', 65).execute()
54
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lt=65'))
55
+ })
56
+
57
+ it('lte operator appends __lte suffix', async () => {
58
+ mockHttpClient.get.mockResolvedValue([])
59
+ await new Database(mockClient).from('accounts').filter('age', 'lte', 65).execute()
60
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('age__lte=65'))
61
+ })
62
+
63
+ it('icontains operator appends __icontains suffix', async () => {
64
+ mockHttpClient.get.mockResolvedValue([])
65
+ await new Database(mockClient).from('accounts').filter('name', 'icontains', 'john').execute()
66
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains=john'))
67
+ })
68
+
69
+ it('in operator joins array values with comma', async () => {
70
+ mockHttpClient.get.mockResolvedValue([])
71
+ await new Database(mockClient).from('accounts').filter('status', 'in', ['active', 'pending']).execute()
72
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('status__in=active%2Cpending'))
73
+ })
74
+
75
+ it('isnull operator appends __isnull suffix', async () => {
76
+ mockHttpClient.get.mockResolvedValue([])
77
+ await new Database(mockClient).from('accounts').filter('deleted_at', 'isnull', true).execute()
78
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('deleted_at__isnull=true'))
79
+ })
80
+
81
+ it('multiple filters chain correctly', async () => {
82
+ mockHttpClient.get.mockResolvedValue([])
83
+ await new Database(mockClient)
84
+ .from('accounts')
85
+ .filter('status', 'eq', 'active')
86
+ .filter('age', 'gte', 18)
87
+ .execute()
88
+ const url = mockHttpClient.get.mock.calls[0][0]
89
+ expect(url).toContain('status=active')
90
+ expect(url).toContain('age__gte=18')
91
+ })
92
+ })
93
+
94
+ describe('sort()', () => {
95
+ it('asc order uses field name without prefix', async () => {
96
+ mockHttpClient.get.mockResolvedValue([])
97
+ await new Database(mockClient).from('accounts').sort('created_at', 'asc').execute()
98
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=created_at'))
99
+ })
100
+
101
+ it('desc order adds - prefix', async () => {
102
+ mockHttpClient.get.mockResolvedValue([])
103
+ await new Database(mockClient).from('accounts').sort('created_at', 'desc').execute()
104
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('ordering=-created_at'))
105
+ })
106
+
107
+ it('defaults to asc when order not specified', async () => {
108
+ mockHttpClient.get.mockResolvedValue([])
109
+ await new Database(mockClient).from('accounts').sort('name').execute()
110
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringMatching(/ordering=name(?!-)/))
111
+ })
112
+ })
113
+
114
+ describe('pagination', () => {
115
+ it('page() sets page number', async () => {
116
+ mockHttpClient.get.mockResolvedValue([])
117
+ await new Database(mockClient).from('accounts').page(2).execute()
118
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('page=2'))
119
+ })
120
+
121
+ it('pageSize() sets page_size', async () => {
122
+ mockHttpClient.get.mockResolvedValue([])
123
+ await new Database(mockClient).from('accounts').pageSize(20).execute()
124
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('page_size=20'))
125
+ })
126
+ })
127
+
128
+ describe('populate()', () => {
129
+ it('joins array with comma', async () => {
130
+ mockHttpClient.get.mockResolvedValue([])
131
+ await new Database(mockClient).from('orders').populate(['customer', 'items']).execute()
132
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('populate=customer%2Citems'))
133
+ })
134
+ })
135
+
136
+ describe('CRUD operations', () => {
137
+ it('get() calls httpClient.get with record ID in URL', async () => {
138
+ mockHttpClient.get.mockResolvedValue({ id: '123' })
139
+ await new Database(mockClient).from('accounts').get('123').execute()
140
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('/123/'))
141
+ })
142
+
143
+ it('create() calls httpClient.post with body', async () => {
144
+ const body = { name: 'Test', status: 'active' }
145
+ mockHttpClient.post.mockResolvedValue({ id: '1', ...body })
146
+ await new Database(mockClient).from('accounts').create(body).execute()
147
+ expect(mockHttpClient.post).toHaveBeenCalledWith(expect.any(String), body)
148
+ })
149
+
150
+ it('update() calls httpClient.patch with body', async () => {
151
+ const body = { status: 'inactive' }
152
+ mockHttpClient.patch.mockResolvedValue({ id: '123', ...body })
153
+ await new Database(mockClient).from('accounts').get('123').update(body).execute()
154
+ expect(mockHttpClient.patch).toHaveBeenCalledWith(expect.stringContaining('/123/'), body)
155
+ })
156
+
157
+ it('update() without recordId throws error', async () => {
158
+ const body = { status: 'inactive' }
159
+ await expect(
160
+ new Database(mockClient).from('accounts').update(body).execute()
161
+ ).rejects.toThrow('PATCH operation requires a record ID')
162
+ })
163
+
164
+ it('delete() calls httpClient.delete', async () => {
165
+ mockHttpClient.delete.mockResolvedValue({})
166
+ await new Database(mockClient).from('accounts').delete('123').execute()
167
+ expect(mockHttpClient.delete).toHaveBeenCalledWith(expect.stringContaining('/123/'))
168
+ })
169
+ })
170
+
171
+ describe('execute()', () => {
172
+ it('throws error without table name', async () => {
173
+ await expect(new Database(mockClient).execute()).rejects.toThrow('Table name is required')
174
+ })
175
+
176
+ it('calls httpClient.get for list query', async () => {
177
+ mockHttpClient.get.mockResolvedValue([])
178
+ await new Database(mockClient).from('accounts').execute()
179
+ expect(mockHttpClient.get).toHaveBeenCalled()
180
+ })
181
+ })
182
+
183
+ describe('first()', () => {
184
+ it('returns first item from array', async () => {
185
+ mockHttpClient.get.mockResolvedValue([{ id: '1' }, { id: '2' }])
186
+ const result = await new Database(mockClient).from('accounts').first()
187
+ expect(result).toEqual({ id: '1' })
188
+ })
189
+
190
+ it('returns null for empty array', async () => {
191
+ mockHttpClient.get.mockResolvedValue([])
192
+ const result = await new Database(mockClient).from('accounts').first()
193
+ expect(result).toBeNull()
194
+ })
195
+
196
+ it('returns single item if not array', async () => {
197
+ mockHttpClient.get.mockResolvedValue({ id: '1' })
198
+ const result = await new Database(mockClient).from('accounts').get('1').first()
199
+ expect(result).toEqual({ id: '1' })
200
+ })
201
+ })
202
+
203
+ describe('count()', () => {
204
+ it('returns array length', async () => {
205
+ mockHttpClient.get.mockResolvedValue([{ id: '1' }, { id: '2' }, { id: '3' }])
206
+ const result = await new Database(mockClient).from('accounts').count()
207
+ expect(result).toBe(3)
208
+ })
209
+
210
+ it('returns 0 for non-array', async () => {
211
+ mockHttpClient.get.mockResolvedValue({ id: '1' })
212
+ const result = await new Database(mockClient).from('accounts').get('1').count()
213
+ expect(result).toBe(0)
214
+ })
215
+ })
216
+
217
+ describe('URL building', () => {
218
+ it('builds correct base URL with app slug and table', async () => {
219
+ mockHttpClient.get.mockResolvedValue([])
220
+ await new Database(mockClient).from('accounts').execute()
221
+ expect(mockHttpClient.get).toHaveBeenCalledWith('api/apps/test-app/datatables/accounts/data/')
222
+ })
223
+
224
+ it('builds correct URL with record ID', async () => {
225
+ mockHttpClient.get.mockResolvedValue({})
226
+ await new Database(mockClient).from('accounts').get('abc-123').execute()
227
+ expect(mockHttpClient.get).toHaveBeenCalledWith('api/apps/test-app/datatables/accounts/data/abc-123/')
228
+ })
229
+
230
+ it('appends query string with filters', async () => {
231
+ mockHttpClient.get.mockResolvedValue([])
232
+ await new Database(mockClient)
233
+ .from('accounts')
234
+ .filter('status', 'eq', 'active')
235
+ .page(1)
236
+ .pageSize(10)
237
+ .execute()
238
+ const url = mockHttpClient.get.mock.calls[0][0]
239
+ expect(url).toContain('?')
240
+ expect(url).toContain('status=active')
241
+ expect(url).toContain('page=1')
242
+ expect(url).toContain('page_size=10')
243
+ })
244
+ })
245
+
246
+ describe('response handling', () => {
247
+ it('returns list response matching DatabaseResponse type', async () => {
248
+ const mockResponse: DatabaseResponse = {
249
+ status: 'success',
250
+ message: 'Data retrieved successfully',
251
+ data: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
252
+ total: 2,
253
+ pagination: { offset: 0, limit: 20, count: 2, current_page: 1, total_pages: 1, has_next: false, has_previous: false }
254
+ }
255
+ mockHttpClient.get.mockResolvedValue(mockResponse)
256
+ const result = await new Database(mockClient).from('accounts').execute() as DatabaseResponse
257
+ expect(result.status).toBe('success')
258
+ expect(result.data).toHaveLength(2)
259
+ expect(result.total).toBe(2)
260
+ expect(result.pagination!.current_page).toBe(1)
261
+ })
262
+
263
+ it('returns single record matching DatabaseSingleResponse type', async () => {
264
+ const mockResponse: DatabaseSingleResponse = {
265
+ status: 'success',
266
+ message: 'Record retrieved successfully',
267
+ data: { id: 1, name: 'Alice', email: 'alice@example.com' }
268
+ }
269
+ mockHttpClient.get.mockResolvedValue(mockResponse)
270
+ const result = await new Database(mockClient).from('accounts').get('1').execute() as DatabaseSingleResponse
271
+ expect(result.data.id).toBe(1)
272
+ expect(result.data.name).toBe('Alice')
273
+ })
274
+
275
+ it('returns created record response', async () => {
276
+ const mockResponse: DatabaseSingleResponse = {
277
+ status: 'success',
278
+ message: 'Record created successfully',
279
+ data: { id: 3, name: 'Carol', status: 'active' }
280
+ }
281
+ mockHttpClient.post.mockResolvedValue(mockResponse)
282
+ const result = await new Database(mockClient).from('accounts').create({ name: 'Carol', status: 'active' }).execute() as DatabaseSingleResponse
283
+ expect(result.data.id).toBe(3)
284
+ })
285
+
286
+ it('returns updated record response', async () => {
287
+ const mockResponse: DatabaseSingleResponse = {
288
+ status: 'success',
289
+ message: 'Record updated successfully',
290
+ data: { id: 1, name: 'Alice Updated' }
291
+ }
292
+ mockHttpClient.patch.mockResolvedValue(mockResponse)
293
+ const result = await new Database(mockClient).from('accounts').get('1').update({ name: 'Alice Updated' }).execute() as DatabaseSingleResponse
294
+ expect(result.data.name).toBe('Alice Updated')
295
+ })
296
+
297
+ it('returns delete response', async () => {
298
+ const mockResponse = { status: 'success', message: 'Record deleted successfully' }
299
+ mockHttpClient.delete.mockResolvedValue(mockResponse)
300
+ const result = await new Database(mockClient).from('accounts').delete('1').execute()
301
+ expect((result as any).status).toBe('success')
302
+ })
303
+ })
304
+ })
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { Database } from '../../../src/lib/database/DatabaseClient.js'
3
+ import { Storage } from '../../../src/lib/storage/StorageClient.js'
4
+ import { Graph } from '../../../src/lib/graphs/GraphClient.js'
5
+ import { Client } from '../../../src/client.js'
6
+
7
+ const mockHttpClient = {
8
+ get: vi.fn(),
9
+ post: vi.fn(),
10
+ put: vi.fn(),
11
+ patch: vi.fn(),
12
+ delete: vi.fn()
13
+ }
14
+
15
+ const mockClient = {
16
+ getConfig: () => ({ apiKey: 'test-key', appSlug: 'test-app', apiUrl: 'https://api.test.com' }),
17
+ httpClient: mockHttpClient
18
+ } as unknown as Client
19
+
20
+ describe('Non-JSON / empty / malformed responses', () => {
21
+ beforeEach(() => vi.clearAllMocks())
22
+
23
+ it('handles empty response (204 no content)', async () => {
24
+ mockHttpClient.get.mockResolvedValue(undefined)
25
+ const result = await new Database(mockClient).from('accounts').execute()
26
+ expect(result).toBeUndefined()
27
+ })
28
+
29
+ it('handles null response body', async () => {
30
+ mockHttpClient.get.mockResolvedValue(null)
31
+ const result = await new Database(mockClient).from('accounts').execute()
32
+ expect(result).toBeNull()
33
+ })
34
+
35
+ it('handles empty string response', async () => {
36
+ mockHttpClient.get.mockResolvedValue('')
37
+ const result = await new Database(mockClient).from('accounts').execute()
38
+ expect(result).toBe('')
39
+ })
40
+
41
+ it('handles plain text response', async () => {
42
+ mockHttpClient.get.mockResolvedValue('Internal Server Error')
43
+ const result = await new Database(mockClient).from('accounts').execute()
44
+ expect(result).toBe('Internal Server Error')
45
+ })
46
+
47
+ it('handles empty array response', async () => {
48
+ mockHttpClient.get.mockResolvedValue([])
49
+ const result = await new Database(mockClient).from('accounts').execute()
50
+ expect(result).toEqual([])
51
+ })
52
+
53
+ it('handles empty object response', async () => {
54
+ mockHttpClient.get.mockResolvedValue({})
55
+ const result = await new Database(mockClient).from('accounts').execute()
56
+ expect(result).toEqual({})
57
+ })
58
+
59
+ it('propagates network error from httpClient', async () => {
60
+ mockHttpClient.get.mockRejectedValue(new Error('Network Error'))
61
+ await expect(new Database(mockClient).from('accounts').execute()).rejects.toThrow('Network Error')
62
+ })
63
+
64
+ it('propagates network error for Graph', async () => {
65
+ mockHttpClient.get.mockRejectedValue(new Error('ECONNREFUSED'))
66
+ await expect(new Graph(mockClient).from('employees').execute()).rejects.toThrow('ECONNREFUSED')
67
+ })
68
+
69
+ it('propagates network error for Storage', async () => {
70
+ mockHttpClient.get.mockRejectedValue(new Error('timeout'))
71
+ await expect(new Storage(mockClient).from('documents').execute()).rejects.toThrow('timeout')
72
+ })
73
+
74
+ it('handles response with unexpected shape', async () => {
75
+ mockHttpClient.get.mockResolvedValue(42)
76
+ const result = await new Database(mockClient).from('accounts').execute()
77
+ expect(result).toBe(42)
78
+ })
79
+
80
+ it('handles response with extra unknown fields', async () => {
81
+ mockHttpClient.get.mockResolvedValue({ status: 'success', data: [], unknown_field: true, nested: { deep: 1 } })
82
+ const result = await new Database(mockClient).from('accounts').execute()
83
+ expect((result as any).unknown_field).toBe(true)
84
+ })
85
+ })
86
+
87
+ describe('Encoding edge cases', () => {
88
+ beforeEach(() => vi.clearAllMocks())
89
+
90
+ it('encodes special characters in table name', async () => {
91
+ mockHttpClient.get.mockResolvedValue([])
92
+ await new Database(mockClient).from('my-table_v2').execute()
93
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('/datatables/my-table_v2/'))
94
+ })
95
+
96
+ it('encodes special characters in record ID', async () => {
97
+ mockHttpClient.get.mockResolvedValue({})
98
+ await new Database(mockClient).from('accounts').get('abc-123-def').execute()
99
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('/abc-123-def/'))
100
+ })
101
+
102
+ it('encodes filter value with spaces', async () => {
103
+ mockHttpClient.get.mockResolvedValue([])
104
+ await new Database(mockClient).from('accounts').filter('name', 'eq', 'John Doe').execute()
105
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name=John+Doe'))
106
+ })
107
+
108
+ it('encodes filter value with special characters', async () => {
109
+ mockHttpClient.get.mockResolvedValue([])
110
+ await new Database(mockClient).from('accounts').filter('email', 'eq', 'user@example.com').execute()
111
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('email=user%40example.com'))
112
+ })
113
+
114
+ it('encodes filter value with ampersand', async () => {
115
+ mockHttpClient.get.mockResolvedValue([])
116
+ await new Database(mockClient).from('accounts').filter('company', 'eq', 'A&B Corp').execute()
117
+ const url = mockHttpClient.get.mock.calls[0][0]
118
+ expect(url).toContain('company=A%26B')
119
+ })
120
+
121
+ it('handles unicode in filter values', async () => {
122
+ mockHttpClient.get.mockResolvedValue([])
123
+ await new Database(mockClient).from('accounts').filter('name', 'icontains', '日本語').execute()
124
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('name__icontains='))
125
+ })
126
+
127
+ it('encodes graph relationship types with special chars', async () => {
128
+ mockHttpClient.get.mockResolvedValue([])
129
+ await new Graph(mockClient).from('employees').types(['reports_to']).execute()
130
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('relationship_type=reports_to'))
131
+ })
132
+
133
+ it('handles storage bucket with hyphens', async () => {
134
+ mockHttpClient.get.mockResolvedValue([])
135
+ await new Storage(mockClient).from('my-bucket-name').execute()
136
+ expect(mockHttpClient.get).toHaveBeenCalledWith(expect.stringContaining('/buckets/my-bucket-name/'))
137
+ })
138
+
139
+ it('handles empty string filter value', async () => {
140
+ mockHttpClient.get.mockResolvedValue([])
141
+ await new Database(mockClient).from('accounts').filter('status', 'eq', '').execute()
142
+ const url = mockHttpClient.get.mock.calls[0][0]
143
+ expect(url).toContain('status=')
144
+ })
145
+ })
146
+
147
+ describe('Builder immutability', () => {
148
+ beforeEach(() => vi.clearAllMocks())
149
+
150
+ it('Database: chaining does not mutate original instance', async () => {
151
+ mockHttpClient.get.mockResolvedValue([])
152
+ const base = new Database(mockClient).from('accounts')
153
+ const filtered = base.filter('status', 'eq', 'active')
154
+ const sorted = base.sort('name', 'asc')
155
+
156
+ await filtered.execute()
157
+ const filteredUrl = mockHttpClient.get.mock.calls[0][0]
158
+
159
+ await sorted.execute()
160
+ const sortedUrl = mockHttpClient.get.mock.calls[1][0]
161
+
162
+ expect(filteredUrl).toContain('status=active')
163
+ expect(filteredUrl).not.toContain('ordering=name')
164
+
165
+ expect(sortedUrl).toContain('ordering=name')
166
+ expect(sortedUrl).not.toContain('status=active')
167
+ })
168
+
169
+ it('Database: page does not affect sibling chain', async () => {
170
+ mockHttpClient.get.mockResolvedValue([])
171
+ const base = new Database(mockClient).from('accounts')
172
+ const page1 = base.page(1)
173
+ const page2 = base.page(2)
174
+
175
+ await page1.execute()
176
+ await page2.execute()
177
+
178
+ expect(mockHttpClient.get.mock.calls[0][0]).toContain('page=1')
179
+ expect(mockHttpClient.get.mock.calls[1][0]).toContain('page=2')
180
+ expect(mockHttpClient.get.mock.calls[0][0]).not.toContain('page=2')
181
+ })
182
+
183
+ it('Graph: chaining does not mutate original instance', async () => {
184
+ mockHttpClient.get.mockResolvedValue([])
185
+ const base = new Graph(mockClient).from('employees')
186
+ const descendants = base.get('1').include('descendants').depth(3)
187
+ const ancestors = base.get('4').include('ancestors')
188
+
189
+ await descendants.execute()
190
+ const descUrl = mockHttpClient.get.mock.calls[0][0]
191
+
192
+ await ancestors.execute()
193
+ const ancUrl = mockHttpClient.get.mock.calls[1][0]
194
+
195
+ expect(descUrl).toContain('/1/')
196
+ expect(descUrl).toContain('include=descendants')
197
+ expect(descUrl).toContain('depth=3')
198
+
199
+ expect(ancUrl).toContain('/4/')
200
+ expect(ancUrl).toContain('include=ancestors')
201
+ expect(ancUrl).not.toContain('depth=3')
202
+ })
203
+
204
+ it('Graph: format does not leak between chains', async () => {
205
+ mockHttpClient.get.mockResolvedValue([])
206
+ const base = new Graph(mockClient).from('employees')
207
+ const tree = base.format('tree')
208
+ const graph = base.format('graph')
209
+
210
+ await tree.execute()
211
+ await graph.execute()
212
+
213
+ expect(mockHttpClient.get.mock.calls[0][0]).toContain('format=tree')
214
+ expect(mockHttpClient.get.mock.calls[0][0]).not.toContain('format=graph')
215
+ expect(mockHttpClient.get.mock.calls[1][0]).toContain('format=graph')
216
+ expect(mockHttpClient.get.mock.calls[1][0]).not.toContain('format=tree')
217
+ })
218
+
219
+ it('Storage: filter does not mutate original instance', async () => {
220
+ mockHttpClient.get.mockResolvedValue([])
221
+ const base = new Storage(mockClient).from('documents')
222
+ const pdfs = base.filter({ mimetype: 'application/pdf' })
223
+ const images = base.filter({ mimetype_category: 'image' })
224
+
225
+ await pdfs.execute()
226
+ await images.execute()
227
+
228
+ expect(mockHttpClient.get.mock.calls[0][0]).toContain('mimetype=application')
229
+ expect(mockHttpClient.get.mock.calls[0][0]).not.toContain('mimetype_category')
230
+ expect(mockHttpClient.get.mock.calls[1][0]).toContain('mimetype_category=image')
231
+ })
232
+
233
+ it('Database: get does not affect list chain', async () => {
234
+ mockHttpClient.get.mockResolvedValue([])
235
+ const base = new Database(mockClient).from('accounts')
236
+ const single = base.get('123')
237
+ const list = base.page(1)
238
+
239
+ await single.execute()
240
+ await list.execute()
241
+
242
+ expect(mockHttpClient.get.mock.calls[0][0]).toContain('/123/')
243
+ expect(mockHttpClient.get.mock.calls[1][0]).not.toContain('/123/')
244
+ })
245
+
246
+ it('Graph: edge operations do not affect traversal chain', async () => {
247
+ mockHttpClient.get.mockResolvedValue([])
248
+ mockHttpClient.post.mockResolvedValue({})
249
+ const base = new Graph(mockClient).from('employees')
250
+ const traversal = base.get('1').include('descendants')
251
+ const edge = base.createEdge([{ from: 1, to: 2, type: 'manager' }])
252
+
253
+ await traversal.execute()
254
+ await edge.execute()
255
+
256
+ expect(mockHttpClient.get.mock.calls[0][0]).toContain('/data/1/')
257
+ expect(mockHttpClient.post.mock.calls[0][0]).toContain('/edges/')
258
+ })
259
+ })