@objectstack/client 2.0.0 → 2.0.2
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 +10 -10
- package/CHANGELOG.md +17 -0
- package/CLIENT_SERVER_INTEGRATION_TESTS.md +939 -0
- package/CLIENT_SPEC_COMPLIANCE.md +361 -0
- package/QUICK_REFERENCE.md +206 -0
- package/README.md +129 -0
- package/dist/index.d.mts +235 -27
- package/dist/index.d.ts +235 -27
- package/dist/index.js +483 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +483 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -11
- package/src/client.test.ts +563 -2
- package/src/index.ts +540 -37
- package/src/query-builder.ts +75 -0
- package/tests/integration/01-discovery.test.ts +68 -0
- package/tests/integration/README.md +72 -0
- package/vitest.integration.config.ts +18 -0
package/src/client.test.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { ObjectStackClient } from './index';
|
|
2
|
+
import { ObjectStackClient, QueryBuilder, FilterBuilder, createQuery, createFilter } from './index';
|
|
3
|
+
|
|
4
|
+
/** Helper: create a client with mocked fetch that returns the given response body */
|
|
5
|
+
function createMockClient(body: any, status = 200) {
|
|
6
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
10
|
+
json: async () => body,
|
|
11
|
+
headers: new Headers()
|
|
12
|
+
});
|
|
13
|
+
const client = new ObjectStackClient({
|
|
14
|
+
baseUrl: 'http://localhost:3000',
|
|
15
|
+
fetch: fetchMock
|
|
16
|
+
});
|
|
17
|
+
return { client, fetchMock };
|
|
18
|
+
}
|
|
3
19
|
|
|
4
20
|
describe('ObjectStackClient', () => {
|
|
5
21
|
it('should initialize with correct configuration', () => {
|
|
@@ -29,7 +45,8 @@ describe('ObjectStackClient', () => {
|
|
|
29
45
|
});
|
|
30
46
|
|
|
31
47
|
await client.connect();
|
|
32
|
-
|
|
48
|
+
// connect() tries .well-known first, which succeeds with our mock
|
|
49
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
33
50
|
});
|
|
34
51
|
|
|
35
52
|
it('should get metadata types', async () => {
|
|
@@ -89,3 +106,547 @@ describe('ObjectStackClient', () => {
|
|
|
89
106
|
expect(result.name).toBe('customer');
|
|
90
107
|
});
|
|
91
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('Permissions namespace', () => {
|
|
111
|
+
it('should check permission with all params', async () => {
|
|
112
|
+
const { client, fetchMock } = createMockClient({
|
|
113
|
+
success: true,
|
|
114
|
+
data: { allowed: true, reason: 'owner' }
|
|
115
|
+
});
|
|
116
|
+
const result = await client.permissions.check({
|
|
117
|
+
object: 'customer',
|
|
118
|
+
action: 'read',
|
|
119
|
+
recordId: '123',
|
|
120
|
+
field: 'email'
|
|
121
|
+
});
|
|
122
|
+
expect(result).toEqual({ allowed: true, reason: 'owner' });
|
|
123
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
124
|
+
expect(url).toContain('/api/v1/permissions/check');
|
|
125
|
+
expect(url).toContain('object=customer');
|
|
126
|
+
expect(url).toContain('action=read');
|
|
127
|
+
expect(url).toContain('recordId=123');
|
|
128
|
+
expect(url).toContain('field=email');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should check permission without optional params', async () => {
|
|
132
|
+
const { client, fetchMock } = createMockClient({
|
|
133
|
+
success: true,
|
|
134
|
+
data: { allowed: false }
|
|
135
|
+
});
|
|
136
|
+
const result = await client.permissions.check({
|
|
137
|
+
object: 'order',
|
|
138
|
+
action: 'delete'
|
|
139
|
+
});
|
|
140
|
+
expect(result).toEqual({ allowed: false });
|
|
141
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
142
|
+
expect(url).not.toContain('recordId');
|
|
143
|
+
expect(url).not.toContain('field=');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should get object permissions', async () => {
|
|
147
|
+
const { client, fetchMock } = createMockClient({
|
|
148
|
+
success: true,
|
|
149
|
+
data: { object: 'customer', permissions: { read: true, create: true } }
|
|
150
|
+
});
|
|
151
|
+
const result = await client.permissions.getObjectPermissions('customer');
|
|
152
|
+
expect(result.object).toBe('customer');
|
|
153
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
154
|
+
expect(url).toContain('/api/v1/permissions/objects/customer');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should get effective permissions', async () => {
|
|
158
|
+
const { client, fetchMock } = createMockClient({
|
|
159
|
+
success: true,
|
|
160
|
+
data: { roles: ['admin'], permissions: [] }
|
|
161
|
+
});
|
|
162
|
+
const result = await client.permissions.getEffectivePermissions();
|
|
163
|
+
expect(result.roles).toEqual(['admin']);
|
|
164
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
165
|
+
expect(url).toContain('/api/v1/permissions/effective');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Realtime namespace', () => {
|
|
170
|
+
it('should connect to realtime', async () => {
|
|
171
|
+
const { client, fetchMock } = createMockClient({
|
|
172
|
+
success: true,
|
|
173
|
+
data: { connectionId: 'conn-1', transport: 'websocket' }
|
|
174
|
+
});
|
|
175
|
+
const result = await client.realtime.connect({ transport: 'websocket' as any });
|
|
176
|
+
expect(result.connectionId).toBe('conn-1');
|
|
177
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
178
|
+
expect(url).toContain('/api/v1/realtime/connect');
|
|
179
|
+
expect(opts.method).toBe('POST');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should disconnect from realtime', async () => {
|
|
183
|
+
const { client, fetchMock } = createMockClient({ success: true });
|
|
184
|
+
await client.realtime.disconnect();
|
|
185
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
186
|
+
expect(url).toContain('/api/v1/realtime/disconnect');
|
|
187
|
+
expect(opts.method).toBe('POST');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should subscribe to a channel', async () => {
|
|
191
|
+
const { client, fetchMock } = createMockClient({
|
|
192
|
+
success: true,
|
|
193
|
+
data: { subscriptionId: 'sub-1' }
|
|
194
|
+
});
|
|
195
|
+
const result = await client.realtime.subscribe({
|
|
196
|
+
channel: 'customer.changes',
|
|
197
|
+
events: ['create', 'update']
|
|
198
|
+
});
|
|
199
|
+
expect(result.subscriptionId).toBe('sub-1');
|
|
200
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
201
|
+
expect(body.channel).toBe('customer.changes');
|
|
202
|
+
expect(body.events).toEqual(['create', 'update']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should unsubscribe from a channel', async () => {
|
|
206
|
+
const { client, fetchMock } = createMockClient({ success: true });
|
|
207
|
+
await client.realtime.unsubscribe('sub-1');
|
|
208
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
209
|
+
expect(body.subscriptionId).toBe('sub-1');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should set presence', async () => {
|
|
213
|
+
const { client, fetchMock } = createMockClient({ success: true });
|
|
214
|
+
await client.realtime.setPresence('room-1', { status: 'online' } as any);
|
|
215
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
216
|
+
expect(url).toContain('/api/v1/realtime/presence');
|
|
217
|
+
expect(opts.method).toBe('PUT');
|
|
218
|
+
const body = JSON.parse(opts.body);
|
|
219
|
+
expect(body.channel).toBe('room-1');
|
|
220
|
+
expect(body.state.status).toBe('online');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should get presence for a channel', async () => {
|
|
224
|
+
const { client, fetchMock } = createMockClient({
|
|
225
|
+
success: true,
|
|
226
|
+
data: { channel: 'room-1', members: [] }
|
|
227
|
+
});
|
|
228
|
+
const result = await client.realtime.getPresence('room-1');
|
|
229
|
+
expect(result.channel).toBe('room-1');
|
|
230
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
231
|
+
expect(url).toContain('/api/v1/realtime/presence/room-1');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('Workflow namespace', () => {
|
|
236
|
+
it('should get workflow config', async () => {
|
|
237
|
+
const { client, fetchMock } = createMockClient({
|
|
238
|
+
success: true,
|
|
239
|
+
data: { object: 'order', states: ['draft', 'submitted'] }
|
|
240
|
+
});
|
|
241
|
+
const result = await client.workflow.getConfig('order');
|
|
242
|
+
expect(result.object).toBe('order');
|
|
243
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
244
|
+
expect(url).toContain('/api/v1/workflow/order/config');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should get workflow state', async () => {
|
|
248
|
+
const { client, fetchMock } = createMockClient({
|
|
249
|
+
success: true,
|
|
250
|
+
data: { state: 'draft', transitions: ['submit'] }
|
|
251
|
+
});
|
|
252
|
+
const result = await client.workflow.getState('order', 'rec-1');
|
|
253
|
+
expect(result.state).toBe('draft');
|
|
254
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
255
|
+
expect(url).toContain('/api/v1/workflow/order/rec-1/state');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should execute workflow transition', async () => {
|
|
259
|
+
const { client, fetchMock } = createMockClient({
|
|
260
|
+
success: true,
|
|
261
|
+
data: { success: true, newState: 'submitted' }
|
|
262
|
+
});
|
|
263
|
+
const result = await client.workflow.transition({
|
|
264
|
+
object: 'order',
|
|
265
|
+
recordId: 'rec-1',
|
|
266
|
+
transition: 'submit',
|
|
267
|
+
comment: 'Ready for review'
|
|
268
|
+
});
|
|
269
|
+
expect(result.newState).toBe('submitted');
|
|
270
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
271
|
+
expect(body.transition).toBe('submit');
|
|
272
|
+
expect(body.comment).toBe('Ready for review');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should approve workflow', async () => {
|
|
276
|
+
const { client, fetchMock } = createMockClient({
|
|
277
|
+
success: true,
|
|
278
|
+
data: { success: true, newState: 'approved' }
|
|
279
|
+
});
|
|
280
|
+
const result = await client.workflow.approve({
|
|
281
|
+
object: 'order',
|
|
282
|
+
recordId: 'rec-1',
|
|
283
|
+
comment: 'Looks good'
|
|
284
|
+
});
|
|
285
|
+
expect(result.newState).toBe('approved');
|
|
286
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
287
|
+
expect(url).toContain('/api/v1/workflow/order/rec-1/approve');
|
|
288
|
+
expect(opts.method).toBe('POST');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should reject workflow', async () => {
|
|
292
|
+
const { client, fetchMock } = createMockClient({
|
|
293
|
+
success: true,
|
|
294
|
+
data: { success: true, newState: 'rejected' }
|
|
295
|
+
});
|
|
296
|
+
const result = await client.workflow.reject({
|
|
297
|
+
object: 'order',
|
|
298
|
+
recordId: 'rec-1',
|
|
299
|
+
reason: 'Incomplete data',
|
|
300
|
+
comment: 'Missing fields'
|
|
301
|
+
});
|
|
302
|
+
expect(result.newState).toBe('rejected');
|
|
303
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
304
|
+
expect(body.reason).toBe('Incomplete data');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('Views namespace', () => {
|
|
309
|
+
it('should list views for an object', async () => {
|
|
310
|
+
const { client, fetchMock } = createMockClient({
|
|
311
|
+
success: true,
|
|
312
|
+
data: { views: [{ id: 'v1', name: 'Default' }] }
|
|
313
|
+
});
|
|
314
|
+
const result = await client.views.list('customer', 'list');
|
|
315
|
+
expect(result.views).toHaveLength(1);
|
|
316
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
317
|
+
expect(url).toContain('/api/v1/ui/views/customer');
|
|
318
|
+
expect(url).toContain('type=list');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should list views without type filter', async () => {
|
|
322
|
+
const { client, fetchMock } = createMockClient({
|
|
323
|
+
success: true,
|
|
324
|
+
data: { views: [] }
|
|
325
|
+
});
|
|
326
|
+
await client.views.list('order');
|
|
327
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
328
|
+
expect(url).toContain('/api/v1/ui/views/order');
|
|
329
|
+
expect(url).not.toContain('type=');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should get a specific view', async () => {
|
|
333
|
+
const { client, fetchMock } = createMockClient({
|
|
334
|
+
success: true,
|
|
335
|
+
data: { id: 'v1', name: 'Default', type: 'list' }
|
|
336
|
+
});
|
|
337
|
+
const result = await client.views.get('customer', 'v1');
|
|
338
|
+
expect(result.id).toBe('v1');
|
|
339
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
340
|
+
expect(url).toContain('/api/v1/ui/views/customer/v1');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should create a view', async () => {
|
|
344
|
+
const { client, fetchMock } = createMockClient({
|
|
345
|
+
success: true,
|
|
346
|
+
data: { id: 'v2', name: 'Custom View' }
|
|
347
|
+
});
|
|
348
|
+
const result = await client.views.create('customer', { name: 'Custom View' } as any);
|
|
349
|
+
expect(result.id).toBe('v2');
|
|
350
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
351
|
+
expect(url).toContain('/api/v1/ui/views/customer');
|
|
352
|
+
expect(opts.method).toBe('POST');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should update a view', async () => {
|
|
356
|
+
const { client, fetchMock } = createMockClient({
|
|
357
|
+
success: true,
|
|
358
|
+
data: { id: 'v1', name: 'Updated View' }
|
|
359
|
+
});
|
|
360
|
+
const result = await client.views.update('customer', 'v1', { name: 'Updated View' } as any);
|
|
361
|
+
expect(result.name).toBe('Updated View');
|
|
362
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
363
|
+
expect(url).toContain('/api/v1/ui/views/customer/v1');
|
|
364
|
+
expect(opts.method).toBe('PUT');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should delete a view', async () => {
|
|
368
|
+
const { client, fetchMock } = createMockClient({
|
|
369
|
+
success: true,
|
|
370
|
+
data: { deleted: true }
|
|
371
|
+
});
|
|
372
|
+
const result = await client.views.delete('customer', 'v1');
|
|
373
|
+
expect(result.deleted).toBe(true);
|
|
374
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
375
|
+
expect(url).toContain('/api/v1/ui/views/customer/v1');
|
|
376
|
+
expect(opts.method).toBe('DELETE');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('Auth enhancements', () => {
|
|
381
|
+
it('should register a new user', async () => {
|
|
382
|
+
const { client, fetchMock } = createMockClient({
|
|
383
|
+
data: { token: 'new-token', user: { email: 'test@example.com' } }
|
|
384
|
+
});
|
|
385
|
+
const result = await client.auth.register({
|
|
386
|
+
email: 'test@example.com',
|
|
387
|
+
password: 'secret123',
|
|
388
|
+
name: 'Test User'
|
|
389
|
+
});
|
|
390
|
+
expect(result.data.token).toBe('new-token');
|
|
391
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
392
|
+
expect(url).toContain('/api/v1/auth/register');
|
|
393
|
+
expect(opts.method).toBe('POST');
|
|
394
|
+
// Token should be auto-set
|
|
395
|
+
expect((client as any).token).toBe('new-token');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should refresh token', async () => {
|
|
399
|
+
const { client, fetchMock } = createMockClient({
|
|
400
|
+
data: { token: 'refreshed-token' }
|
|
401
|
+
});
|
|
402
|
+
const result = await client.auth.refreshToken('old-refresh-token');
|
|
403
|
+
expect(result.data.token).toBe('refreshed-token');
|
|
404
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
405
|
+
expect(url).toContain('/api/v1/auth/refresh');
|
|
406
|
+
expect(opts.method).toBe('POST');
|
|
407
|
+
const body = JSON.parse(opts.body);
|
|
408
|
+
expect(body.refreshToken).toBe('old-refresh-token');
|
|
409
|
+
// Token should be auto-set
|
|
410
|
+
expect((client as any).token).toBe('refreshed-token');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('Notifications namespace', () => {
|
|
415
|
+
it('should register a device', async () => {
|
|
416
|
+
const { client, fetchMock } = createMockClient({
|
|
417
|
+
success: true,
|
|
418
|
+
data: { deviceId: 'dev-1', registered: true }
|
|
419
|
+
});
|
|
420
|
+
const result = await client.notifications.registerDevice({
|
|
421
|
+
token: 'push-token',
|
|
422
|
+
platform: 'web',
|
|
423
|
+
deviceId: 'dev-1'
|
|
424
|
+
});
|
|
425
|
+
expect(result.deviceId).toBe('dev-1');
|
|
426
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
427
|
+
expect(url).toContain('/api/v1/notifications/devices');
|
|
428
|
+
expect(opts.method).toBe('POST');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should unregister a device', async () => {
|
|
432
|
+
const { client, fetchMock } = createMockClient({
|
|
433
|
+
success: true,
|
|
434
|
+
data: { success: true }
|
|
435
|
+
});
|
|
436
|
+
await client.notifications.unregisterDevice('dev-1');
|
|
437
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
438
|
+
expect(url).toContain('/api/v1/notifications/devices/dev-1');
|
|
439
|
+
expect(opts.method).toBe('DELETE');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should list notifications with filters', async () => {
|
|
443
|
+
const { client, fetchMock } = createMockClient({
|
|
444
|
+
success: true,
|
|
445
|
+
data: { notifications: [], total: 0 }
|
|
446
|
+
});
|
|
447
|
+
await client.notifications.list({ read: false, limit: 10 });
|
|
448
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
449
|
+
expect(url).toContain('/api/v1/notifications');
|
|
450
|
+
expect(url).toContain('read=false');
|
|
451
|
+
expect(url).toContain('limit=10');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should mark notifications as read', async () => {
|
|
455
|
+
const { client, fetchMock } = createMockClient({
|
|
456
|
+
success: true,
|
|
457
|
+
data: { updated: 2 }
|
|
458
|
+
});
|
|
459
|
+
const result = await client.notifications.markRead(['n1', 'n2']);
|
|
460
|
+
expect(result.updated).toBe(2);
|
|
461
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
462
|
+
expect(body.ids).toEqual(['n1', 'n2']);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should mark all notifications as read', async () => {
|
|
466
|
+
const { client, fetchMock } = createMockClient({
|
|
467
|
+
success: true,
|
|
468
|
+
data: { updated: 5 }
|
|
469
|
+
});
|
|
470
|
+
const result = await client.notifications.markAllRead();
|
|
471
|
+
expect(result.updated).toBe(5);
|
|
472
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
473
|
+
expect(url).toContain('/api/v1/notifications/read/all');
|
|
474
|
+
expect(opts.method).toBe('POST');
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe('AI namespace', () => {
|
|
479
|
+
it('should execute natural language query', async () => {
|
|
480
|
+
const { client, fetchMock } = createMockClient({
|
|
481
|
+
success: true,
|
|
482
|
+
data: { query: { object: 'customer', where: {} }, confidence: 0.95 }
|
|
483
|
+
});
|
|
484
|
+
const result = await client.ai.nlq({ query: 'find all active customers' });
|
|
485
|
+
expect(result.confidence).toBe(0.95);
|
|
486
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
487
|
+
expect(url).toContain('/api/v1/ai/nlq');
|
|
488
|
+
expect(opts.method).toBe('POST');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should chat with AI', async () => {
|
|
492
|
+
const { client, fetchMock } = createMockClient({
|
|
493
|
+
success: true,
|
|
494
|
+
data: { message: 'Here are the results...', conversationId: 'conv-1' }
|
|
495
|
+
});
|
|
496
|
+
const result = await client.ai.chat({
|
|
497
|
+
message: 'Show me customer stats',
|
|
498
|
+
conversationId: 'conv-1'
|
|
499
|
+
});
|
|
500
|
+
expect(result.conversationId).toBe('conv-1');
|
|
501
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
502
|
+
expect(body.message).toBe('Show me customer stats');
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should get AI suggestions', async () => {
|
|
506
|
+
const { client, fetchMock } = createMockClient({
|
|
507
|
+
success: true,
|
|
508
|
+
data: { suggestions: ['Alice Corp', 'Alpha Inc'] }
|
|
509
|
+
});
|
|
510
|
+
const result = await client.ai.suggest({
|
|
511
|
+
object: 'customer',
|
|
512
|
+
field: 'name',
|
|
513
|
+
partial: 'Al'
|
|
514
|
+
});
|
|
515
|
+
expect(result.suggestions).toHaveLength(2);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should get AI insights', async () => {
|
|
519
|
+
const { client, fetchMock } = createMockClient({
|
|
520
|
+
success: true,
|
|
521
|
+
data: { type: 'summary', insights: [] }
|
|
522
|
+
});
|
|
523
|
+
const result = await client.ai.insights({
|
|
524
|
+
object: 'order',
|
|
525
|
+
type: 'summary'
|
|
526
|
+
});
|
|
527
|
+
expect(result.type).toBe('summary');
|
|
528
|
+
const [url, opts] = fetchMock.mock.calls[0];
|
|
529
|
+
expect(url).toContain('/api/v1/ai/insights');
|
|
530
|
+
expect(opts.method).toBe('POST');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('i18n namespace', () => {
|
|
535
|
+
it('should get available locales', async () => {
|
|
536
|
+
const { client, fetchMock } = createMockClient({
|
|
537
|
+
success: true,
|
|
538
|
+
data: { locales: ['en', 'zh-CN', 'ja'], default: 'en' }
|
|
539
|
+
});
|
|
540
|
+
const result = await client.i18n.getLocales();
|
|
541
|
+
expect(result.locales).toContain('en');
|
|
542
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
543
|
+
expect(url).toContain('/api/v1/i18n/locales');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('should get translations', async () => {
|
|
547
|
+
const { client, fetchMock } = createMockClient({
|
|
548
|
+
success: true,
|
|
549
|
+
data: { locale: 'zh-CN', translations: { hello: '你好' } }
|
|
550
|
+
});
|
|
551
|
+
const result = await client.i18n.getTranslations('zh-CN', { namespace: 'common' });
|
|
552
|
+
expect(result.locale).toBe('zh-CN');
|
|
553
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
554
|
+
expect(url).toContain('/api/v1/i18n/translations');
|
|
555
|
+
expect(url).toContain('locale=zh-CN');
|
|
556
|
+
expect(url).toContain('namespace=common');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should get field labels', async () => {
|
|
560
|
+
const { client, fetchMock } = createMockClient({
|
|
561
|
+
success: true,
|
|
562
|
+
data: { object: 'customer', labels: { name: '名前' } }
|
|
563
|
+
});
|
|
564
|
+
const result = await client.i18n.getFieldLabels('customer', 'ja');
|
|
565
|
+
expect(result.object).toBe('customer');
|
|
566
|
+
const url = fetchMock.mock.calls[0][0] as string;
|
|
567
|
+
expect(url).toContain('/api/v1/i18n/labels/customer');
|
|
568
|
+
expect(url).toContain('locale=ja');
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('QueryBuilder enhancements', () => {
|
|
573
|
+
it('should add expand for nested relation loading', () => {
|
|
574
|
+
const q = createQuery('order')
|
|
575
|
+
.select('id', 'total')
|
|
576
|
+
.expand('customer', { fields: ['name', 'email'] } as any)
|
|
577
|
+
.expand('items')
|
|
578
|
+
.build();
|
|
579
|
+
expect(q.expand).toBeDefined();
|
|
580
|
+
expect((q.expand as any).customer).toEqual({ fields: ['name', 'email'] });
|
|
581
|
+
expect((q.expand as any).items).toEqual({});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should add full-text search', () => {
|
|
585
|
+
const q = createQuery('customer')
|
|
586
|
+
.search('alice', { fields: ['name', 'email'], fuzzy: true })
|
|
587
|
+
.build();
|
|
588
|
+
expect((q as any).search).toEqual({
|
|
589
|
+
query: 'alice',
|
|
590
|
+
fields: ['name', 'email'],
|
|
591
|
+
fuzzy: true
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should set cursor for keyset pagination', () => {
|
|
596
|
+
const q = createQuery('customer')
|
|
597
|
+
.cursor({ id: 'last-seen-id', created_at: '2024-01-01' })
|
|
598
|
+
.build();
|
|
599
|
+
expect((q as any).cursor).toEqual({
|
|
600
|
+
id: 'last-seen-id',
|
|
601
|
+
created_at: '2024-01-01'
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('should enable distinct', () => {
|
|
606
|
+
const q = createQuery('customer')
|
|
607
|
+
.select('status')
|
|
608
|
+
.distinct()
|
|
609
|
+
.build();
|
|
610
|
+
expect((q as any).distinct).toBe(true);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe('FilterBuilder enhancements', () => {
|
|
615
|
+
it('should add between filter', () => {
|
|
616
|
+
const f = createFilter<{ age: number }>()
|
|
617
|
+
.between('age', 18, 65)
|
|
618
|
+
.build();
|
|
619
|
+
// between generates: ['and', [field, '>=', min], [field, '<=', max]]
|
|
620
|
+
expect(f[0]).toBe('and');
|
|
621
|
+
expect(f[1]).toEqual(['age', '>=', 18]);
|
|
622
|
+
expect(f[2]).toEqual(['age', '<=', 65]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should add contains filter', () => {
|
|
626
|
+
const f = createFilter<{ name: string }>()
|
|
627
|
+
.contains('name', 'alice')
|
|
628
|
+
.build();
|
|
629
|
+
expect(f).toEqual(['name', 'like', '%alice%']);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should add startsWith filter', () => {
|
|
633
|
+
const f = createFilter<{ name: string }>()
|
|
634
|
+
.startsWith('name', 'A')
|
|
635
|
+
.build();
|
|
636
|
+
expect(f).toEqual(['name', 'like', 'A%']);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should add endsWith filter', () => {
|
|
640
|
+
const f = createFilter<{ email: string }>()
|
|
641
|
+
.endsWith('email', '.com')
|
|
642
|
+
.build();
|
|
643
|
+
expect(f).toEqual(['email', 'like', '%.com']);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('should add exists filter', () => {
|
|
647
|
+
const f = createFilter<{ phone: string }>()
|
|
648
|
+
.exists('phone')
|
|
649
|
+
.build();
|
|
650
|
+
expect(f).toEqual(['phone', 'is_not_null', null]);
|
|
651
|
+
});
|
|
652
|
+
});
|