@objectstack/client 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,891 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
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
- }
19
-
20
- describe('ObjectStackClient', () => {
21
- it('should initialize with correct configuration', () => {
22
- const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' });
23
- expect(client).toBeDefined();
24
- });
25
-
26
- it('should normalize base URL', () => {
27
- const client: any = new ObjectStackClient({ baseUrl: 'http://localhost:3000/' });
28
- expect(client.baseUrl).toBe('http://localhost:3000');
29
- });
30
-
31
- it('should make discovery request on connect', async () => {
32
- const fetchMock = vi.fn().mockResolvedValue({
33
- ok: true,
34
- json: async () => ({
35
- version: 'v1',
36
- apiName: 'ObjectStack',
37
- capabilities: ['metadata', 'data', 'ui'],
38
- endpoints: {}
39
- })
40
- });
41
-
42
- const client = new ObjectStackClient({
43
- baseUrl: 'http://localhost:3000',
44
- fetch: fetchMock
45
- });
46
-
47
- await client.connect();
48
- // connect() tries .well-known first, which succeeds with our mock
49
- expect(fetchMock).toHaveBeenCalled();
50
- });
51
-
52
- it('should get metadata types', async () => {
53
- const fetchMock = vi.fn().mockResolvedValue({
54
- ok: true,
55
- json: async () => ({
56
- types: ['object', 'plugin', 'view']
57
- })
58
- });
59
-
60
- const client = new ObjectStackClient({
61
- baseUrl: 'http://localhost:3000',
62
- fetch: fetchMock
63
- });
64
-
65
- const result = await client.meta.getTypes();
66
- expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta', expect.any(Object));
67
- expect(result.types).toEqual(['object', 'plugin', 'view']);
68
- });
69
-
70
- it('should get metadata items by type', async () => {
71
- const fetchMock = vi.fn().mockResolvedValue({
72
- ok: true,
73
- json: async () => ({
74
- type: 'object',
75
- items: [{ name: 'customer' }, { name: 'order' }]
76
- })
77
- });
78
-
79
- const client = new ObjectStackClient({
80
- baseUrl: 'http://localhost:3000',
81
- fetch: fetchMock
82
- });
83
-
84
- const result = await client.meta.getItems('object');
85
- expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object', expect.any(Object));
86
- expect(result.type).toBe('object');
87
- expect(result.items).toHaveLength(2);
88
- });
89
-
90
- it('should get metadata item by type and name', async () => {
91
- const fetchMock = vi.fn().mockResolvedValue({
92
- ok: true,
93
- json: async () => ({
94
- name: 'customer',
95
- fields: []
96
- })
97
- });
98
-
99
- const client = new ObjectStackClient({
100
- baseUrl: 'http://localhost:3000',
101
- fetch: fetchMock
102
- });
103
-
104
- const result = await client.meta.getItem('object', 'customer');
105
- expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object));
106
- expect(result.name).toBe('customer');
107
- });
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/sign-up/email'); // Updated to better-auth endpoint
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/get-session'); // Updated: better-auth uses get-session for refresh
406
- expect(opts.method).toBe('GET'); // Updated: GET instead of POST
407
- // Token should be auto-set
408
- expect((client as any).token).toBe('refreshed-token');
409
- });
410
- });
411
-
412
- describe('Notifications namespace', () => {
413
- it('should register a device', async () => {
414
- const { client, fetchMock } = createMockClient({
415
- success: true,
416
- data: { deviceId: 'dev-1', registered: true }
417
- });
418
- const result = await client.notifications.registerDevice({
419
- token: 'push-token',
420
- platform: 'web',
421
- deviceId: 'dev-1'
422
- });
423
- expect(result.deviceId).toBe('dev-1');
424
- const [url, opts] = fetchMock.mock.calls[0];
425
- expect(url).toContain('/api/v1/notifications/devices');
426
- expect(opts.method).toBe('POST');
427
- });
428
-
429
- it('should unregister a device', async () => {
430
- const { client, fetchMock } = createMockClient({
431
- success: true,
432
- data: { success: true }
433
- });
434
- await client.notifications.unregisterDevice('dev-1');
435
- const [url, opts] = fetchMock.mock.calls[0];
436
- expect(url).toContain('/api/v1/notifications/devices/dev-1');
437
- expect(opts.method).toBe('DELETE');
438
- });
439
-
440
- it('should list notifications with filters', async () => {
441
- const { client, fetchMock } = createMockClient({
442
- success: true,
443
- data: { notifications: [], total: 0 }
444
- });
445
- await client.notifications.list({ read: false, limit: 10 });
446
- const url = fetchMock.mock.calls[0][0] as string;
447
- expect(url).toContain('/api/v1/notifications');
448
- expect(url).toContain('read=false');
449
- expect(url).toContain('limit=10');
450
- });
451
-
452
- it('should mark notifications as read', async () => {
453
- const { client, fetchMock } = createMockClient({
454
- success: true,
455
- data: { updated: 2 }
456
- });
457
- const result = await client.notifications.markRead(['n1', 'n2']);
458
- expect(result.updated).toBe(2);
459
- const body = JSON.parse(fetchMock.mock.calls[0][1].body);
460
- expect(body.ids).toEqual(['n1', 'n2']);
461
- });
462
-
463
- it('should mark all notifications as read', async () => {
464
- const { client, fetchMock } = createMockClient({
465
- success: true,
466
- data: { updated: 5 }
467
- });
468
- const result = await client.notifications.markAllRead();
469
- expect(result.updated).toBe(5);
470
- const [url, opts] = fetchMock.mock.calls[0];
471
- expect(url).toContain('/api/v1/notifications/read/all');
472
- expect(opts.method).toBe('POST');
473
- });
474
- });
475
-
476
- describe('AI namespace', () => {
477
- it('should execute natural language query', async () => {
478
- const { client, fetchMock } = createMockClient({
479
- success: true,
480
- data: { query: { object: 'customer', where: {} }, confidence: 0.95 }
481
- });
482
- const result = await client.ai.nlq({ query: 'find all active customers' });
483
- expect(result.confidence).toBe(0.95);
484
- const [url, opts] = fetchMock.mock.calls[0];
485
- expect(url).toContain('/api/v1/ai/nlq');
486
- expect(opts.method).toBe('POST');
487
- });
488
-
489
- it('should not expose chat method (use Vercel AI SDK useChat directly)', () => {
490
- const { client } = createMockClient({ success: true, data: {} });
491
- // ai.chat was removed — consumers should use @ai-sdk/react useChat() directly
492
- expect(client.ai).not.toHaveProperty('chat');
493
- });
494
-
495
- it('should get AI suggestions', async () => {
496
- const { client, fetchMock } = createMockClient({
497
- success: true,
498
- data: { suggestions: ['Alice Corp', 'Alpha Inc'] }
499
- });
500
- const result = await client.ai.suggest({
501
- object: 'customer',
502
- field: 'name',
503
- partial: 'Al'
504
- });
505
- expect(result.suggestions).toHaveLength(2);
506
- });
507
-
508
- it('should get AI insights', async () => {
509
- const { client, fetchMock } = createMockClient({
510
- success: true,
511
- data: { type: 'summary', insights: [] }
512
- });
513
- const result = await client.ai.insights({
514
- object: 'order',
515
- type: 'summary'
516
- });
517
- expect(result.type).toBe('summary');
518
- const [url, opts] = fetchMock.mock.calls[0];
519
- expect(url).toContain('/api/v1/ai/insights');
520
- expect(opts.method).toBe('POST');
521
- });
522
- });
523
-
524
- describe('i18n namespace', () => {
525
- it('should get available locales', async () => {
526
- const { client, fetchMock } = createMockClient({
527
- success: true,
528
- data: { locales: ['en', 'zh-CN', 'ja'], default: 'en' }
529
- });
530
- const result = await client.i18n.getLocales();
531
- expect(result.locales).toContain('en');
532
- const url = fetchMock.mock.calls[0][0] as string;
533
- expect(url).toContain('/api/v1/i18n/locales');
534
- });
535
-
536
- it('should get translations', async () => {
537
- const { client, fetchMock } = createMockClient({
538
- success: true,
539
- data: { locale: 'zh-CN', translations: { hello: '你好' } }
540
- });
541
- const result = await client.i18n.getTranslations('zh-CN', { namespace: 'common' });
542
- expect(result.locale).toBe('zh-CN');
543
- const url = fetchMock.mock.calls[0][0] as string;
544
- expect(url).toContain('/api/v1/i18n/translations');
545
- expect(url).toContain('locale=zh-CN');
546
- expect(url).toContain('namespace=common');
547
- });
548
-
549
- it('should get field labels', async () => {
550
- const { client, fetchMock } = createMockClient({
551
- success: true,
552
- data: { object: 'customer', labels: { name: '名前' } }
553
- });
554
- const result = await client.i18n.getFieldLabels('customer', 'ja');
555
- expect(result.object).toBe('customer');
556
- const url = fetchMock.mock.calls[0][0] as string;
557
- expect(url).toContain('/api/v1/i18n/labels/customer');
558
- expect(url).toContain('locale=ja');
559
- });
560
- });
561
-
562
- describe('QueryBuilder enhancements', () => {
563
- it('should add expand for nested relation loading', () => {
564
- const q = createQuery('order')
565
- .select('id', 'total')
566
- .expand('customer', { fields: ['name', 'email'] } as any)
567
- .expand('items')
568
- .build();
569
- expect(q.expand).toBeDefined();
570
- expect((q.expand as any).customer).toEqual({ fields: ['name', 'email'] });
571
- expect((q.expand as any).items).toEqual({});
572
- });
573
-
574
- it('should add full-text search', () => {
575
- const q = createQuery('customer')
576
- .search('alice', { fields: ['name', 'email'], fuzzy: true })
577
- .build();
578
- expect((q as any).search).toEqual({
579
- query: 'alice',
580
- fields: ['name', 'email'],
581
- fuzzy: true
582
- });
583
- });
584
-
585
- it('should set cursor for keyset pagination', () => {
586
- const q = createQuery('customer')
587
- .cursor({ id: 'last-seen-id', created_at: '2024-01-01' })
588
- .build();
589
- expect((q as any).cursor).toEqual({
590
- id: 'last-seen-id',
591
- created_at: '2024-01-01'
592
- });
593
- });
594
-
595
- it('should enable distinct', () => {
596
- const q = createQuery('customer')
597
- .select('status')
598
- .distinct()
599
- .build();
600
- expect((q as any).distinct).toBe(true);
601
- });
602
- });
603
-
604
- describe('FilterBuilder enhancements', () => {
605
- it('should add between filter', () => {
606
- const f = createFilter<{ age: number }>()
607
- .between('age', 18, 65)
608
- .build();
609
- // between generates: ['and', [field, '>=', min], [field, '<=', max]]
610
- expect(f[0]).toBe('and');
611
- expect(f[1]).toEqual(['age', '>=', 18]);
612
- expect(f[2]).toEqual(['age', '<=', 65]);
613
- });
614
-
615
- it('should add contains filter', () => {
616
- const f = createFilter<{ name: string }>()
617
- .contains('name', 'alice')
618
- .build();
619
- expect(f).toEqual(['name', 'like', '%alice%']);
620
- });
621
-
622
- it('should add startsWith filter', () => {
623
- const f = createFilter<{ name: string }>()
624
- .startsWith('name', 'A')
625
- .build();
626
- expect(f).toEqual(['name', 'like', 'A%']);
627
- });
628
-
629
- it('should add endsWith filter', () => {
630
- const f = createFilter<{ email: string }>()
631
- .endsWith('email', '.com')
632
- .build();
633
- expect(f).toEqual(['email', 'like', '%.com']);
634
- });
635
-
636
- it('should add exists filter', () => {
637
- const f = createFilter<{ phone: string }>()
638
- .exists('phone')
639
- .build();
640
- expect(f).toEqual(['phone', 'is_not_null', null]);
641
- });
642
- });
643
-
644
- // ==========================================
645
- // Automation Client Tests
646
- // ==========================================
647
-
648
- describe('ObjectStackClient.automation', () => {
649
- it('should list flows', async () => {
650
- const { client, fetchMock } = createMockClient({
651
- success: true,
652
- data: { flows: ['flow_a', 'flow_b'], total: 2, hasMore: false },
653
- });
654
-
655
- const result = await client.automation.list();
656
- expect(fetchMock).toHaveBeenCalledWith(
657
- 'http://localhost:3000/api/v1/automation',
658
- expect.any(Object),
659
- );
660
- expect(result.flows).toEqual(['flow_a', 'flow_b']);
661
- });
662
-
663
- it('should get a flow by name', async () => {
664
- const { client, fetchMock } = createMockClient({
665
- success: true,
666
- data: { name: 'my_flow', label: 'My Flow' },
667
- });
668
-
669
- const result = await client.automation.get('my_flow');
670
- expect(fetchMock).toHaveBeenCalledWith(
671
- 'http://localhost:3000/api/v1/automation/my_flow',
672
- expect.any(Object),
673
- );
674
- expect(result.name).toBe('my_flow');
675
- });
676
-
677
- it('should create a flow', async () => {
678
- const { client, fetchMock } = createMockClient({
679
- success: true,
680
- data: { name: 'new_flow' },
681
- });
682
-
683
- await client.automation.create('new_flow', { label: 'New' });
684
- expect(fetchMock).toHaveBeenCalledWith(
685
- 'http://localhost:3000/api/v1/automation',
686
- expect.objectContaining({ method: 'POST' }),
687
- );
688
- });
689
-
690
- it('should update a flow', async () => {
691
- const { client, fetchMock } = createMockClient({
692
- success: true,
693
- data: { name: 'my_flow', label: 'Updated' },
694
- });
695
-
696
- await client.automation.update('my_flow', { label: 'Updated' });
697
- expect(fetchMock).toHaveBeenCalledWith(
698
- 'http://localhost:3000/api/v1/automation/my_flow',
699
- expect.objectContaining({ method: 'PUT' }),
700
- );
701
- });
702
-
703
- it('should delete a flow', async () => {
704
- const { client, fetchMock } = createMockClient({
705
- success: true,
706
- data: { name: 'old_flow', deleted: true },
707
- });
708
-
709
- const result = await client.automation.delete('old_flow');
710
- expect(fetchMock).toHaveBeenCalledWith(
711
- 'http://localhost:3000/api/v1/automation/old_flow',
712
- expect.objectContaining({ method: 'DELETE' }),
713
- );
714
- expect(result.deleted).toBe(true);
715
- });
716
-
717
- it('should toggle a flow', async () => {
718
- const { client, fetchMock } = createMockClient({
719
- success: true,
720
- data: { name: 'my_flow', enabled: false },
721
- });
722
-
723
- const result = await client.automation.toggle('my_flow', false);
724
- expect(fetchMock).toHaveBeenCalledWith(
725
- 'http://localhost:3000/api/v1/automation/my_flow/toggle',
726
- expect.objectContaining({ method: 'POST' }),
727
- );
728
- expect(result.enabled).toBe(false);
729
- });
730
-
731
- it('should list runs for a flow', async () => {
732
- const { client, fetchMock } = createMockClient({
733
- success: true,
734
- data: { runs: [{ id: 'run_1' }], hasMore: false },
735
- });
736
-
737
- const result = await client.automation.runs.list('my_flow');
738
- expect(fetchMock).toHaveBeenCalledWith(
739
- 'http://localhost:3000/api/v1/automation/my_flow/runs',
740
- expect.any(Object),
741
- );
742
- expect(result.runs).toHaveLength(1);
743
- });
744
-
745
- it('should list runs with pagination options', async () => {
746
- const { client, fetchMock } = createMockClient({
747
- success: true,
748
- data: { runs: [], hasMore: false },
749
- });
750
-
751
- await client.automation.runs.list('my_flow', { limit: 5, cursor: 'abc' });
752
- expect(fetchMock).toHaveBeenCalledWith(
753
- 'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5&cursor=abc',
754
- expect.any(Object),
755
- );
756
- });
757
-
758
- it('should get a single run', async () => {
759
- const { client, fetchMock } = createMockClient({
760
- success: true,
761
- data: { id: 'run_1', status: 'completed' },
762
- });
763
-
764
- const result = await client.automation.runs.get('my_flow', 'run_1');
765
- expect(fetchMock).toHaveBeenCalledWith(
766
- 'http://localhost:3000/api/v1/automation/my_flow/runs/run_1',
767
- expect.any(Object),
768
- );
769
- expect(result.id).toBe('run_1');
770
- });
771
-
772
- it('should still support legacy trigger', async () => {
773
- const { client, fetchMock } = createMockClient({ success: true, data: { result: 'ok' } });
774
-
775
- await client.automation.trigger('my_flow', { key: 'val' });
776
- expect(fetchMock).toHaveBeenCalledWith(
777
- 'http://localhost:3000/api/v1/automation/trigger/my_flow',
778
- expect.objectContaining({ method: 'POST' }),
779
- );
780
- });
781
-
782
- // ==========================================
783
- // capabilities getter
784
- // ==========================================
785
-
786
- it('should return undefined capabilities before connect', () => {
787
- const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' });
788
- expect(client.capabilities).toBeUndefined();
789
- });
790
-
791
- it('should expose capabilities after connect', async () => {
792
- const caps = {
793
- feed: true,
794
- comments: true,
795
- automation: false,
796
- cron: false,
797
- search: true,
798
- export: false,
799
- chunkedUpload: false,
800
- };
801
- const fetchMock = vi.fn().mockResolvedValue({
802
- ok: true,
803
- json: async () => ({
804
- version: 'v1',
805
- apiName: 'ObjectStack API',
806
- capabilities: caps,
807
- }),
808
- });
809
-
810
- const client = new ObjectStackClient({
811
- baseUrl: 'http://localhost:3000',
812
- fetch: fetchMock,
813
- });
814
-
815
- await client.connect();
816
- expect(client.capabilities).toBeDefined();
817
- expect(client.capabilities!.feed).toBe(true);
818
- expect(client.capabilities!.automation).toBe(false);
819
- expect(client.capabilities!.search).toBe(true);
820
- });
821
- });
822
-
823
- // ==========================================
824
- // QueryOptionsV2 (Canonical Query Syntax) Tests
825
- // ==========================================
826
-
827
- describe('QueryOptionsV2 — canonical find()', () => {
828
- it('should accept canonical field names (where, fields, orderBy, limit, offset)', async () => {
829
- const { client, fetchMock } = createMockClient({
830
- success: true,
831
- data: { object: 'account', records: [], total: 0 }
832
- });
833
-
834
- await client.data.find('account', {
835
- where: { status: 'active' },
836
- fields: ['name', 'email'],
837
- orderBy: ['-created_at'],
838
- limit: 10,
839
- offset: 5,
840
- });
841
-
842
- const url = fetchMock.mock.calls[0][0] as string;
843
- // V2 canonical options are normalized to HTTP transport params
844
- expect(url).toContain('top=10');
845
- expect(url).toContain('skip=5');
846
- expect(url).toContain('select=name%2Cemail');
847
- expect(url).toContain('sort=-created_at');
848
- // where → filter as JSON
849
- expect(url).toContain('status=active');
850
- });
851
-
852
- it('should still accept legacy field names (filter, select, sort, top, skip)', async () => {
853
- const { client, fetchMock } = createMockClient({
854
- success: true,
855
- data: { object: 'account', records: [], total: 0 }
856
- });
857
-
858
- await client.data.find('account', {
859
- filter: { industry: 'Tech' },
860
- select: ['name'],
861
- sort: ['-revenue'],
862
- top: 20,
863
- skip: 0,
864
- });
865
-
866
- const url = fetchMock.mock.calls[0][0] as string;
867
- expect(url).toContain('top=20');
868
- expect(url).toContain('select=name');
869
- expect(url).toContain('sort=-revenue');
870
- expect(url).toContain('industry=Tech');
871
- });
872
- });
873
-
874
- describe('QueryBuilder — offset() alias', () => {
875
- it('should set offset via .offset() method', () => {
876
- const q = createQuery('task')
877
- .limit(10)
878
- .offset(20)
879
- .build();
880
- expect(q.limit).toBe(10);
881
- expect(q.offset).toBe(20);
882
- });
883
-
884
- it('should set offset via deprecated .skip() method', () => {
885
- const q = createQuery('task')
886
- .limit(10)
887
- .skip(30)
888
- .build();
889
- expect(q.offset).toBe(30);
890
- });
891
- });