@objectql/sdk 4.1.0 → 4.2.1
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/dist/index.d.ts +15 -17
- package/dist/index.js +17 -17
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/index.test.ts +760 -0
- package/src/index.ts +21 -21
- package/test/remote-driver.test.ts +52 -48
- package/tsconfig.json +1 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/jest.config.js +0 -23
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectQL
|
|
3
|
+
* Copyright (c) 2026-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SDK Driver Tests
|
|
11
|
+
*
|
|
12
|
+
* Test suite for the @objectql/sdk package covering RemoteDriver,
|
|
13
|
+
* DataApiClient, MetadataApiClient, and exported interfaces.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { vi, type Mock } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
RemoteDriver,
|
|
19
|
+
DataApiClient,
|
|
20
|
+
MetadataApiClient,
|
|
21
|
+
type Command,
|
|
22
|
+
type CommandResult,
|
|
23
|
+
type SdkConfig,
|
|
24
|
+
} from './index';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Global fetch mock
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const fetchMock = vi.fn() as Mock;
|
|
30
|
+
global.fetch = fetchMock;
|
|
31
|
+
|
|
32
|
+
function mockFetchJson(json: unknown, ok = true, status = 200, statusText = 'OK') {
|
|
33
|
+
fetchMock.mockResolvedValueOnce({
|
|
34
|
+
ok,
|
|
35
|
+
status,
|
|
36
|
+
statusText,
|
|
37
|
+
json: async () => json,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// RemoteDriver
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
describe('RemoteDriver', () => {
|
|
45
|
+
const BASE = 'http://localhost:3000';
|
|
46
|
+
let driver: RemoteDriver;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
driver = new RemoteDriver(BASE);
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// -- Construction & configuration --------------------------------------
|
|
54
|
+
describe('Construction', () => {
|
|
55
|
+
it('should create instance with string URL', () => {
|
|
56
|
+
expect(driver).toBeInstanceOf(RemoteDriver);
|
|
57
|
+
expect(driver.name).toBe('RemoteDriver');
|
|
58
|
+
expect(driver.version).toBe('4.0.0');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should accept SdkConfig object', () => {
|
|
62
|
+
const cfg: SdkConfig = {
|
|
63
|
+
baseUrl: BASE,
|
|
64
|
+
token: 'tok',
|
|
65
|
+
apiKey: 'key',
|
|
66
|
+
timeout: 5000,
|
|
67
|
+
enableRetry: true,
|
|
68
|
+
maxRetries: 2,
|
|
69
|
+
enableLogging: false,
|
|
70
|
+
};
|
|
71
|
+
const d = new RemoteDriver(cfg);
|
|
72
|
+
expect(d).toBeInstanceOf(RemoteDriver);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should expose supports capabilities', () => {
|
|
76
|
+
expect(driver.supports).toBeDefined();
|
|
77
|
+
expect(driver.supports.transactions).toBe(false);
|
|
78
|
+
expect(driver.supports.queryFilters).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should strip trailing slash from base URL', async () => {
|
|
82
|
+
const d = new RemoteDriver('http://example.com/');
|
|
83
|
+
mockFetchJson({ data: [] });
|
|
84
|
+
await d.find('users', {});
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
86
|
+
'http://example.com/api/objectql',
|
|
87
|
+
expect.any(Object),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// -- Legacy RPC operations (find, findOne, create, update, delete, count)
|
|
93
|
+
describe('find', () => {
|
|
94
|
+
it('should POST to RPC endpoint and return data array', async () => {
|
|
95
|
+
mockFetchJson({ data: [{ _id: '1', name: 'Alice' }] });
|
|
96
|
+
const result = await driver.find('users', { where: { active: true } });
|
|
97
|
+
|
|
98
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
99
|
+
`${BASE}/api/objectql`,
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
op: 'find',
|
|
105
|
+
object: 'users',
|
|
106
|
+
args: { where: { active: true } },
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
expect(result).toEqual([{ _id: '1', name: 'Alice' }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return empty array when server returns empty data', async () => {
|
|
114
|
+
mockFetchJson({ data: [] });
|
|
115
|
+
const result = await driver.find('users', {});
|
|
116
|
+
expect(result).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should throw ObjectQLError when response contains error', async () => {
|
|
120
|
+
mockFetchJson({ error: { message: 'Object not found' } });
|
|
121
|
+
await expect(driver.find('missing', {})).rejects.toThrow('Object not found');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('findOne', () => {
|
|
126
|
+
it('should fetch a single record by id', async () => {
|
|
127
|
+
mockFetchJson({ data: { _id: '1', name: 'Alice' } });
|
|
128
|
+
const result = await driver.findOne('users', '1');
|
|
129
|
+
|
|
130
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
131
|
+
`${BASE}/api/objectql`,
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
op: 'findOne',
|
|
135
|
+
object: 'users',
|
|
136
|
+
args: { id: '1', query: undefined },
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(result).toEqual({ _id: '1', name: 'Alice' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should return null when record not found', async () => {
|
|
144
|
+
mockFetchJson({ data: null });
|
|
145
|
+
expect(await driver.findOne('users', 'x')).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should accept numeric ids', async () => {
|
|
149
|
+
mockFetchJson({ data: { _id: 42 } });
|
|
150
|
+
await driver.findOne('users', 42);
|
|
151
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body);
|
|
152
|
+
expect(body.args.id).toBe(42);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('create', () => {
|
|
157
|
+
it('should create a record and return the result', async () => {
|
|
158
|
+
const input = { name: 'Bob', email: 'bob@test.com' };
|
|
159
|
+
mockFetchJson({ data: { _id: '2', ...input } });
|
|
160
|
+
const result = await driver.create('users', input);
|
|
161
|
+
expect(result._id).toBe('2');
|
|
162
|
+
expect(result.name).toBe('Bob');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should propagate server validation errors', async () => {
|
|
166
|
+
mockFetchJson({ error: { message: 'email is required' } });
|
|
167
|
+
await expect(driver.create('users', {})).rejects.toThrow('email is required');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('update', () => {
|
|
172
|
+
it('should update and return the record', async () => {
|
|
173
|
+
mockFetchJson({ data: { _id: '1', name: 'Updated' } });
|
|
174
|
+
const result = await driver.update('users', '1', { name: 'Updated' });
|
|
175
|
+
|
|
176
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body);
|
|
177
|
+
expect(body).toEqual({
|
|
178
|
+
op: 'update',
|
|
179
|
+
object: 'users',
|
|
180
|
+
args: { id: '1', data: { name: 'Updated' } },
|
|
181
|
+
});
|
|
182
|
+
expect(result.name).toBe('Updated');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should handle numeric id', async () => {
|
|
186
|
+
mockFetchJson({ data: { _id: 7 } });
|
|
187
|
+
await driver.update('users', 7, { role: 'admin' });
|
|
188
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body);
|
|
189
|
+
expect(body.args.id).toBe(7);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('delete', () => {
|
|
194
|
+
it('should delete a record and return affected count', async () => {
|
|
195
|
+
mockFetchJson({ data: 1 });
|
|
196
|
+
const result = await driver.delete('users', '1');
|
|
197
|
+
expect(result).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should propagate not-found errors', async () => {
|
|
201
|
+
mockFetchJson({ error: { message: 'Record not found' } });
|
|
202
|
+
await expect(driver.delete('users', 'x')).rejects.toThrow('Record not found');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('count', () => {
|
|
207
|
+
it('should return record count', async () => {
|
|
208
|
+
mockFetchJson({ data: 42 });
|
|
209
|
+
const result = await driver.count('users', { active: true });
|
|
210
|
+
expect(result).toBe(42);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should return 0 for empty collection', async () => {
|
|
214
|
+
mockFetchJson({ data: 0 });
|
|
215
|
+
expect(await driver.count('empty', {})).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('createMany / updateMany / deleteMany', () => {
|
|
220
|
+
it('should create many records', async () => {
|
|
221
|
+
mockFetchJson({ data: [{ _id: '1' }, { _id: '2' }] });
|
|
222
|
+
const result = await driver.createMany('users', [{ name: 'A' }, { name: 'B' }]);
|
|
223
|
+
expect(result).toHaveLength(2);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should update many records', async () => {
|
|
227
|
+
mockFetchJson({ data: { affected: 3 } });
|
|
228
|
+
await driver.updateMany('users', { active: false }, { active: true });
|
|
229
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body);
|
|
230
|
+
expect(body.op).toBe('updateMany');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should delete many records', async () => {
|
|
234
|
+
mockFetchJson({ data: { affected: 2 } });
|
|
235
|
+
await driver.deleteMany('users', { status: 'deleted' });
|
|
236
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body);
|
|
237
|
+
expect(body.op).toBe('deleteMany');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// -- DriverInterface v4 methods ----------------------------------------
|
|
242
|
+
describe('executeQuery', () => {
|
|
243
|
+
it('should POST QueryAST to /api/query and return results', async () => {
|
|
244
|
+
const ast = { object: 'users', fields: ['name'] };
|
|
245
|
+
mockFetchJson({ value: [{ name: 'Alice' }], count: 1 });
|
|
246
|
+
const result = await driver.executeQuery(ast);
|
|
247
|
+
|
|
248
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
249
|
+
`${BASE}/api/query`,
|
|
250
|
+
expect.objectContaining({
|
|
251
|
+
method: 'POST',
|
|
252
|
+
body: JSON.stringify(ast),
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
expect(result.value).toEqual([{ name: 'Alice' }]);
|
|
256
|
+
expect(result.count).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should normalise data-wrapped responses', async () => {
|
|
260
|
+
mockFetchJson({ data: [{ id: 1 }] });
|
|
261
|
+
const result = await driver.executeQuery({ object: 'items' });
|
|
262
|
+
expect(result.value).toEqual([{ id: 1 }]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should normalise plain-array responses', async () => {
|
|
266
|
+
fetchMock.mockResolvedValueOnce({
|
|
267
|
+
ok: true,
|
|
268
|
+
json: async () => [{ id: 2 }],
|
|
269
|
+
});
|
|
270
|
+
const result = await driver.executeQuery({ object: 'items' });
|
|
271
|
+
expect(result.value).toEqual([{ id: 2 }]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should include auth headers when configured', async () => {
|
|
275
|
+
const d = new RemoteDriver({ baseUrl: BASE, token: 'tk', apiKey: 'ak' });
|
|
276
|
+
mockFetchJson({ value: [] });
|
|
277
|
+
await d.executeQuery({ object: 'x' });
|
|
278
|
+
|
|
279
|
+
const headers = (fetchMock.mock.calls[0][1] as any).headers;
|
|
280
|
+
expect(headers['Authorization']).toBe('Bearer tk');
|
|
281
|
+
expect(headers['X-API-Key']).toBe('ak');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should throw on HTTP error with structured error body', async () => {
|
|
285
|
+
mockFetchJson(
|
|
286
|
+
{ error: { code: 'NOT_FOUND', message: 'Not found' } },
|
|
287
|
+
false, 404, 'Not Found',
|
|
288
|
+
);
|
|
289
|
+
await expect(driver.executeQuery({ object: 'x' })).rejects.toThrow('Not found');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('executeCommand', () => {
|
|
294
|
+
it('should POST command to /api/command', async () => {
|
|
295
|
+
const cmd: Command = { type: 'create', object: 'users', data: { name: 'A' } };
|
|
296
|
+
mockFetchJson({ success: true, data: { id: '1' }, affected: 1 });
|
|
297
|
+
const result = await driver.executeCommand(cmd);
|
|
298
|
+
|
|
299
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
300
|
+
`${BASE}/api/command`,
|
|
301
|
+
expect.objectContaining({ method: 'POST', body: JSON.stringify(cmd) }),
|
|
302
|
+
);
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
expect(result.affected).toBe(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should return failure result on error response', async () => {
|
|
308
|
+
mockFetchJson({ error: { message: 'Validation failed' } });
|
|
309
|
+
const result = await driver.executeCommand({
|
|
310
|
+
type: 'create', object: 'users', data: {},
|
|
311
|
+
});
|
|
312
|
+
expect(result.success).toBe(false);
|
|
313
|
+
expect(result.error).toContain('Validation failed');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should throw on HTTP-level error', async () => {
|
|
317
|
+
mockFetchJson(
|
|
318
|
+
{ error: { message: 'Server error' } },
|
|
319
|
+
false, 500, 'Internal Server Error',
|
|
320
|
+
);
|
|
321
|
+
await expect(
|
|
322
|
+
driver.executeCommand({ type: 'delete', object: 'users', id: '1' }),
|
|
323
|
+
).rejects.toThrow('Server error');
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('executeCustomEndpoint', () => {
|
|
328
|
+
it('should call custom endpoint path', async () => {
|
|
329
|
+
mockFetchJson({ result: 'ok' });
|
|
330
|
+
const result = await driver.executeCustomEndpoint('/api/custom', { a: 1 });
|
|
331
|
+
|
|
332
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
333
|
+
`${BASE}/api/custom`,
|
|
334
|
+
expect.objectContaining({ method: 'POST', body: JSON.stringify({ a: 1 }) }),
|
|
335
|
+
);
|
|
336
|
+
expect(result).toEqual({ result: 'ok' });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should default to /api/execute when no path given', async () => {
|
|
340
|
+
mockFetchJson({ ok: true });
|
|
341
|
+
await driver.executeCustomEndpoint(undefined, { action: 'test' });
|
|
342
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
343
|
+
`${BASE}/api/execute`,
|
|
344
|
+
expect.any(Object),
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should throw on error response', async () => {
|
|
349
|
+
mockFetchJson(
|
|
350
|
+
{ error: { message: 'Server error' } },
|
|
351
|
+
false, 500, 'Internal Server Error',
|
|
352
|
+
);
|
|
353
|
+
await expect(driver.executeCustomEndpoint('/api/x', {})).rejects.toThrow('Server error');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// -- Error handling ----------------------------------------------------
|
|
358
|
+
describe('Error handling', () => {
|
|
359
|
+
it('should propagate network errors', async () => {
|
|
360
|
+
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
|
361
|
+
await expect(driver.find('users', {})).rejects.toThrow('Network error');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should propagate JSON parse errors', async () => {
|
|
365
|
+
fetchMock.mockResolvedValueOnce({
|
|
366
|
+
json: async () => { throw new Error('Invalid JSON'); },
|
|
367
|
+
});
|
|
368
|
+
await expect(driver.find('users', {})).rejects.toThrow('Invalid JSON');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// -- Retry logic -------------------------------------------------------
|
|
373
|
+
describe('Retry logic', () => {
|
|
374
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
375
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
376
|
+
|
|
377
|
+
it('should retry on network errors when enabled', async () => {
|
|
378
|
+
const d = new RemoteDriver({ baseUrl: BASE, enableRetry: true, maxRetries: 2 });
|
|
379
|
+
|
|
380
|
+
fetchMock
|
|
381
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
382
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
383
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ value: [], count: 0 }) });
|
|
384
|
+
|
|
385
|
+
const promise = d.executeQuery({ object: 'users' });
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < 2; i++) {
|
|
388
|
+
await vi.runAllTimersAsync();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const result = await promise;
|
|
392
|
+
expect(result.value).toEqual([]);
|
|
393
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should not retry on 400 validation errors', async () => {
|
|
397
|
+
const d = new RemoteDriver({ baseUrl: BASE, enableRetry: true, maxRetries: 3 });
|
|
398
|
+
|
|
399
|
+
mockFetchJson(
|
|
400
|
+
{ error: { code: 'VALIDATION_ERROR', message: 'Invalid data' } },
|
|
401
|
+
false, 400, 'Bad Request',
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
await expect(d.executeQuery({ object: 'users' })).rejects.toThrow('Invalid data');
|
|
405
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// DataApiClient
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
describe('DataApiClient', () => {
|
|
414
|
+
let client: DataApiClient;
|
|
415
|
+
const BASE = 'http://localhost:4000';
|
|
416
|
+
|
|
417
|
+
beforeEach(() => {
|
|
418
|
+
client = new DataApiClient({ baseUrl: BASE });
|
|
419
|
+
vi.clearAllMocks();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
describe('Construction', () => {
|
|
423
|
+
it('should create instance with config', () => {
|
|
424
|
+
expect(client).toBeInstanceOf(DataApiClient);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should strip trailing slash and accept custom dataPath', () => {
|
|
428
|
+
const c = new DataApiClient({ baseUrl: `${BASE}/`, dataPath: '/v2/data' });
|
|
429
|
+
expect(c).toBeInstanceOf(DataApiClient);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should include auth header when token provided', async () => {
|
|
433
|
+
const c = new DataApiClient({ baseUrl: BASE, token: 'secret' });
|
|
434
|
+
mockFetchJson({ data: [], total: 0 });
|
|
435
|
+
await c.list('users');
|
|
436
|
+
|
|
437
|
+
const headers = (fetchMock.mock.calls[0][1] as any).headers;
|
|
438
|
+
expect(headers['Authorization']).toBe('Bearer secret');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('list', () => {
|
|
443
|
+
it('should GET /api/data/{object}', async () => {
|
|
444
|
+
mockFetchJson({ data: [{ id: '1' }], total: 1 });
|
|
445
|
+
const result = await client.list('users');
|
|
446
|
+
|
|
447
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
448
|
+
expect(url).toContain(`${BASE}/api/data/users`);
|
|
449
|
+
expect(result.data).toEqual([{ id: '1' }]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should pass query params for filtering', async () => {
|
|
453
|
+
mockFetchJson({ data: [], total: 0 });
|
|
454
|
+
await client.list('users', { limit: 10, offset: 0 } as any);
|
|
455
|
+
|
|
456
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
457
|
+
expect(url).toContain('limit=10');
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('get', () => {
|
|
462
|
+
it('should GET /api/data/{object}/{id}', async () => {
|
|
463
|
+
mockFetchJson({ data: { id: '1', name: 'Alice' } });
|
|
464
|
+
const result = await client.get('users', '1');
|
|
465
|
+
|
|
466
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
467
|
+
expect(url).toBe(`${BASE}/api/data/users/1`);
|
|
468
|
+
expect(result.data).toEqual({ id: '1', name: 'Alice' });
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe('create', () => {
|
|
473
|
+
it('should POST to /api/data/{object}', async () => {
|
|
474
|
+
const payload = { name: 'Bob' };
|
|
475
|
+
mockFetchJson({ data: { id: '2', name: 'Bob' } });
|
|
476
|
+
const result = await client.create('users', payload);
|
|
477
|
+
|
|
478
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
479
|
+
`${BASE}/api/data/users`,
|
|
480
|
+
expect.objectContaining({
|
|
481
|
+
method: 'POST',
|
|
482
|
+
body: JSON.stringify(payload),
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
expect(result.data).toHaveProperty('id');
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe('createMany', () => {
|
|
490
|
+
it('should POST bulk records', async () => {
|
|
491
|
+
const payload = { records: [{ name: 'A' }, { name: 'B' }] };
|
|
492
|
+
mockFetchJson({ data: [{ id: '1' }, { id: '2' }], total: 2 });
|
|
493
|
+
const result = await client.createMany('users', payload);
|
|
494
|
+
expect(result.data).toHaveLength(2);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('update', () => {
|
|
499
|
+
it('should PUT to /api/data/{object}/{id}', async () => {
|
|
500
|
+
mockFetchJson({ data: { id: '1', name: 'Updated' } });
|
|
501
|
+
await client.update('users', '1', { name: 'Updated' });
|
|
502
|
+
|
|
503
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
504
|
+
`${BASE}/api/data/users/1`,
|
|
505
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe('updateMany', () => {
|
|
511
|
+
it('should POST to /api/data/{object}/bulk-update', async () => {
|
|
512
|
+
mockFetchJson({ success: true, affected: 3 });
|
|
513
|
+
await client.updateMany('users', { ids: ['1', '2', '3'], data: { active: true } } as any);
|
|
514
|
+
|
|
515
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
516
|
+
expect(url).toBe(`${BASE}/api/data/users/bulk-update`);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('delete', () => {
|
|
521
|
+
it('should DELETE /api/data/{object}/{id}', async () => {
|
|
522
|
+
mockFetchJson({ success: true, affected: 1 });
|
|
523
|
+
await client.delete('users', '1');
|
|
524
|
+
|
|
525
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
526
|
+
`${BASE}/api/data/users/1`,
|
|
527
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('deleteMany', () => {
|
|
533
|
+
it('should POST to /api/data/{object}/bulk-delete', async () => {
|
|
534
|
+
mockFetchJson({ success: true, affected: 2 });
|
|
535
|
+
await client.deleteMany('users', { ids: ['1', '2'] } as any);
|
|
536
|
+
|
|
537
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
538
|
+
expect(url).toBe(`${BASE}/api/data/users/bulk-delete`);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('count', () => {
|
|
543
|
+
it('should GET count with limit=0', async () => {
|
|
544
|
+
mockFetchJson({ total: 99 });
|
|
545
|
+
await client.count('users');
|
|
546
|
+
|
|
547
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
548
|
+
expect(url).toContain('limit=0');
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('Error handling', () => {
|
|
553
|
+
it('should throw ObjectQLError on error response', async () => {
|
|
554
|
+
mockFetchJson({ error: { code: 'NOT_FOUND', message: 'Not found' } });
|
|
555
|
+
await expect(client.get('users', 'x')).rejects.toThrow('Not found');
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('should propagate network errors', async () => {
|
|
559
|
+
fetchMock.mockRejectedValueOnce(new Error('Offline'));
|
|
560
|
+
await expect(client.list('users')).rejects.toThrow('Offline');
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// MetadataApiClient
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
describe('MetadataApiClient', () => {
|
|
569
|
+
let client: MetadataApiClient;
|
|
570
|
+
const BASE = 'http://localhost:5000';
|
|
571
|
+
|
|
572
|
+
beforeEach(() => {
|
|
573
|
+
client = new MetadataApiClient({ baseUrl: BASE });
|
|
574
|
+
vi.clearAllMocks();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('Construction', () => {
|
|
578
|
+
it('should create instance with defaults', () => {
|
|
579
|
+
expect(client).toBeInstanceOf(MetadataApiClient);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should accept custom metadataPath', () => {
|
|
583
|
+
const c = new MetadataApiClient({ baseUrl: BASE, metadataPath: '/v2/meta' });
|
|
584
|
+
expect(c).toBeInstanceOf(MetadataApiClient);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should include auth header when token provided', async () => {
|
|
588
|
+
const c = new MetadataApiClient({ baseUrl: BASE, token: 'tok' });
|
|
589
|
+
mockFetchJson({ data: [] });
|
|
590
|
+
await c.listObjects();
|
|
591
|
+
|
|
592
|
+
const headers = (fetchMock.mock.calls[0][1] as any).headers;
|
|
593
|
+
expect(headers['Authorization']).toBe('Bearer tok');
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
describe('listObjects', () => {
|
|
598
|
+
it('should GET /api/metadata/objects', async () => {
|
|
599
|
+
mockFetchJson({ data: [{ name: 'users' }, { name: 'projects' }] });
|
|
600
|
+
const result = await client.listObjects();
|
|
601
|
+
|
|
602
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
603
|
+
`${BASE}/api/metadata/objects`,
|
|
604
|
+
expect.objectContaining({ method: 'GET' }),
|
|
605
|
+
);
|
|
606
|
+
expect(result.data).toHaveLength(2);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
describe('getObject', () => {
|
|
611
|
+
it('should GET /api/metadata/object/{name}', async () => {
|
|
612
|
+
mockFetchJson({ data: { name: 'users', fields: [] } });
|
|
613
|
+
const result = await client.getObject('users');
|
|
614
|
+
|
|
615
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
616
|
+
`${BASE}/api/metadata/object/users`,
|
|
617
|
+
expect.any(Object),
|
|
618
|
+
);
|
|
619
|
+
expect(result.data).toHaveProperty('name', 'users');
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe('getField', () => {
|
|
624
|
+
it('should GET /api/metadata/object/{obj}/fields/{field}', async () => {
|
|
625
|
+
mockFetchJson({ data: { name: 'email', type: 'string' } });
|
|
626
|
+
const result = await client.getField('users', 'email');
|
|
627
|
+
|
|
628
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
629
|
+
`${BASE}/api/metadata/object/users/fields/email`,
|
|
630
|
+
expect.any(Object),
|
|
631
|
+
);
|
|
632
|
+
expect(result.data).toHaveProperty('type', 'string');
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe('listActions', () => {
|
|
637
|
+
it('should GET /api/metadata/object/{obj}/actions', async () => {
|
|
638
|
+
mockFetchJson({ data: [{ name: 'approve' }] });
|
|
639
|
+
const result = await client.listActions('orders');
|
|
640
|
+
|
|
641
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
642
|
+
`${BASE}/api/metadata/object/orders/actions`,
|
|
643
|
+
expect.any(Object),
|
|
644
|
+
);
|
|
645
|
+
expect(result.data).toHaveLength(1);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe('listByType', () => {
|
|
650
|
+
it('should GET /api/metadata/{type}', async () => {
|
|
651
|
+
mockFetchJson({ data: [{ id: '1' }] });
|
|
652
|
+
await client.listByType('permissions');
|
|
653
|
+
|
|
654
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
655
|
+
`${BASE}/api/metadata/permissions`,
|
|
656
|
+
expect.any(Object),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('getMetadata', () => {
|
|
662
|
+
it('should GET /api/metadata/{type}/{id}', async () => {
|
|
663
|
+
mockFetchJson({ data: { id: 'p1', name: 'admin' } });
|
|
664
|
+
await client.getMetadata('permissions', 'p1');
|
|
665
|
+
|
|
666
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
667
|
+
`${BASE}/api/metadata/permissions/p1`,
|
|
668
|
+
expect.any(Object),
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe('Error handling', () => {
|
|
674
|
+
it('should throw ObjectQLError on error response', async () => {
|
|
675
|
+
mockFetchJson({ error: { code: 'NOT_FOUND', message: 'Object not found' } });
|
|
676
|
+
await expect(client.getObject('missing')).rejects.toThrow('Object not found');
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Command / CommandResult interfaces (compile-time shape validation)
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
describe('Command and CommandResult interfaces', () => {
|
|
685
|
+
it('should accept valid Command shapes', () => {
|
|
686
|
+
const createCmd: Command = { type: 'create', object: 'users', data: { name: 'A' } };
|
|
687
|
+
const updateCmd: Command = { type: 'update', object: 'users', id: '1', data: { name: 'B' } };
|
|
688
|
+
const deleteCmd: Command = { type: 'delete', object: 'users', id: '1' };
|
|
689
|
+
const bulkCreateCmd: Command = { type: 'bulkCreate', object: 'users', records: [{ name: 'C' }] };
|
|
690
|
+
const bulkUpdateCmd: Command = {
|
|
691
|
+
type: 'bulkUpdate', object: 'users',
|
|
692
|
+
updates: [{ id: '1', data: { active: true } }],
|
|
693
|
+
};
|
|
694
|
+
const bulkDeleteCmd: Command = { type: 'bulkDelete', object: 'users', ids: ['1', '2'] };
|
|
695
|
+
|
|
696
|
+
expect(createCmd.type).toBe('create');
|
|
697
|
+
expect(updateCmd.type).toBe('update');
|
|
698
|
+
expect(deleteCmd.type).toBe('delete');
|
|
699
|
+
expect(bulkCreateCmd.type).toBe('bulkCreate');
|
|
700
|
+
expect(bulkUpdateCmd.type).toBe('bulkUpdate');
|
|
701
|
+
expect(bulkDeleteCmd.type).toBe('bulkDelete');
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('should accept valid CommandResult shapes', () => {
|
|
705
|
+
const success: CommandResult = { success: true, affected: 1, data: { id: '1' } };
|
|
706
|
+
const failure: CommandResult = { success: false, affected: 0, error: 'Something went wrong' };
|
|
707
|
+
|
|
708
|
+
expect(success.success).toBe(true);
|
|
709
|
+
expect(failure.error).toBeDefined();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
// SdkConfig interface
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
describe('SdkConfig interface', () => {
|
|
717
|
+
it('should require baseUrl and allow optional fields', () => {
|
|
718
|
+
const minimal: SdkConfig = { baseUrl: 'http://localhost:3000' };
|
|
719
|
+
const full: SdkConfig = {
|
|
720
|
+
baseUrl: 'http://localhost:3000',
|
|
721
|
+
rpcPath: '/rpc',
|
|
722
|
+
queryPath: '/q',
|
|
723
|
+
commandPath: '/cmd',
|
|
724
|
+
executePath: '/exec',
|
|
725
|
+
token: 'tok',
|
|
726
|
+
apiKey: 'key',
|
|
727
|
+
headers: { 'X-Custom': 'val' },
|
|
728
|
+
timeout: 5000,
|
|
729
|
+
enableRetry: true,
|
|
730
|
+
maxRetries: 5,
|
|
731
|
+
enableLogging: true,
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
expect(minimal.baseUrl).toBeDefined();
|
|
735
|
+
expect(full.enableRetry).toBe(true);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('should use custom paths when provided to RemoteDriver', async () => {
|
|
739
|
+
const d = new RemoteDriver({
|
|
740
|
+
baseUrl: 'http://localhost:3000',
|
|
741
|
+
queryPath: '/custom/query',
|
|
742
|
+
commandPath: '/custom/cmd',
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
mockFetchJson({ value: [], count: 0 });
|
|
746
|
+
await d.executeQuery({ object: 'x' });
|
|
747
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
748
|
+
'http://localhost:3000/custom/query',
|
|
749
|
+
expect.any(Object),
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
vi.clearAllMocks();
|
|
753
|
+
mockFetchJson({ success: true, affected: 0 });
|
|
754
|
+
await d.executeCommand({ type: 'create', object: 'x', data: {} });
|
|
755
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
756
|
+
'http://localhost:3000/custom/cmd',
|
|
757
|
+
expect.any(Object),
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
});
|