datagrok-tools 6.1.8 → 6.1.10

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,298 @@
1
+ import {describe, it, expect, beforeEach} from 'vitest';
2
+ import {NodeGroupsDataSource} from '../utils/node-dapi';
3
+
4
+ interface Call {method: string; path: string; body?: any}
5
+
6
+ function makeMock(responder: (method: string, path: string, body?: any) => any) {
7
+ const calls: Call[] = [];
8
+ const client: any = {
9
+ async request(method: string, path: string, body?: any) {
10
+ calls.push({method, path, body});
11
+ return responder(method, path, body);
12
+ },
13
+ get(path: string) { return this.request('GET', path); },
14
+ post(path: string, body?: any) { return this.request('POST', path, body); },
15
+ del(path: string) { return this.request('DELETE', path); },
16
+ };
17
+ return {client, calls};
18
+ }
19
+
20
+ const PARENT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
21
+ const ALICE_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
22
+ const BOB_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
23
+
24
+ function makeParent(children: any[] = []) {
25
+ return {id: PARENT_ID, friendlyName: 'Admins', children};
26
+ }
27
+
28
+ describe('NodeGroupsDataSource.resolve', () => {
29
+ it('treats a UUID as a direct ID and calls find()', async () => {
30
+ const {client, calls} = makeMock((_m, path) => {
31
+ if (path.startsWith(`/public/v1/groups/${PARENT_ID}`)) return makeParent();
32
+ throw new Error(`unexpected ${path}`);
33
+ });
34
+ const ds = new NodeGroupsDataSource(client);
35
+ const g = await ds.resolve(PARENT_ID);
36
+ expect(g.id).toBe(PARENT_ID);
37
+ expect(calls[0].path).toBe(`/public/v1/groups/${PARENT_ID}`);
38
+ });
39
+
40
+ it('uses /lookup for non-UUID names and returns the unique match', async () => {
41
+ const {client, calls} = makeMock((_m, path) => {
42
+ if (path.startsWith('/public/v1/groups/lookup'))
43
+ return [{id: ALICE_ID, name: 'alice', friendlyName: 'alice', personal: true}];
44
+ throw new Error(`unexpected ${path}`);
45
+ });
46
+ const ds = new NodeGroupsDataSource(client);
47
+ const g = await ds.resolve('alice');
48
+ expect(g.id).toBe(ALICE_ID);
49
+ expect(calls[0].path).toBe('/public/v1/groups/lookup?query=alice');
50
+ });
51
+
52
+ it('errors with a list when lookup returns multiple matches', async () => {
53
+ const {client} = makeMock((_m, path) => {
54
+ if (path.startsWith('/public/v1/groups/lookup'))
55
+ return [
56
+ {id: 'g1', friendlyName: 'alice', personal: false},
57
+ {id: 'g2', friendlyName: 'alice', personal: true},
58
+ ];
59
+ throw new Error(`unexpected ${path}`);
60
+ });
61
+ const ds = new NodeGroupsDataSource(client);
62
+ await expect(ds.resolve('alice')).rejects.toThrow(/Multiple groups match 'alice'/);
63
+ });
64
+
65
+ it('personalOnly filters lookup results to personal groups', async () => {
66
+ const {client} = makeMock((_m, path) => {
67
+ if (path.startsWith('/public/v1/groups/lookup'))
68
+ return [
69
+ {id: 'g1', friendlyName: 'alice', personal: false},
70
+ {id: ALICE_ID, friendlyName: 'alice', personal: true},
71
+ ];
72
+ throw new Error(`unexpected ${path}`);
73
+ });
74
+ const ds = new NodeGroupsDataSource(client);
75
+ const g = await ds.resolve('alice', {personalOnly: true});
76
+ expect(g.id).toBe(ALICE_ID);
77
+ });
78
+
79
+ it('errors when personalOnly yields no matches', async () => {
80
+ const {client} = makeMock((_m, path) => {
81
+ if (path.startsWith('/public/v1/groups/lookup'))
82
+ return [{id: 'g1', friendlyName: 'alice', personal: false}];
83
+ throw new Error(`unexpected ${path}`);
84
+ });
85
+ const ds = new NodeGroupsDataSource(client);
86
+ await expect(ds.resolve('alice', {personalOnly: true})).rejects.toThrow(/No group matching 'alice' \(personal\)/);
87
+ });
88
+ });
89
+
90
+ describe('NodeGroupsDataSource.save', () => {
91
+ it('POSTs the group body to /public/v1/groups and auto-generates an id when missing', async () => {
92
+ const {client, calls} = makeMock((method, path, body) => {
93
+ if (method === 'POST' && path === '/public/v1/groups') return {...body, friendlyName: 'Chemists'};
94
+ throw new Error(`unexpected ${method} ${path}`);
95
+ });
96
+ const ds = new NodeGroupsDataSource(client);
97
+ await ds.save({friendlyName: 'Chemists'});
98
+ expect(calls[0].path).toBe('/public/v1/groups');
99
+ expect(calls[0].body.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
100
+ });
101
+
102
+ it('appends saveRelations=true when requested', async () => {
103
+ const {client, calls} = makeMock((method, path) => {
104
+ if (method === 'POST' && path === '/public/v1/groups?saveRelations=true') return {id: 'g'};
105
+ throw new Error(`unexpected ${method} ${path}`);
106
+ });
107
+ const ds = new NodeGroupsDataSource(client);
108
+ await ds.save({id: 'g', friendlyName: 'Chemists'}, true);
109
+ expect(calls[0].path).toBe('/public/v1/groups?saveRelations=true');
110
+ });
111
+ });
112
+
113
+ describe('NodeGroupsDataSource.addMembers', () => {
114
+ it('appends new relations and POSTs with saveRelations=true', async () => {
115
+ const {client, calls} = makeMock((method, path, _body) => {
116
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
117
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
118
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
119
+ if (method === 'POST' && path === '/public/v1/groups?saveRelations=true') return {id: PARENT_ID};
120
+ throw new Error(`unexpected ${method} ${path}`);
121
+ });
122
+ const ds = new NodeGroupsDataSource(client);
123
+ const results = await ds.addMembers(PARENT_ID, ['alice']);
124
+ expect(results).toEqual([{member: 'alice', status: 'added'}]);
125
+
126
+ const post = calls.find((c) => c.method === 'POST')!;
127
+ expect(post.path).toBe('/public/v1/groups?saveRelations=true');
128
+ expect(post.body.children).toEqual([{parent: {id: PARENT_ID}, child: {id: ALICE_ID}, isAdmin: false}]);
129
+ });
130
+
131
+ it('supports --admin by setting isAdmin on the new relation', async () => {
132
+ const {client, calls} = makeMock((method, path) => {
133
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
134
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
135
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
136
+ if (method === 'POST') return {id: PARENT_ID};
137
+ throw new Error(`unexpected ${method} ${path}`);
138
+ });
139
+ const ds = new NodeGroupsDataSource(client);
140
+ await ds.addMembers(PARENT_ID, ['alice'], true);
141
+ const post = calls.find((c) => c.method === 'POST')!;
142
+ expect(post.body.children[0].isAdmin).toBe(true);
143
+ });
144
+
145
+ it('is idempotent: re-adding with the same role reports noop and skips POST', async () => {
146
+ const existing = {parent: {id: PARENT_ID}, child: {id: ALICE_ID}, isAdmin: false};
147
+ const {client, calls} = makeMock((method, path) => {
148
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
149
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
150
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
151
+ if (method === 'POST') return {id: PARENT_ID};
152
+ throw new Error(`unexpected ${method} ${path}`);
153
+ });
154
+ const ds = new NodeGroupsDataSource(client);
155
+ const results = await ds.addMembers(PARENT_ID, ['alice']);
156
+ expect(results).toEqual([{member: 'alice', status: 'noop'}]);
157
+ expect(calls.find((c) => c.method === 'POST')).toBeUndefined();
158
+ });
159
+
160
+ it('flips isAdmin when re-adding an existing member with a different role', async () => {
161
+ const existing = {parent: {id: PARENT_ID}, child: {id: ALICE_ID}, isAdmin: false};
162
+ const {client, calls} = makeMock((method, path) => {
163
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
164
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
165
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
166
+ if (method === 'POST') return {id: PARENT_ID};
167
+ throw new Error(`unexpected ${method} ${path}`);
168
+ });
169
+ const ds = new NodeGroupsDataSource(client);
170
+ const results = await ds.addMembers(PARENT_ID, ['alice'], true);
171
+ expect(results).toEqual([{member: 'alice', status: 'updated'}]);
172
+ const post = calls.find((c) => c.method === 'POST')!;
173
+ expect(post.body.children).toHaveLength(1);
174
+ expect(post.body.children[0].isAdmin).toBe(true);
175
+ });
176
+
177
+ it('processes a mixed batch: one valid, one unresolvable', async () => {
178
+ const {client, calls} = makeMock((method, path) => {
179
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
180
+ if (method === 'GET' && path === '/public/v1/groups/lookup?query=alice')
181
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
182
+ if (method === 'GET' && path === '/public/v1/groups/lookup?query=nobody')
183
+ return [];
184
+ if (method === 'POST') return {id: PARENT_ID};
185
+ throw new Error(`unexpected ${method} ${path}`);
186
+ });
187
+ const ds = new NodeGroupsDataSource(client);
188
+ const results = await ds.addMembers(PARENT_ID, ['alice', 'nobody']);
189
+ expect(results[0]).toEqual({member: 'alice', status: 'added'});
190
+ expect(results[1].member).toBe('nobody');
191
+ expect(results[1].status).toBe('error');
192
+ expect(results[1].error).toMatch(/No group matching 'nobody'/);
193
+ // POST should still happen for the valid member
194
+ const post = calls.find((c) => c.method === 'POST')!;
195
+ expect(post.body.children).toHaveLength(1);
196
+ expect(post.body.children[0].child.id).toBe(ALICE_ID);
197
+ });
198
+
199
+ it('handles multiple members in a single call', async () => {
200
+ const {client, calls} = makeMock((method, path) => {
201
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
202
+ if (method === 'GET' && path === '/public/v1/groups/lookup?query=alice')
203
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
204
+ if (method === 'GET' && path === '/public/v1/groups/lookup?query=bob')
205
+ return [{id: BOB_ID, friendlyName: 'bob', personal: true}];
206
+ if (method === 'POST') return {id: PARENT_ID};
207
+ throw new Error(`unexpected ${method} ${path}`);
208
+ });
209
+ const ds = new NodeGroupsDataSource(client);
210
+ const results = await ds.addMembers(PARENT_ID, ['alice', 'bob']);
211
+ expect(results.map((r) => r.status)).toEqual(['added', 'added']);
212
+ const post = calls.find((c) => c.method === 'POST')!;
213
+ expect(post.body.children.map((c: any) => c.child.id)).toEqual([ALICE_ID, BOB_ID]);
214
+ // exactly one POST regardless of batch size
215
+ expect(calls.filter((c) => c.method === 'POST')).toHaveLength(1);
216
+ });
217
+ });
218
+
219
+ describe('NodeGroupsDataSource.removeMembers', () => {
220
+ it('filters the relation out and POSTs with saveRelations=true', async () => {
221
+ const existing = {parent: {id: PARENT_ID}, child: {id: ALICE_ID}, isAdmin: false};
222
+ const {client, calls} = makeMock((method, path) => {
223
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent([existing]);
224
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
225
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
226
+ if (method === 'POST') return {id: PARENT_ID};
227
+ throw new Error(`unexpected ${method} ${path}`);
228
+ });
229
+ const ds = new NodeGroupsDataSource(client);
230
+ const results = await ds.removeMembers(PARENT_ID, ['alice']);
231
+ expect(results).toEqual([{member: 'alice', status: 'removed'}]);
232
+ const post = calls.find((c) => c.method === 'POST')!;
233
+ expect(post.path).toBe('/public/v1/groups?saveRelations=true');
234
+ expect(post.body.children).toEqual([]);
235
+ });
236
+
237
+ it('reports not-member and skips POST when the member isn\'t in the group', async () => {
238
+ const {client, calls} = makeMock((method, path) => {
239
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
240
+ if (method === 'GET' && path.startsWith('/public/v1/groups/lookup'))
241
+ return [{id: ALICE_ID, friendlyName: 'alice', personal: true}];
242
+ if (method === 'POST') return {id: PARENT_ID};
243
+ throw new Error(`unexpected ${method} ${path}`);
244
+ });
245
+ const ds = new NodeGroupsDataSource(client);
246
+ const results = await ds.removeMembers(PARENT_ID, ['alice']);
247
+ expect(results).toEqual([{member: 'alice', status: 'not-member'}]);
248
+ expect(calls.find((c) => c.method === 'POST')).toBeUndefined();
249
+ });
250
+ });
251
+
252
+ describe('NodeGroupsDataSource.getMembers', () => {
253
+ it('calls /members without an admin query when admin is undefined', async () => {
254
+ const {client, calls} = makeMock((method, path) => {
255
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
256
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members`) return [];
257
+ throw new Error(`unexpected ${method} ${path}`);
258
+ });
259
+ const ds = new NodeGroupsDataSource(client);
260
+ await ds.getMembers(PARENT_ID);
261
+ expect(calls.map((c) => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members`);
262
+ });
263
+
264
+ it('passes admin=true', async () => {
265
+ const {client, calls} = makeMock((method, path) => {
266
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
267
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members?admin=true`) return [];
268
+ throw new Error(`unexpected ${method} ${path}`);
269
+ });
270
+ const ds = new NodeGroupsDataSource(client);
271
+ await ds.getMembers(PARENT_ID, true);
272
+ expect(calls.map((c) => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members?admin=true`);
273
+ });
274
+
275
+ it('passes admin=false', async () => {
276
+ const {client, calls} = makeMock((method, path) => {
277
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
278
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/members?admin=false`) return [];
279
+ throw new Error(`unexpected ${method} ${path}`);
280
+ });
281
+ const ds = new NodeGroupsDataSource(client);
282
+ await ds.getMembers(PARENT_ID, false);
283
+ expect(calls.map((c) => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/members?admin=false`);
284
+ });
285
+ });
286
+
287
+ describe('NodeGroupsDataSource.getMemberships', () => {
288
+ it('calls /memberships with admin flag when provided', async () => {
289
+ const {client, calls} = makeMock((method, path) => {
290
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}`) return makeParent();
291
+ if (method === 'GET' && path === `/public/v1/groups/${PARENT_ID}/memberships?admin=true`) return [];
292
+ throw new Error(`unexpected ${method} ${path}`);
293
+ });
294
+ const ds = new NodeGroupsDataSource(client);
295
+ await ds.getMemberships(PARENT_ID, true);
296
+ expect(calls.map((c) => c.path)).toContain(`/public/v1/groups/${PARENT_ID}/memberships?admin=true`);
297
+ });
298
+ });