@unito/integration-sdk 4.0.0 → 4.2.0

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.
@@ -33,11 +33,17 @@ function assertCreateItemRequestPayload(body) {
33
33
  if (typeof body !== 'object' || body === null) {
34
34
  throw new BadRequestError('Invalid CreateItemRequestPayload');
35
35
  }
36
+ if (Object.keys(body).length === 0) {
37
+ throw new BadRequestError('Empty CreateItemRequestPayload');
38
+ }
36
39
  }
37
40
  function assertUpdateItemRequestPayload(body) {
38
41
  if (typeof body !== 'object' || body === null) {
39
42
  throw new BadRequestError('Invalid UpdateItemRequestPayload');
40
43
  }
44
+ if (Object.keys(body).length === 0) {
45
+ throw new BadRequestError('Empty UpdateItemRequestPayload');
46
+ }
41
47
  }
42
48
  function assertWebhookParseRequestPayload(body) {
43
49
  if (typeof body !== 'object' || body === null) {
@@ -6,6 +6,7 @@ var crypto = require('crypto');
6
6
  var util = require('util');
7
7
  var express = require('express');
8
8
  var busboy = require('busboy');
9
+ var fs = require('fs');
9
10
  var https = require('https');
10
11
 
11
12
  function _interopNamespaceDefault(e) {
@@ -481,11 +482,17 @@ function assertCreateItemRequestPayload(body) {
481
482
  if (typeof body !== 'object' || body === null) {
482
483
  throw new BadRequestError('Invalid CreateItemRequestPayload');
483
484
  }
485
+ if (Object.keys(body).length === 0) {
486
+ throw new BadRequestError('Empty CreateItemRequestPayload');
487
+ }
484
488
  }
485
489
  function assertUpdateItemRequestPayload(body) {
486
490
  if (typeof body !== 'object' || body === null) {
487
491
  throw new BadRequestError('Invalid UpdateItemRequestPayload');
488
492
  }
493
+ if (Object.keys(body).length === 0) {
494
+ throw new BadRequestError('Empty UpdateItemRequestPayload');
495
+ }
489
496
  }
490
497
  function assertWebhookParseRequestPayload(body) {
491
498
  if (typeof body !== 'object' || body === null) {
@@ -1184,6 +1191,10 @@ class Provider {
1184
1191
  * The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
1185
1192
  */
1186
1193
  rateLimiter = undefined;
1194
+ static recordingSeq = 0;
1195
+ static get recordingPath() {
1196
+ return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1197
+ }
1187
1198
  /**
1188
1199
  * Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
1189
1200
  *
@@ -1307,9 +1318,7 @@ class Provider {
1307
1318
  }
1308
1319
  resolve({
1309
1320
  status: 201,
1310
- headers: Object.fromEntries(Object.entries(response.headers)
1311
- .filter((entry) => entry[1] !== undefined)
1312
- .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
1321
+ headers: Provider.normalizeNodeHeaders(response.headers),
1313
1322
  body,
1314
1323
  });
1315
1324
  }
@@ -1410,9 +1419,7 @@ class Provider {
1410
1419
  const body = responseBody ? JSON.parse(responseBody) : undefined;
1411
1420
  safeResolve({
1412
1421
  status: response.statusCode || 200,
1413
- headers: Object.fromEntries(Object.entries(response.headers)
1414
- .filter((entry) => entry[1] !== undefined)
1415
- .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
1422
+ headers: Provider.normalizeNodeHeaders(response.headers),
1416
1423
  body,
1417
1424
  });
1418
1425
  }
@@ -1626,6 +1633,32 @@ class Provider {
1626
1633
  },
1627
1634
  },
1628
1635
  });
1636
+ // Record raw response for schema-snapshot coverage analysis.
1637
+ // Clone the response before consuming the body so recording doesn't interfere with normal flow.
1638
+ // Only record JSON-like responses (skip binary streams and raw body requests).
1639
+ if (Provider.recordingPath &&
1640
+ !options.rawBody &&
1641
+ headers.Accept !== 'application/octet-stream' &&
1642
+ response.status < 400) {
1643
+ try {
1644
+ const cloned = response.clone();
1645
+ const recordBody = await cloned.json().catch(() => undefined);
1646
+ if (recordBody !== undefined) {
1647
+ Provider.recordingSeq++;
1648
+ const entry = JSON.stringify({
1649
+ seq: Provider.recordingSeq,
1650
+ url: absoluteUrl,
1651
+ method: options.method,
1652
+ status: response.status,
1653
+ body: recordBody,
1654
+ });
1655
+ fs.appendFileSync(Provider.recordingPath, entry + '\n');
1656
+ }
1657
+ }
1658
+ catch {
1659
+ // Recording failure should never break the integration.
1660
+ }
1661
+ }
1629
1662
  if (response.status >= 400) {
1630
1663
  const textResult = await response.text();
1631
1664
  throw this.handleError(response.status, textResult, options);
@@ -1671,6 +1704,15 @@ class Provider {
1671
1704
  };
1672
1705
  return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
1673
1706
  }
1707
+ /**
1708
+ * Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
1709
+ * into a flat Record<string, string> by joining array values and dropping undefined entries.
1710
+ */
1711
+ static normalizeNodeHeaders(headers) {
1712
+ return Object.fromEntries(Object.entries(headers)
1713
+ .filter((entry) => entry[1] !== undefined)
1714
+ .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]));
1715
+ }
1674
1716
  handleError(responseStatus, message, options) {
1675
1717
  const customError = this.customErrorHandler?.(responseStatus, message, options);
1676
1718
  return customError ?? buildHttpError(responseStatus, message);
@@ -80,6 +80,8 @@ export declare class Provider {
80
80
  * The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
81
81
  */
82
82
  protected rateLimiter: RateLimiter | undefined;
83
+ private static recordingSeq;
84
+ private static get recordingPath();
83
85
  /**
84
86
  * Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
85
87
  *
@@ -219,5 +221,10 @@ export declare class Provider {
219
221
  delete<T = undefined>(endpoint: string, options: RequestOptions, body?: RequestBody | null): Promise<Response<T>>;
220
222
  private generateAbsoluteUrl;
221
223
  private fetchWrapper;
224
+ /**
225
+ * Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
226
+ * into a flat Record<string, string> by joining array values and dropping undefined entries.
227
+ */
228
+ private static normalizeNodeHeaders;
222
229
  private handleError;
223
230
  }
@@ -1,3 +1,4 @@
1
+ import fs from 'fs';
1
2
  import https from 'https';
2
3
  import { buildHttpError } from '../errors.js';
3
4
  /**
@@ -19,6 +20,10 @@ export class Provider {
19
20
  * The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
20
21
  */
21
22
  rateLimiter = undefined;
23
+ static recordingSeq = 0;
24
+ static get recordingPath() {
25
+ return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
26
+ }
22
27
  /**
23
28
  * Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
24
29
  *
@@ -142,9 +147,7 @@ export class Provider {
142
147
  }
143
148
  resolve({
144
149
  status: 201,
145
- headers: Object.fromEntries(Object.entries(response.headers)
146
- .filter((entry) => entry[1] !== undefined)
147
- .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
150
+ headers: Provider.normalizeNodeHeaders(response.headers),
148
151
  body,
149
152
  });
150
153
  }
@@ -245,9 +248,7 @@ export class Provider {
245
248
  const body = responseBody ? JSON.parse(responseBody) : undefined;
246
249
  safeResolve({
247
250
  status: response.statusCode || 200,
248
- headers: Object.fromEntries(Object.entries(response.headers)
249
- .filter((entry) => entry[1] !== undefined)
250
- .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value])),
251
+ headers: Provider.normalizeNodeHeaders(response.headers),
251
252
  body,
252
253
  });
253
254
  }
@@ -461,6 +462,32 @@ export class Provider {
461
462
  },
462
463
  },
463
464
  });
465
+ // Record raw response for schema-snapshot coverage analysis.
466
+ // Clone the response before consuming the body so recording doesn't interfere with normal flow.
467
+ // Only record JSON-like responses (skip binary streams and raw body requests).
468
+ if (Provider.recordingPath &&
469
+ !options.rawBody &&
470
+ headers.Accept !== 'application/octet-stream' &&
471
+ response.status < 400) {
472
+ try {
473
+ const cloned = response.clone();
474
+ const recordBody = await cloned.json().catch(() => undefined);
475
+ if (recordBody !== undefined) {
476
+ Provider.recordingSeq++;
477
+ const entry = JSON.stringify({
478
+ seq: Provider.recordingSeq,
479
+ url: absoluteUrl,
480
+ method: options.method,
481
+ status: response.status,
482
+ body: recordBody,
483
+ });
484
+ fs.appendFileSync(Provider.recordingPath, entry + '\n');
485
+ }
486
+ }
487
+ catch {
488
+ // Recording failure should never break the integration.
489
+ }
490
+ }
464
491
  if (response.status >= 400) {
465
492
  const textResult = await response.text();
466
493
  throw this.handleError(response.status, textResult, options);
@@ -506,6 +533,15 @@ export class Provider {
506
533
  };
507
534
  return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
508
535
  }
536
+ /**
537
+ * Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
538
+ * into a flat Record<string, string> by joining array values and dropping undefined entries.
539
+ */
540
+ static normalizeNodeHeaders(headers) {
541
+ return Object.fromEntries(Object.entries(headers)
542
+ .filter((entry) => entry[1] !== undefined)
543
+ .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]));
544
+ }
509
545
  handleError(responseStatus, message, options) {
510
546
  const customError = this.customErrorHandler?.(responseStatus, message, options);
511
547
  return customError ?? buildHttpError(responseStatus, message);
@@ -160,11 +160,13 @@ describe('Handler', () => {
160
160
  await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
161
161
  await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
162
162
  await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
163
+ await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), BadRequestError);
163
164
  // UpdateItemRequestPayload.
164
- const updateHandler = routes[0].route.stack[0].handle;
165
+ const updateHandler = routes[1].route.stack[0].handle;
165
166
  await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
166
167
  await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
167
168
  await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
169
+ await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), BadRequestError);
168
170
  });
169
171
  });
170
172
  });
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { describe, it } from 'node:test';
3
+ import fs from 'fs';
3
4
  import { Readable } from 'stream';
4
5
  import nock from 'nock';
5
6
  import https from 'https';
@@ -1225,4 +1226,114 @@ describe('Provider', () => {
1225
1226
  // Verify the signal is indeed aborted
1226
1227
  assert.ok(abortController.signal.aborted, 'Signal should be aborted');
1227
1228
  });
1229
+ describe('response recording', () => {
1230
+ it('records JSON responses to JSONL file when env var is set', async () => {
1231
+ const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`;
1232
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1233
+ try {
1234
+ const recordingProvider = new Provider({
1235
+ prepareRequest: () => ({
1236
+ url: 'https://www.myApi.com',
1237
+ headers: { 'Content-Type': 'application/json' },
1238
+ }),
1239
+ });
1240
+ nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' });
1241
+ await recordingProvider.get('/items', {
1242
+ credentials: { unitoCredentialId: '123' },
1243
+ logger,
1244
+ });
1245
+ const content = fs.readFileSync(tmpFile, 'utf-8');
1246
+ const lines = content.trim().split('\n');
1247
+ assert.equal(lines.length, 1);
1248
+ const entry = JSON.parse(lines[0]);
1249
+ assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`);
1250
+ assert.equal(entry.url, 'https://www.myApi.com/items');
1251
+ assert.equal(entry.method, 'GET');
1252
+ assert.equal(entry.status, 200);
1253
+ assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' });
1254
+ }
1255
+ finally {
1256
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1257
+ try {
1258
+ fs.unlinkSync(tmpFile);
1259
+ }
1260
+ catch {
1261
+ /* ignore */
1262
+ }
1263
+ }
1264
+ });
1265
+ it('does not record when env var is not set', async () => {
1266
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1267
+ const noRecordProvider = new Provider({
1268
+ prepareRequest: () => ({
1269
+ url: 'https://www.myApi.com',
1270
+ headers: { 'Content-Type': 'application/json' },
1271
+ }),
1272
+ });
1273
+ nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' });
1274
+ await noRecordProvider.get('/no-record', {
1275
+ credentials: { unitoCredentialId: '123' },
1276
+ logger,
1277
+ });
1278
+ // No assertion needed — just ensure no crash.
1279
+ });
1280
+ it('does not record stream responses', async () => {
1281
+ const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`;
1282
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1283
+ try {
1284
+ const recordingProvider = new Provider({
1285
+ prepareRequest: () => ({
1286
+ url: 'https://www.myApi.com',
1287
+ headers: {},
1288
+ }),
1289
+ });
1290
+ nock('https://www.myApi.com')
1291
+ .get('/download')
1292
+ .reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' });
1293
+ await recordingProvider.streamingGet('/download', {
1294
+ credentials: { unitoCredentialId: '123' },
1295
+ logger,
1296
+ });
1297
+ assert.equal(fs.existsSync(tmpFile), false);
1298
+ }
1299
+ finally {
1300
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1301
+ try {
1302
+ fs.unlinkSync(tmpFile);
1303
+ }
1304
+ catch {
1305
+ /* ignore */
1306
+ }
1307
+ }
1308
+ });
1309
+ it('increments seq across multiple requests', async () => {
1310
+ const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`;
1311
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1312
+ try {
1313
+ const recordingProvider = new Provider({
1314
+ prepareRequest: () => ({
1315
+ url: 'https://www.myApi.com',
1316
+ headers: { 'Content-Type': 'application/json' },
1317
+ }),
1318
+ });
1319
+ nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' });
1320
+ await recordingProvider.get('/a', { credentials: { unitoCredentialId: '123' }, logger });
1321
+ await recordingProvider.get('/b', { credentials: { unitoCredentialId: '123' }, logger });
1322
+ const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n');
1323
+ assert.equal(lines.length, 2);
1324
+ const seq1 = JSON.parse(lines[0]).seq;
1325
+ const seq2 = JSON.parse(lines[1]).seq;
1326
+ assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`);
1327
+ }
1328
+ finally {
1329
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1330
+ try {
1331
+ fs.unlinkSync(tmpFile);
1332
+ }
1333
+ catch {
1334
+ /* ignore */
1335
+ }
1336
+ }
1337
+ });
1338
+ });
1228
1339
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@types/express": "5.x",
56
- "@unito/integration-api": "5.x",
56
+ "@unito/integration-api": "6.x",
57
57
  "busboy": "^1.6.0",
58
58
  "cachette": "4.x",
59
59
  "express": "^5.1",
package/src/handler.ts CHANGED
@@ -216,12 +216,18 @@ function assertCreateItemRequestPayload(body: unknown): asserts body is API.Crea
216
216
  if (typeof body !== 'object' || body === null) {
217
217
  throw new BadRequestError('Invalid CreateItemRequestPayload');
218
218
  }
219
+ if (Object.keys(body).length === 0) {
220
+ throw new BadRequestError('Empty CreateItemRequestPayload');
221
+ }
219
222
  }
220
223
 
221
224
  function assertUpdateItemRequestPayload(body: unknown): asserts body is API.UpdateItemRequestPayload {
222
225
  if (typeof body !== 'object' || body === null) {
223
226
  throw new BadRequestError('Invalid UpdateItemRequestPayload');
224
227
  }
228
+ if (Object.keys(body).length === 0) {
229
+ throw new BadRequestError('Empty UpdateItemRequestPayload');
230
+ }
225
231
  }
226
232
 
227
233
  function assertWebhookParseRequestPayload(body: unknown): asserts body is API.WebhookParseRequestPayload {
@@ -1,3 +1,4 @@
1
+ import fs from 'fs';
1
2
  import https from 'https';
2
3
  import FormData from 'form-data';
3
4
  import * as stream from 'stream';
@@ -85,6 +86,12 @@ export class Provider {
85
86
  * The Rate Limiter function to use to limit the rate of calls made to the provider based on the caller's credentials.
86
87
  */
87
88
  protected rateLimiter: RateLimiter | undefined = undefined;
89
+
90
+ private static recordingSeq = 0;
91
+ private static get recordingPath(): string | undefined {
92
+ return process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
93
+ }
94
+
88
95
  /**
89
96
  * Function called before each request to define the Provider's base URL and any specific headers to add to the requests.
90
97
  *
@@ -232,11 +239,7 @@ export class Provider {
232
239
  }
233
240
  resolve({
234
241
  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
- ),
242
+ headers: Provider.normalizeNodeHeaders(response.headers),
240
243
  body,
241
244
  });
242
245
  } catch (error) {
@@ -353,11 +356,7 @@ export class Provider {
353
356
  const body = responseBody ? JSON.parse(responseBody) : undefined;
354
357
  safeResolve({
355
358
  status: response.statusCode || 200,
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
- ),
359
+ headers: Provider.normalizeNodeHeaders(response.headers),
361
360
  body,
362
361
  });
363
362
  } catch (error) {
@@ -613,6 +612,34 @@ export class Provider {
613
612
  },
614
613
  );
615
614
 
615
+ // Record raw response for schema-snapshot coverage analysis.
616
+ // Clone the response before consuming the body so recording doesn't interfere with normal flow.
617
+ // Only record JSON-like responses (skip binary streams and raw body requests).
618
+ if (
619
+ Provider.recordingPath &&
620
+ !options.rawBody &&
621
+ headers.Accept !== 'application/octet-stream' &&
622
+ response.status < 400
623
+ ) {
624
+ try {
625
+ const cloned = response.clone();
626
+ const recordBody = await cloned.json().catch(() => undefined);
627
+ if (recordBody !== undefined) {
628
+ Provider.recordingSeq++;
629
+ const entry = JSON.stringify({
630
+ seq: Provider.recordingSeq,
631
+ url: absoluteUrl,
632
+ method: options.method,
633
+ status: response.status,
634
+ body: recordBody,
635
+ });
636
+ fs.appendFileSync(Provider.recordingPath, entry + '\n');
637
+ }
638
+ } catch {
639
+ // Recording failure should never break the integration.
640
+ }
641
+ }
642
+
616
643
  if (response.status >= 400) {
617
644
  const textResult = await response.text();
618
645
  throw this.handleError(response.status, textResult, options);
@@ -663,6 +690,18 @@ export class Provider {
663
690
  return this.rateLimiter ? this.rateLimiter(options, callToProvider) : callToProvider();
664
691
  }
665
692
 
693
+ /**
694
+ * Normalizes Node.js IncomingHttpHeaders (which may have undefined or string[] values)
695
+ * into a flat Record<string, string> by joining array values and dropping undefined entries.
696
+ */
697
+ private static normalizeNodeHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string> {
698
+ return Object.fromEntries(
699
+ Object.entries(headers)
700
+ .filter((entry): entry is [string, string | string[]] => entry[1] !== undefined)
701
+ .map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : value]),
702
+ );
703
+ }
704
+
666
705
  private handleError(responseStatus: number, message: string, options: RequestOptions): HttpErrors.HttpError {
667
706
  const customError = this.customErrorHandler?.(responseStatus, message, options);
668
707
 
@@ -201,12 +201,14 @@ describe('Handler', () => {
201
201
  await assert.doesNotReject(async () => await executeHandler(createHandler, { body: { foo: 'bar' } }));
202
202
  await assert.rejects(async () => await executeHandler(createHandler, { body: null }), BadRequestError);
203
203
  await assert.rejects(async () => await executeHandler(createHandler, { body: 'not json' }), BadRequestError);
204
+ await assert.rejects(async () => await executeHandler(createHandler, { body: {} }), BadRequestError);
204
205
 
205
206
  // UpdateItemRequestPayload.
206
- const updateHandler = routes[0]!.route!.stack[0]!.handle;
207
+ const updateHandler = routes[1]!.route!.stack[0]!.handle;
207
208
  await assert.doesNotReject(async () => await executeHandler(updateHandler, { body: { foo: 'bar' } }));
208
209
  await assert.rejects(async () => await executeHandler(updateHandler, { body: null }), BadRequestError);
209
210
  await assert.rejects(async () => await executeHandler(updateHandler, { body: 'not json' }), BadRequestError);
211
+ await assert.rejects(async () => await executeHandler(updateHandler, { body: {} }), BadRequestError);
210
212
  });
211
213
  });
212
214
  });
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { describe, it } from 'node:test';
3
+ import fs from 'fs';
3
4
  import { Readable } from 'stream';
4
5
  import nock from 'nock';
5
6
  import https from 'https';
@@ -1461,4 +1462,128 @@ describe('Provider', () => {
1461
1462
  // Verify the signal is indeed aborted
1462
1463
  assert.ok(abortController.signal.aborted, 'Signal should be aborted');
1463
1464
  });
1465
+
1466
+ describe('response recording', () => {
1467
+ it('records JSON responses to JSONL file when env var is set', async () => {
1468
+ const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`;
1469
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1470
+
1471
+ try {
1472
+ const recordingProvider = new Provider({
1473
+ prepareRequest: () => ({
1474
+ url: 'https://www.myApi.com',
1475
+ headers: { 'Content-Type': 'application/json' },
1476
+ }),
1477
+ });
1478
+
1479
+ nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' });
1480
+
1481
+ await recordingProvider.get('/items', {
1482
+ credentials: { unitoCredentialId: '123' },
1483
+ logger,
1484
+ });
1485
+
1486
+ const content = fs.readFileSync(tmpFile, 'utf-8');
1487
+ const lines = content.trim().split('\n');
1488
+ assert.equal(lines.length, 1);
1489
+
1490
+ const entry = JSON.parse(lines[0]!);
1491
+ assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`);
1492
+ assert.equal(entry.url, 'https://www.myApi.com/items');
1493
+ assert.equal(entry.method, 'GET');
1494
+ assert.equal(entry.status, 200);
1495
+ assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' });
1496
+ } finally {
1497
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1498
+ try {
1499
+ fs.unlinkSync(tmpFile);
1500
+ } catch {
1501
+ /* ignore */
1502
+ }
1503
+ }
1504
+ });
1505
+
1506
+ it('does not record when env var is not set', async () => {
1507
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1508
+
1509
+ const noRecordProvider = new Provider({
1510
+ prepareRequest: () => ({
1511
+ url: 'https://www.myApi.com',
1512
+ headers: { 'Content-Type': 'application/json' },
1513
+ }),
1514
+ });
1515
+
1516
+ nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' });
1517
+
1518
+ await noRecordProvider.get('/no-record', {
1519
+ credentials: { unitoCredentialId: '123' },
1520
+ logger,
1521
+ });
1522
+ // No assertion needed — just ensure no crash.
1523
+ });
1524
+
1525
+ it('does not record stream responses', async () => {
1526
+ const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`;
1527
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1528
+
1529
+ try {
1530
+ const recordingProvider = new Provider({
1531
+ prepareRequest: () => ({
1532
+ url: 'https://www.myApi.com',
1533
+ headers: {},
1534
+ }),
1535
+ });
1536
+
1537
+ nock('https://www.myApi.com')
1538
+ .get('/download')
1539
+ .reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' });
1540
+
1541
+ await recordingProvider.streamingGet('/download', {
1542
+ credentials: { unitoCredentialId: '123' },
1543
+ logger,
1544
+ });
1545
+
1546
+ assert.equal(fs.existsSync(tmpFile), false);
1547
+ } finally {
1548
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1549
+ try {
1550
+ fs.unlinkSync(tmpFile);
1551
+ } catch {
1552
+ /* ignore */
1553
+ }
1554
+ }
1555
+ });
1556
+
1557
+ it('increments seq across multiple requests', async () => {
1558
+ const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`;
1559
+ process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;
1560
+
1561
+ try {
1562
+ const recordingProvider = new Provider({
1563
+ prepareRequest: () => ({
1564
+ url: 'https://www.myApi.com',
1565
+ headers: { 'Content-Type': 'application/json' },
1566
+ }),
1567
+ });
1568
+
1569
+ nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' });
1570
+
1571
+ await recordingProvider.get('/a', { credentials: { unitoCredentialId: '123' }, logger });
1572
+ await recordingProvider.get('/b', { credentials: { unitoCredentialId: '123' }, logger });
1573
+
1574
+ const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n');
1575
+ assert.equal(lines.length, 2);
1576
+ const seq1 = JSON.parse(lines[0]!).seq;
1577
+ const seq2 = JSON.parse(lines[1]!).seq;
1578
+ assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`);
1579
+ } finally {
1580
+ delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
1581
+ try {
1582
+ fs.unlinkSync(tmpFile);
1583
+ } catch {
1584
+ /* ignore */
1585
+ }
1586
+ }
1587
+ });
1588
+ });
1464
1589
  });