@objectstack/client 3.0.7 → 3.0.9

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.
@@ -0,0 +1,273 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { ObjectStackClient } from './index';
5
+
6
+ /** Helper: create a client with mocked fetch */
7
+ function createMockClient(body: any, status = 200) {
8
+ const fetchMock = vi.fn().mockResolvedValue({
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ statusText: status === 200 ? 'OK' : 'Error',
12
+ json: async () => body,
13
+ headers: new Headers()
14
+ });
15
+ const client = new ObjectStackClient({
16
+ baseUrl: 'http://localhost:3000',
17
+ fetch: fetchMock
18
+ });
19
+ return { client, fetchMock };
20
+ }
21
+
22
+ describe('ObjectStackClient - Feed Namespace', () => {
23
+ // ==========================================
24
+ // Feed CRUD
25
+ // ==========================================
26
+
27
+ it('feed.list should GET /api/v1/data/:object/:recordId/feed', async () => {
28
+ const { client, fetchMock } = createMockClient({
29
+ success: true,
30
+ data: { items: [], total: 0, hasMore: false }
31
+ });
32
+
33
+ const result = await client.feed.list('account', 'rec_123', { type: 'all', limit: 10 });
34
+
35
+ expect(fetchMock).toHaveBeenCalledWith(
36
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed?type=all&limit=10',
37
+ expect.objectContaining({ headers: expect.any(Object) })
38
+ );
39
+ expect(result.items).toEqual([]);
40
+ expect(result.hasMore).toBe(false);
41
+ });
42
+
43
+ it('feed.create should POST /api/v1/data/:object/:recordId/feed', async () => {
44
+ const { client, fetchMock } = createMockClient({
45
+ success: true,
46
+ data: { id: 'feed_1', type: 'comment', body: 'Hello' }
47
+ });
48
+
49
+ const result = await client.feed.create('account', 'rec_123', {
50
+ type: 'comment',
51
+ body: 'Hello'
52
+ });
53
+
54
+ expect(fetchMock).toHaveBeenCalledWith(
55
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed',
56
+ expect.objectContaining({
57
+ method: 'POST',
58
+ body: JSON.stringify({ type: 'comment', body: 'Hello' })
59
+ })
60
+ );
61
+ expect(result.id).toBe('feed_1');
62
+ });
63
+
64
+ it('feed.update should PUT /api/v1/data/:object/:recordId/feed/:feedId', async () => {
65
+ const { client, fetchMock } = createMockClient({
66
+ success: true,
67
+ data: { id: 'feed_1', type: 'comment', body: 'Updated' }
68
+ });
69
+
70
+ const result = await client.feed.update('account', 'rec_123', 'feed_1', {
71
+ body: 'Updated'
72
+ });
73
+
74
+ expect(fetchMock).toHaveBeenCalledWith(
75
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1',
76
+ expect.objectContaining({
77
+ method: 'PUT',
78
+ body: JSON.stringify({ body: 'Updated' })
79
+ })
80
+ );
81
+ expect(result.body).toBe('Updated');
82
+ });
83
+
84
+ it('feed.delete should DELETE /api/v1/data/:object/:recordId/feed/:feedId', async () => {
85
+ const { client, fetchMock } = createMockClient({
86
+ success: true,
87
+ data: { feedId: 'feed_1' }
88
+ });
89
+
90
+ const result = await client.feed.delete('account', 'rec_123', 'feed_1');
91
+
92
+ expect(fetchMock).toHaveBeenCalledWith(
93
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1',
94
+ expect.objectContaining({ method: 'DELETE' })
95
+ );
96
+ expect(result.feedId).toBe('feed_1');
97
+ });
98
+
99
+ // ==========================================
100
+ // Reactions
101
+ // ==========================================
102
+
103
+ it('feed.addReaction should POST reactions endpoint', async () => {
104
+ const { client, fetchMock } = createMockClient({
105
+ success: true,
106
+ data: { reactions: [{ emoji: '👍', count: 1 }] }
107
+ });
108
+
109
+ const result = await client.feed.addReaction('account', 'rec_123', 'feed_1', '👍');
110
+
111
+ expect(fetchMock).toHaveBeenCalledWith(
112
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/reactions',
113
+ expect.objectContaining({
114
+ method: 'POST',
115
+ body: JSON.stringify({ emoji: '👍' })
116
+ })
117
+ );
118
+ expect(result.reactions).toHaveLength(1);
119
+ });
120
+
121
+ it('feed.removeReaction should DELETE reactions/:emoji endpoint', async () => {
122
+ const { client, fetchMock } = createMockClient({
123
+ success: true,
124
+ data: { reactions: [] }
125
+ });
126
+
127
+ await client.feed.removeReaction('account', 'rec_123', 'feed_1', '👍');
128
+
129
+ expect(fetchMock).toHaveBeenCalledWith(
130
+ expect.stringContaining('/api/v1/data/account/rec_123/feed/feed_1/reactions/'),
131
+ expect.objectContaining({ method: 'DELETE' })
132
+ );
133
+ });
134
+
135
+ // ==========================================
136
+ // Pin / Star
137
+ // ==========================================
138
+
139
+ it('feed.pin should POST pin endpoint', async () => {
140
+ const { client, fetchMock } = createMockClient({
141
+ success: true,
142
+ data: { feedId: 'feed_1', pinned: true, pinnedAt: '2026-01-01T00:00:00Z' }
143
+ });
144
+
145
+ const result = await client.feed.pin('account', 'rec_123', 'feed_1');
146
+
147
+ expect(fetchMock).toHaveBeenCalledWith(
148
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin',
149
+ expect.objectContaining({ method: 'POST' })
150
+ );
151
+ expect(result.pinned).toBe(true);
152
+ });
153
+
154
+ it('feed.unpin should DELETE pin endpoint', async () => {
155
+ const { client, fetchMock } = createMockClient({
156
+ success: true,
157
+ data: { feedId: 'feed_1', pinned: false }
158
+ });
159
+
160
+ const result = await client.feed.unpin('account', 'rec_123', 'feed_1');
161
+
162
+ expect(fetchMock).toHaveBeenCalledWith(
163
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/pin',
164
+ expect.objectContaining({ method: 'DELETE' })
165
+ );
166
+ expect(result.pinned).toBe(false);
167
+ });
168
+
169
+ it('feed.star should POST star endpoint', async () => {
170
+ const { client, fetchMock } = createMockClient({
171
+ success: true,
172
+ data: { feedId: 'feed_1', starred: true, starredAt: '2026-01-01T00:00:00Z' }
173
+ });
174
+
175
+ const result = await client.feed.star('account', 'rec_123', 'feed_1');
176
+
177
+ expect(fetchMock).toHaveBeenCalledWith(
178
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star',
179
+ expect.objectContaining({ method: 'POST' })
180
+ );
181
+ expect(result.starred).toBe(true);
182
+ });
183
+
184
+ it('feed.unstar should DELETE star endpoint', async () => {
185
+ const { client, fetchMock } = createMockClient({
186
+ success: true,
187
+ data: { feedId: 'feed_1', starred: false }
188
+ });
189
+
190
+ const result = await client.feed.unstar('account', 'rec_123', 'feed_1');
191
+
192
+ expect(fetchMock).toHaveBeenCalledWith(
193
+ 'http://localhost:3000/api/v1/data/account/rec_123/feed/feed_1/star',
194
+ expect.objectContaining({ method: 'DELETE' })
195
+ );
196
+ expect(result.starred).toBe(false);
197
+ });
198
+
199
+ // ==========================================
200
+ // Search & Changelog
201
+ // ==========================================
202
+
203
+ it('feed.search should GET search endpoint with query params', async () => {
204
+ const { client, fetchMock } = createMockClient({
205
+ success: true,
206
+ data: { items: [], total: 0, hasMore: false }
207
+ });
208
+
209
+ await client.feed.search('account', 'rec_123', 'follow up', { limit: 10 });
210
+
211
+ expect(fetchMock).toHaveBeenCalledWith(
212
+ expect.stringContaining('/api/v1/data/account/rec_123/feed/search?query=follow+up'),
213
+ expect.any(Object)
214
+ );
215
+ });
216
+
217
+ it('feed.getChangelog should GET changelog endpoint', async () => {
218
+ const { client, fetchMock } = createMockClient({
219
+ success: true,
220
+ data: { entries: [], total: 0, hasMore: false }
221
+ });
222
+
223
+ await client.feed.getChangelog('account', 'rec_123', { field: 'status' });
224
+
225
+ expect(fetchMock).toHaveBeenCalledWith(
226
+ 'http://localhost:3000/api/v1/data/account/rec_123/changelog?field=status',
227
+ expect.any(Object)
228
+ );
229
+ });
230
+
231
+ // ==========================================
232
+ // Subscriptions
233
+ // ==========================================
234
+
235
+ it('feed.subscribe should POST subscribe endpoint', async () => {
236
+ const { client, fetchMock } = createMockClient({
237
+ success: true,
238
+ data: { object: 'account', recordId: 'rec_123', events: ['all'], channels: ['in_app'] }
239
+ });
240
+
241
+ const result = await client.feed.subscribe('account', 'rec_123', {
242
+ events: ['comment', 'field_change'],
243
+ channels: ['in_app', 'email']
244
+ });
245
+
246
+ expect(fetchMock).toHaveBeenCalledWith(
247
+ 'http://localhost:3000/api/v1/data/account/rec_123/subscribe',
248
+ expect.objectContaining({
249
+ method: 'POST',
250
+ body: JSON.stringify({
251
+ events: ['comment', 'field_change'],
252
+ channels: ['in_app', 'email']
253
+ })
254
+ })
255
+ );
256
+ expect(result.object).toBe('account');
257
+ });
258
+
259
+ it('feed.unsubscribe should DELETE subscribe endpoint', async () => {
260
+ const { client, fetchMock } = createMockClient({
261
+ success: true,
262
+ data: { object: 'account', recordId: 'rec_123', unsubscribed: true }
263
+ });
264
+
265
+ const result = await client.feed.unsubscribe('account', 'rec_123');
266
+
267
+ expect(fetchMock).toHaveBeenCalledWith(
268
+ 'http://localhost:3000/api/v1/data/account/rec_123/subscribe',
269
+ expect.objectContaining({ method: 'DELETE' })
270
+ );
271
+ expect(result.unsubscribed).toBe(true);
272
+ });
273
+ });
@@ -648,3 +648,182 @@ describe('FilterBuilder enhancements', () => {
648
648
  expect(f).toEqual(['phone', 'is_not_null', null]);
649
649
  });
650
650
  });
651
+
652
+ // ==========================================
653
+ // Automation Client Tests
654
+ // ==========================================
655
+
656
+ describe('ObjectStackClient.automation', () => {
657
+ it('should list flows', async () => {
658
+ const { client, fetchMock } = createMockClient({
659
+ success: true,
660
+ data: { flows: ['flow_a', 'flow_b'], total: 2, hasMore: false },
661
+ });
662
+
663
+ const result = await client.automation.list();
664
+ expect(fetchMock).toHaveBeenCalledWith(
665
+ 'http://localhost:3000/api/v1/automation',
666
+ expect.any(Object),
667
+ );
668
+ expect(result.flows).toEqual(['flow_a', 'flow_b']);
669
+ });
670
+
671
+ it('should get a flow by name', async () => {
672
+ const { client, fetchMock } = createMockClient({
673
+ success: true,
674
+ data: { name: 'my_flow', label: 'My Flow' },
675
+ });
676
+
677
+ const result = await client.automation.get('my_flow');
678
+ expect(fetchMock).toHaveBeenCalledWith(
679
+ 'http://localhost:3000/api/v1/automation/my_flow',
680
+ expect.any(Object),
681
+ );
682
+ expect(result.name).toBe('my_flow');
683
+ });
684
+
685
+ it('should create a flow', async () => {
686
+ const { client, fetchMock } = createMockClient({
687
+ success: true,
688
+ data: { name: 'new_flow' },
689
+ });
690
+
691
+ await client.automation.create('new_flow', { label: 'New' });
692
+ expect(fetchMock).toHaveBeenCalledWith(
693
+ 'http://localhost:3000/api/v1/automation',
694
+ expect.objectContaining({ method: 'POST' }),
695
+ );
696
+ });
697
+
698
+ it('should update a flow', async () => {
699
+ const { client, fetchMock } = createMockClient({
700
+ success: true,
701
+ data: { name: 'my_flow', label: 'Updated' },
702
+ });
703
+
704
+ await client.automation.update('my_flow', { label: 'Updated' });
705
+ expect(fetchMock).toHaveBeenCalledWith(
706
+ 'http://localhost:3000/api/v1/automation/my_flow',
707
+ expect.objectContaining({ method: 'PUT' }),
708
+ );
709
+ });
710
+
711
+ it('should delete a flow', async () => {
712
+ const { client, fetchMock } = createMockClient({
713
+ success: true,
714
+ data: { name: 'old_flow', deleted: true },
715
+ });
716
+
717
+ const result = await client.automation.delete('old_flow');
718
+ expect(fetchMock).toHaveBeenCalledWith(
719
+ 'http://localhost:3000/api/v1/automation/old_flow',
720
+ expect.objectContaining({ method: 'DELETE' }),
721
+ );
722
+ expect(result.deleted).toBe(true);
723
+ });
724
+
725
+ it('should toggle a flow', async () => {
726
+ const { client, fetchMock } = createMockClient({
727
+ success: true,
728
+ data: { name: 'my_flow', enabled: false },
729
+ });
730
+
731
+ const result = await client.automation.toggle('my_flow', false);
732
+ expect(fetchMock).toHaveBeenCalledWith(
733
+ 'http://localhost:3000/api/v1/automation/my_flow/toggle',
734
+ expect.objectContaining({ method: 'POST' }),
735
+ );
736
+ expect(result.enabled).toBe(false);
737
+ });
738
+
739
+ it('should list runs for a flow', async () => {
740
+ const { client, fetchMock } = createMockClient({
741
+ success: true,
742
+ data: { runs: [{ id: 'run_1' }], hasMore: false },
743
+ });
744
+
745
+ const result = await client.automation.runs.list('my_flow');
746
+ expect(fetchMock).toHaveBeenCalledWith(
747
+ 'http://localhost:3000/api/v1/automation/my_flow/runs',
748
+ expect.any(Object),
749
+ );
750
+ expect(result.runs).toHaveLength(1);
751
+ });
752
+
753
+ it('should list runs with pagination options', async () => {
754
+ const { client, fetchMock } = createMockClient({
755
+ success: true,
756
+ data: { runs: [], hasMore: false },
757
+ });
758
+
759
+ await client.automation.runs.list('my_flow', { limit: 5, cursor: 'abc' });
760
+ expect(fetchMock).toHaveBeenCalledWith(
761
+ 'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5&cursor=abc',
762
+ expect.any(Object),
763
+ );
764
+ });
765
+
766
+ it('should get a single run', async () => {
767
+ const { client, fetchMock } = createMockClient({
768
+ success: true,
769
+ data: { id: 'run_1', status: 'completed' },
770
+ });
771
+
772
+ const result = await client.automation.runs.get('my_flow', 'run_1');
773
+ expect(fetchMock).toHaveBeenCalledWith(
774
+ 'http://localhost:3000/api/v1/automation/my_flow/runs/run_1',
775
+ expect.any(Object),
776
+ );
777
+ expect(result.id).toBe('run_1');
778
+ });
779
+
780
+ it('should still support legacy trigger', async () => {
781
+ const { client, fetchMock } = createMockClient({ success: true, data: { result: 'ok' } });
782
+
783
+ await client.automation.trigger('my_flow', { key: 'val' });
784
+ expect(fetchMock).toHaveBeenCalledWith(
785
+ 'http://localhost:3000/api/v1/automation/trigger/my_flow',
786
+ expect.objectContaining({ method: 'POST' }),
787
+ );
788
+ });
789
+
790
+ // ==========================================
791
+ // capabilities getter
792
+ // ==========================================
793
+
794
+ it('should return undefined capabilities before connect', () => {
795
+ const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' });
796
+ expect(client.capabilities).toBeUndefined();
797
+ });
798
+
799
+ it('should expose capabilities after connect', async () => {
800
+ const caps = {
801
+ feed: true,
802
+ comments: true,
803
+ automation: false,
804
+ cron: false,
805
+ search: true,
806
+ export: false,
807
+ chunkedUpload: false,
808
+ };
809
+ const fetchMock = vi.fn().mockResolvedValue({
810
+ ok: true,
811
+ json: async () => ({
812
+ version: 'v1',
813
+ apiName: 'ObjectStack API',
814
+ capabilities: caps,
815
+ }),
816
+ });
817
+
818
+ const client = new ObjectStackClient({
819
+ baseUrl: 'http://localhost:3000',
820
+ fetch: fetchMock,
821
+ });
822
+
823
+ await client.connect();
824
+ expect(client.capabilities).toBeDefined();
825
+ expect(client.capabilities!.feed).toBe(true);
826
+ expect(client.capabilities!.automation).toBe(false);
827
+ expect(client.capabilities!.search).toBe(true);
828
+ });
829
+ });