@unito/integration-sdk 2.4.4 → 3.0.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.
@@ -42,7 +42,7 @@ describe('Provider', () => {
42
42
  it('get', async (context) => {
43
43
  const response = new Response('{"data": "value"}', {
44
44
  status: 200,
45
- headers: { 'Content-Type': 'application/json' },
45
+ headers: new Headers({ 'Content-Type': 'application/json' }),
46
46
  });
47
47
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
48
48
  const actualResponse = await provider.get('/endpoint', {
@@ -66,12 +66,16 @@ describe('Provider', () => {
66
66
  },
67
67
  },
68
68
  ]);
69
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
69
+ assert.deepEqual(actualResponse, {
70
+ status: 200,
71
+ headers: Object.fromEntries(response.headers.entries()),
72
+ body: { data: 'value' },
73
+ });
70
74
  });
71
75
  it('accepts text/html type response', async (context) => {
72
76
  const response = new Response('', {
73
77
  status: 200,
74
- headers: { 'Content-Type': 'text/html; charset=UTF-8' },
78
+ headers: new Headers({ 'Content-Type': 'text/html; charset=UTF-8' }),
75
79
  });
76
80
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
77
81
  const actualResponse = await provider.get('/endpoint', {
@@ -95,12 +99,16 @@ describe('Provider', () => {
95
99
  },
96
100
  },
97
101
  ]);
98
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: '' });
102
+ assert.deepEqual(actualResponse, {
103
+ status: 200,
104
+ headers: Object.fromEntries(response.headers.entries()),
105
+ body: '',
106
+ });
99
107
  });
100
108
  it('accepts application/schema+json type response', async (context) => {
101
109
  const response = new Response('{"data": "value"}', {
102
110
  status: 200,
103
- headers: { 'Content-Type': 'application/schema+json; charset=UTF-8' },
111
+ headers: new Headers({ 'Content-Type': 'application/schema+json; charset=UTF-8' }),
104
112
  });
105
113
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
106
114
  const actualResponse = await provider.get('/endpoint', {
@@ -124,12 +132,16 @@ describe('Provider', () => {
124
132
  },
125
133
  },
126
134
  ]);
127
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
135
+ assert.deepEqual(actualResponse, {
136
+ status: 200,
137
+ headers: Object.fromEntries(response.headers.entries()),
138
+ body: { data: 'value' },
139
+ });
128
140
  });
129
141
  it('accepts application/swagger+json type response', async (context) => {
130
142
  const response = new Response('{"data": "value"}', {
131
143
  status: 200,
132
- headers: { 'Content-Type': 'application/swagger+json; charset=UTF-8' },
144
+ headers: new Headers({ 'Content-Type': 'application/swagger+json; charset=UTF-8' }),
133
145
  });
134
146
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
135
147
  const actualResponse = await provider.get('/endpoint', {
@@ -153,12 +165,16 @@ describe('Provider', () => {
153
165
  },
154
166
  },
155
167
  ]);
156
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
168
+ assert.deepEqual(actualResponse, {
169
+ status: 200,
170
+ headers: Object.fromEntries(response.headers.entries()),
171
+ body: { data: 'value' },
172
+ });
157
173
  });
158
174
  it('accepts application/vnd.oracle.resource+json type response', async (context) => {
159
175
  const response = new Response('{"data": "value"}', {
160
176
  status: 200,
161
- headers: { 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' },
177
+ headers: new Headers({ 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' }),
162
178
  });
163
179
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
164
180
  const actualResponse = await provider.get('/endpoint', {
@@ -182,12 +198,16 @@ describe('Provider', () => {
182
198
  },
183
199
  },
184
200
  ]);
185
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
201
+ assert.deepEqual(actualResponse, {
202
+ status: 200,
203
+ headers: Object.fromEntries(response.headers.entries()),
204
+ body: { data: 'value' },
205
+ });
186
206
  });
187
207
  it('returns the raw response body if specified', async (context) => {
188
208
  const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
189
209
  status: 200,
190
- headers: { 'Content-Type': 'image/png' },
210
+ headers: new Headers({ 'Content-Type': 'image/png' }),
191
211
  });
192
212
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
193
213
  const providerResponse = await provider.streamingGet('/endpoint/123', {
@@ -206,7 +226,7 @@ describe('Provider', () => {
206
226
  it('gets an endpoint which is an absolute url', async (context) => {
207
227
  const response = new Response('{"data": "value"}', {
208
228
  status: 200,
209
- headers: { 'Content-Type': 'application/json' },
229
+ headers: new Headers({ 'Content-Type': 'application/json' }),
210
230
  });
211
231
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
212
232
  const actualResponse = await provider.get('https://my-cdn.my-domain.com/file.png', {
@@ -228,12 +248,16 @@ describe('Provider', () => {
228
248
  },
229
249
  },
230
250
  ]);
231
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
251
+ assert.deepEqual(actualResponse, {
252
+ status: 200,
253
+ headers: Object.fromEntries(response.headers.entries()),
254
+ body: { data: 'value' },
255
+ });
232
256
  });
233
257
  it('gets on provider url', async (context) => {
234
258
  const response = new Response('{"data": "value"}', {
235
259
  status: 200,
236
- headers: { 'Content-Type': 'application/json' },
260
+ headers: new Headers({ 'Content-Type': 'application/json' }),
237
261
  });
238
262
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
239
263
  const actualResponse = await provider.get('', {
@@ -255,12 +279,16 @@ describe('Provider', () => {
255
279
  },
256
280
  },
257
281
  ]);
258
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { data: 'value' } });
282
+ assert.deepEqual(actualResponse, {
283
+ status: 200,
284
+ headers: Object.fromEntries(response.headers.entries()),
285
+ body: { data: 'value' },
286
+ });
259
287
  });
260
288
  it('post with url encoded body', async (context) => {
261
289
  const response = new Response('{"data": "value"}', {
262
290
  status: 201,
263
- headers: { 'Content-Type': 'application/json' },
291
+ headers: new Headers({ 'Content-Type': 'application/json' }),
264
292
  });
265
293
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
266
294
  const actualResponse = await provider.post('/endpoint', {
@@ -287,12 +315,16 @@ describe('Provider', () => {
287
315
  },
288
316
  },
289
317
  ]);
290
- assert.deepEqual(actualResponse, { status: 201, headers: response.headers, body: { data: 'value' } });
318
+ assert.deepEqual(actualResponse, {
319
+ status: 201,
320
+ headers: Object.fromEntries(response.headers.entries()),
321
+ body: { data: 'value' },
322
+ });
291
323
  });
292
324
  it('accepts an array as body for post request', async (context) => {
293
325
  const response = new Response('{"data": "value"}', {
294
326
  status: 201,
295
- headers: { 'Content-Type': 'application/json' },
327
+ headers: new Headers({ 'Content-Type': 'application/json' }),
296
328
  });
297
329
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
298
330
  const actualResponse = await provider.post('/endpoint', [
@@ -320,12 +352,16 @@ describe('Provider', () => {
320
352
  },
321
353
  },
322
354
  ]);
323
- assert.deepEqual(actualResponse, { status: 201, headers: response.headers, body: { data: 'value' } });
355
+ assert.deepEqual(actualResponse, {
356
+ status: 201,
357
+ headers: Object.fromEntries(response.headers.entries()),
358
+ body: { data: 'value' },
359
+ });
324
360
  });
325
361
  it('put with json body', async (context) => {
326
362
  const response = new Response('{"data": "value"}', {
327
363
  status: 201,
328
- headers: { 'Content-Type': 'application/json' },
364
+ headers: new Headers({ 'Content-Type': 'application/json' }),
329
365
  });
330
366
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
331
367
  // Removing leading '/' on endpoint to make sure we support both cases
@@ -353,12 +389,16 @@ describe('Provider', () => {
353
389
  },
354
390
  },
355
391
  ]);
356
- assert.deepEqual(actualResponse, { status: 201, headers: response.headers, body: { data: 'value' } });
392
+ assert.deepEqual(actualResponse, {
393
+ status: 201,
394
+ headers: Object.fromEntries(response.headers.entries()),
395
+ body: { data: 'value' },
396
+ });
357
397
  });
358
398
  it('putBuffer with Buffer body', async (context) => {
359
399
  const response = new Response('{"data": "value"}', {
360
400
  status: 201,
361
- headers: { 'Content-Type': 'application/json' },
401
+ headers: new Headers({ 'Content-Type': 'application/json' }),
362
402
  });
363
403
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
364
404
  const buffer = Buffer.from('binary data content');
@@ -385,12 +425,16 @@ describe('Provider', () => {
385
425
  },
386
426
  },
387
427
  ]);
388
- assert.deepEqual(actualResponse, { status: 201, headers: response.headers, body: { data: 'value' } });
428
+ assert.deepEqual(actualResponse, {
429
+ status: 201,
430
+ headers: Object.fromEntries(response.headers.entries()),
431
+ body: { data: 'value' },
432
+ });
389
433
  });
390
434
  it('patch with query params', async (context) => {
391
435
  const response = new Response('{"data": "value"}', {
392
436
  status: 201,
393
- headers: { 'Content-Type': 'application/json' },
437
+ headers: new Headers({ 'Content-Type': 'application/json' }),
394
438
  });
395
439
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
396
440
  const actualResponse = await provider.patch('/endpoint/123', {
@@ -418,12 +462,16 @@ describe('Provider', () => {
418
462
  },
419
463
  },
420
464
  ]);
421
- assert.deepEqual(actualResponse, { status: 201, headers: response.headers, body: { data: 'value' } });
465
+ assert.deepEqual(actualResponse, {
466
+ status: 201,
467
+ headers: Object.fromEntries(response.headers.entries()),
468
+ body: { data: 'value' },
469
+ });
422
470
  });
423
471
  it('delete', async (context) => {
424
472
  const response = new Response(undefined, {
425
473
  status: 204,
426
- headers: { 'Content-Type': 'application/json' },
474
+ headers: new Headers({ 'Content-Type': 'application/json' }),
427
475
  });
428
476
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
429
477
  const actualResponse = await provider.delete('/endpoint/123', {
@@ -447,12 +495,16 @@ describe('Provider', () => {
447
495
  },
448
496
  },
449
497
  ]);
450
- assert.deepEqual(actualResponse, { status: 204, headers: response.headers, body: undefined });
498
+ assert.deepEqual(actualResponse, {
499
+ status: 204,
500
+ headers: Object.fromEntries(response.headers.entries()),
501
+ body: undefined,
502
+ });
451
503
  });
452
504
  it('deleteWithBody', async (context) => {
453
505
  const response = new Response('{"success": true}', {
454
506
  status: 200,
455
- headers: { 'Content-Type': 'application/json' },
507
+ headers: new Headers({ 'Content-Type': 'application/json' }),
456
508
  });
457
509
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
458
510
  const requestBody = { webhookIds: [1, 2, 3] };
@@ -478,7 +530,11 @@ describe('Provider', () => {
478
530
  },
479
531
  },
480
532
  ]);
481
- assert.deepEqual(actualResponse, { status: 200, headers: response.headers, body: { success: true } });
533
+ assert.deepEqual(actualResponse, {
534
+ status: 200,
535
+ headers: Object.fromEntries(response.headers.entries()),
536
+ body: { success: true },
537
+ });
482
538
  });
483
539
  it('uses rate limiter if provided', async (context) => {
484
540
  const mockRateLimiter = context.mock.fn((_context, request) => Promise.resolve(request()));
@@ -496,7 +552,7 @@ describe('Provider', () => {
496
552
  });
497
553
  const response = new Response(undefined, {
498
554
  status: 204,
499
- headers: { 'Content-Type': 'application/json' },
555
+ headers: new Headers({ 'Content-Type': 'application/json' }),
500
556
  });
501
557
  const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));
502
558
  const options = {
@@ -523,7 +579,11 @@ describe('Provider', () => {
523
579
  },
524
580
  },
525
581
  ]);
526
- assert.deepEqual(actualResponse, { status: 204, headers: response.headers, body: undefined });
582
+ assert.deepEqual(actualResponse, {
583
+ status: 204,
584
+ headers: Object.fromEntries(response.headers.entries()),
585
+ body: undefined,
586
+ });
527
587
  });
528
588
  it('uses custom error handler if provided', async (context) => {
529
589
  const rateLimitedProvider = new Provider({
@@ -542,7 +602,7 @@ describe('Provider', () => {
542
602
  });
543
603
  const response = new Response(undefined, {
544
604
  status: 400,
545
- headers: { 'Content-Type': 'application/json' },
605
+ headers: new Headers({ 'Content-Type': 'application/json' }),
546
606
  });
547
607
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
548
608
  const options = {
@@ -583,7 +643,7 @@ describe('Provider', () => {
583
643
  });
584
644
  const response = new Response(undefined, {
585
645
  status: 400,
586
- headers: { 'Content-Type': 'application/json' },
646
+ headers: new Headers({ 'Content-Type': 'application/json' }),
587
647
  });
588
648
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
589
649
  const options = {
@@ -619,7 +679,7 @@ describe('Provider', () => {
619
679
  });
620
680
  const response = new Response(undefined, {
621
681
  status: 404,
622
- headers: { 'Content-Type': 'application/json' },
682
+ headers: new Headers({ 'Content-Type': 'application/json' }),
623
683
  });
624
684
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
625
685
  const options = {
@@ -641,7 +701,7 @@ describe('Provider', () => {
641
701
  it('returns valid json response', async (context) => {
642
702
  const response = new Response(`{ "validJson": true }`, {
643
703
  status: 200,
644
- headers: { 'Content-Type': 'application/json;charset=utf-8' },
704
+ headers: new Headers({ 'Content-Type': 'application/json;charset=utf-8' }),
645
705
  });
646
706
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
647
707
  const providerResponse = await provider.get('/endpoint/123', {
@@ -669,7 +729,7 @@ describe('Provider', () => {
669
729
  it('returns streamable response on streaming get calls', async (context) => {
670
730
  const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
671
731
  status: 200,
672
- headers: { 'Content-Type': 'video/mp4' },
732
+ headers: new Headers({ 'Content-Type': 'video/mp4' }),
673
733
  });
674
734
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
675
735
  const providerResponse = await provider.streamingGet('/endpoint/123', {
@@ -683,7 +743,7 @@ describe('Provider', () => {
683
743
  it('returns successfully on unexpected content-type response with no body', async (context) => {
684
744
  const response = new Response(null, {
685
745
  status: 201,
686
- headers: { 'Content-Type': 'html/text' },
746
+ headers: new Headers({ 'Content-Type': 'html/text' }),
687
747
  });
688
748
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
689
749
  const providerResponse = await provider.post('/endpoint/123', {}, {
@@ -693,13 +753,13 @@ describe('Provider', () => {
693
753
  });
694
754
  assert.ok(providerResponse);
695
755
  assert.strictEqual(providerResponse.status, response.status);
696
- assert.strictEqual(providerResponse.headers, response.headers);
756
+ assert.deepEqual(providerResponse.headers, Object.fromEntries(response.headers.entries()));
697
757
  assert.strictEqual(providerResponse.body, undefined);
698
758
  });
699
759
  it('throws on invalid json response', async (context) => {
700
760
  const response = new Response('{invalidJSON}', {
701
761
  status: 200,
702
- headers: { 'Content-Type': 'application/json' },
762
+ headers: new Headers({ 'Content-Type': 'application/json' }),
703
763
  });
704
764
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
705
765
  let error;
@@ -719,7 +779,7 @@ describe('Provider', () => {
719
779
  it('throws on unexpected content-type response', async (context) => {
720
780
  const response = new Response('text', {
721
781
  status: 200,
722
- headers: { 'Content-Type': 'application/text' },
782
+ headers: new Headers({ 'Content-Type': 'application/text' }),
723
783
  });
724
784
  context.mock.method(global, 'fetch', () => Promise.resolve(response));
725
785
  let error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "2.4.4",
3
+ "version": "3.0.1",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
package/src/helpers.ts CHANGED
@@ -35,3 +35,30 @@ export function getApplicableFilters(context: { filters: Filter[] }, fields: Fie
35
35
 
36
36
  return applicableFilters;
37
37
  }
38
+
39
+ /**
40
+ * Use this helper function to build the query params for a collection next page URL.
41
+ * It re-encodes the parsed filters and selects back into query-param form,
42
+ * so integrations can easily construct `nextPage` URLs.
43
+ *
44
+ * @example
45
+ * const params = buildCollectionQueryParams({ filters: context.filters, selects: context.selects });
46
+ * params.append('cursor', nextCursor);
47
+ * return { info: { nextPage: `/items?${params.toString()}` }, data: [...] };
48
+ */
49
+ export function buildCollectionQueryParams(params?: { filters?: Filter[]; selects?: string[] }): URLSearchParams {
50
+ const result = new URLSearchParams();
51
+
52
+ if (params?.filters?.length) {
53
+ result.set(
54
+ 'filter',
55
+ params.filters.map(f => `${f.field}${f.operator}${(f.values ?? []).map(encodeURIComponent).join('|')}`).join(','),
56
+ );
57
+ }
58
+
59
+ if (params?.selects?.length) {
60
+ result.set('select', params.selects.map(encodeURIComponent).join(','));
61
+ }
62
+
63
+ return result;
64
+ }
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export type { Secrets } from './middlewares/secrets.js';
14
14
  export type { Credentials } from './middlewares/credentials.js';
15
15
  export type { Filter } from './middlewares/filters.js';
16
16
  export * as HttpErrors from './httpErrors.js';
17
- export { getApplicableFilters } from './helpers.js';
17
+ export { buildCollectionQueryParams, getApplicableFilters } from './helpers.js';
18
18
  export * from './resources/context.js';
19
19
  export { type default as Logger, NULL_LOGGER } from './resources/logger.js';
20
20
  /* c8 ignore stop */
@@ -1,7 +1,6 @@
1
1
  import https from 'https';
2
2
  import FormData from 'form-data';
3
3
  import * as stream from 'stream';
4
- import { IncomingHttpHeaders } from 'http';
5
4
 
6
5
  import { buildHttpError } from '../errors.js';
7
6
  import * as HttpErrors from '../httpErrors.js';
@@ -57,7 +56,7 @@ export type RequestOptions = {
57
56
  export type Response<T> = {
58
57
  body: T;
59
58
  status: number;
60
- headers: Headers | IncomingHttpHeaders;
59
+ headers: Record<string, string>;
61
60
  };
62
61
 
63
62
  export type PreparedRequest = {
@@ -231,7 +230,15 @@ export class Provider {
231
230
  if (body.error) {
232
231
  reject(this.handleError(400, body.error.message, options));
233
232
  }
234
- resolve({ status: 201, headers: response.headers, body });
233
+ resolve({
234
+ status: 201,
235
+ headers: Object.fromEntries(
236
+ Object.entries(response.headers)
237
+ .filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
238
+ .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
239
+ ),
240
+ body,
241
+ });
235
242
  } catch (error) {
236
243
  reject(this.handleError(500, `Failed to parse response body: "${error}"`, options));
237
244
  }
@@ -346,7 +353,11 @@ export class Provider {
346
353
  const body = responseBody ? JSON.parse(responseBody) : undefined;
347
354
  safeResolve({
348
355
  status: response.statusCode || 200,
349
- headers: response.headers,
356
+ headers: Object.fromEntries(
357
+ Object.entries(response.headers)
358
+ .filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
359
+ .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
360
+ ),
350
361
  body,
351
362
  });
352
363
  } catch (error) {
@@ -607,7 +618,11 @@ export class Provider {
607
618
  throw this.handleError(response.status, textResult, options);
608
619
  } else if (response.status === 204 || response.body === null) {
609
620
  // No content: return without inspecting the body
610
- return { status: response.status, headers: response.headers, body: undefined as unknown as T };
621
+ return {
622
+ status: response.status,
623
+ headers: Object.fromEntries(response.headers.entries()),
624
+ body: undefined as unknown as T,
625
+ };
611
626
  }
612
627
 
613
628
  const responseContentType = response.headers.get('content-type');
@@ -642,7 +657,7 @@ export class Provider {
642
657
  throw this.handleError(500, 'Unsupported Content-Type', options);
643
658
  }
644
659
 
645
- return { status: response.status, headers: response.headers, body };
660
+ return { status: response.status, headers: Object.fromEntries(response.headers.entries()), body };
646
661
  };
647
662
 
648
663
  return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
@@ -2,7 +2,7 @@ import { FieldValueTypes, OperatorTypes, Semantics } from '@unito/integration-ap
2
2
 
3
3
  import assert from 'node:assert/strict';
4
4
  import { describe, it } from 'node:test';
5
- import { getApplicableFilters } from '../src/helpers.js';
5
+ import { buildCollectionQueryParams, getApplicableFilters } from '../src/helpers.js';
6
6
 
7
7
  describe('Helpers', () => {
8
8
  describe('getApplicableFilters', () => {
@@ -72,4 +72,113 @@ describe('Helpers', () => {
72
72
  assert.deepEqual(actual, []);
73
73
  });
74
74
  });
75
+
76
+ describe('buildCollectionQueryParams', () => {
77
+ it('encodes a single filter with one value', () => {
78
+ const result = buildCollectionQueryParams({
79
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
80
+ });
81
+ assert.equal(result.get('filter'), 'status=active');
82
+ assert.equal(result.get('select'), null);
83
+ });
84
+
85
+ it('encodes filter values with special characters', () => {
86
+ const result = buildCollectionQueryParams({
87
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['foo,bar'] }],
88
+ });
89
+ assert.equal(result.get('filter'), 'status=foo%2Cbar');
90
+ });
91
+
92
+ it('encodes filter values containing pipe characters', () => {
93
+ const result = buildCollectionQueryParams({
94
+ filters: [{ field: 'tags', operator: OperatorTypes.EQUAL, values: ['a|b'] }],
95
+ });
96
+ assert.equal(result.get('filter'), 'tags=a%7Cb');
97
+ });
98
+
99
+ it('joins multiple values for one filter with |', () => {
100
+ const result = buildCollectionQueryParams({
101
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active', 'pending'] }],
102
+ });
103
+ assert.equal(result.get('filter'), 'status=active|pending');
104
+ });
105
+
106
+ it('joins multiple filters with ,', () => {
107
+ const result = buildCollectionQueryParams({
108
+ filters: [
109
+ { field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] },
110
+ { field: 'name', operator: OperatorTypes.INCLUDE, values: ['john'] },
111
+ ],
112
+ });
113
+ assert.equal(result.get('filter'), 'status=active,name*=john');
114
+ });
115
+
116
+ it('handles IS_NULL operator (no values)', () => {
117
+ const result = buildCollectionQueryParams({
118
+ filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: [] }],
119
+ });
120
+ assert.equal(result.get('filter'), 'status!!');
121
+ });
122
+
123
+ it('handles IS_NULL operator with undefined values', () => {
124
+ const result = buildCollectionQueryParams({
125
+ filters: [{ field: 'status', operator: OperatorTypes.IS_NULL, values: undefined }],
126
+ });
127
+ assert.equal(result.get('filter'), 'status!!');
128
+ });
129
+
130
+ it('handles IS_NOT_NULL operator (no values)', () => {
131
+ const result = buildCollectionQueryParams({
132
+ filters: [{ field: 'assignee', operator: OperatorTypes.IS_NOT_NULL, values: [] }],
133
+ });
134
+ assert.equal(result.get('filter'), 'assignee!');
135
+ });
136
+
137
+ it('encodes selects', () => {
138
+ const result = buildCollectionQueryParams({
139
+ selects: ['name', 'dept.id'],
140
+ });
141
+ assert.equal(result.get('select'), 'name,dept.id');
142
+ assert.equal(result.get('filter'), null);
143
+ });
144
+
145
+ it('encodes selects with special characters', () => {
146
+ const result = buildCollectionQueryParams({
147
+ selects: ['field with spaces'],
148
+ });
149
+ assert.equal(result.get('select'), 'field%20with%20spaces');
150
+ });
151
+
152
+ it('encodes both filters and selects', () => {
153
+ const result = buildCollectionQueryParams({
154
+ filters: [{ field: 'status', operator: OperatorTypes.EQUAL, values: ['active'] }],
155
+ selects: ['name', 'status'],
156
+ });
157
+ assert.equal(result.get('filter'), 'status=active');
158
+ assert.equal(result.get('select'), 'name,status');
159
+ });
160
+
161
+ it('returns empty URLSearchParams when called with no arguments', () => {
162
+ const result = buildCollectionQueryParams();
163
+ assert.equal(result.toString(), '');
164
+ });
165
+
166
+ it('omits filter key for empty filters array', () => {
167
+ const result = buildCollectionQueryParams({ filters: [] });
168
+ assert.equal(result.get('filter'), null);
169
+ });
170
+
171
+ it('omits select key for empty selects array', () => {
172
+ const result = buildCollectionQueryParams({ selects: [] });
173
+ assert.equal(result.get('select'), null);
174
+ });
175
+
176
+ it('can be extended with additional params (e.g. cursor)', () => {
177
+ const result = buildCollectionQueryParams({
178
+ selects: ['name'],
179
+ });
180
+ result.append('cursor', 'abc123');
181
+ assert.equal(result.toString(), 'select=name&cursor=abc123');
182
+ });
183
+ });
75
184
  });