nango 0.69.21 → 0.69.26

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.
Files changed (63) hide show
  1. package/dist/index.js +122 -20
  2. package/dist/index.js.map +1 -1
  3. package/dist/services/clone.service.d.ts +12 -0
  4. package/dist/services/clone.service.js +440 -0
  5. package/dist/services/clone.service.js.map +1 -0
  6. package/dist/services/dryrun.service.js +23 -82
  7. package/dist/services/dryrun.service.js.map +1 -1
  8. package/dist/services/ensure.service.d.ts +18 -0
  9. package/dist/services/ensure.service.js +60 -0
  10. package/dist/services/ensure.service.js.map +1 -0
  11. package/dist/services/function-create.service.d.ts +3 -4
  12. package/dist/services/function-create.service.js +2 -12
  13. package/dist/services/function-create.service.js.map +1 -1
  14. package/dist/services/interactive.service.d.ts +13 -0
  15. package/dist/services/interactive.service.js +147 -0
  16. package/dist/services/interactive.service.js.map +1 -0
  17. package/dist/services/response-collector.service.d.ts +23 -0
  18. package/dist/services/response-collector.service.js +233 -0
  19. package/dist/services/response-collector.service.js.map +1 -0
  20. package/dist/services/sdk.js +2 -1
  21. package/dist/services/sdk.js.map +1 -1
  22. package/dist/testMocks/utils.d.ts +137 -51
  23. package/dist/testMocks/utils.js +755 -172
  24. package/dist/testMocks/utils.js.map +1 -1
  25. package/dist/types.d.ts +3 -0
  26. package/dist/types.js +1 -1
  27. package/dist/types.js.map +1 -1
  28. package/dist/utils/errors.d.ts +3 -0
  29. package/dist/utils/errors.js +6 -0
  30. package/dist/utils/errors.js.map +1 -1
  31. package/dist/utils.d.ts +2 -1
  32. package/dist/utils.js +15 -0
  33. package/dist/utils.js.map +1 -1
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +11 -8
  37. package/dist/migrations/toZeroYaml.unit.test.d.ts +0 -1
  38. package/dist/migrations/toZeroYaml.unit.test.js +0 -307
  39. package/dist/migrations/toZeroYaml.unit.test.js.map +0 -1
  40. package/dist/services/compile.service.unit.test.d.ts +0 -1
  41. package/dist/services/compile.service.unit.test.js +0 -44
  42. package/dist/services/compile.service.unit.test.js.map +0 -1
  43. package/dist/services/config.service.unit.test.d.ts +0 -1
  44. package/dist/services/config.service.unit.test.js +0 -162
  45. package/dist/services/config.service.unit.test.js.map +0 -1
  46. package/dist/services/docs.service.unit.test.d.ts +0 -1
  47. package/dist/services/docs.service.unit.test.js +0 -51
  48. package/dist/services/docs.service.unit.test.js.map +0 -1
  49. package/dist/services/model.service.unit.test.d.ts +0 -1
  50. package/dist/services/model.service.unit.test.js +0 -240
  51. package/dist/services/model.service.unit.test.js.map +0 -1
  52. package/dist/services/response-saver.service.d.ts +0 -17
  53. package/dist/services/response-saver.service.js +0 -169
  54. package/dist/services/response-saver.service.js.map +0 -1
  55. package/dist/services/sdk.service.unit.test.d.ts +0 -1
  56. package/dist/services/sdk.service.unit.test.js +0 -66
  57. package/dist/services/sdk.service.unit.test.js.map +0 -1
  58. package/dist/zeroYaml/definitions.unit.test.d.ts +0 -1
  59. package/dist/zeroYaml/definitions.unit.test.js +0 -111
  60. package/dist/zeroYaml/definitions.unit.test.js.map +0 -1
  61. package/dist/zeroYaml/zodToNango.unit.test.d.ts +0 -1
  62. package/dist/zeroYaml/zodToNango.unit.test.js +0 -185
  63. package/dist/zeroYaml/zodToNango.unit.test.js.map +0 -1
@@ -1,180 +1,600 @@
1
1
  import * as crypto from 'crypto';
2
2
  import { promises as fs } from 'fs';
3
3
  import * as path from 'path';
4
+ import parseLinksHeader from 'parse-link-header';
4
5
  import { vi } from 'vitest';
5
6
  import { getProvider } from '@nangohq/providers';
6
- import { ActionError, PaginationService } from '@nangohq/runner-sdk';
7
- class NangoActionMock {
7
+ import paginateService from '@nangohq/runner-sdk/lib/paginate.service.js';
8
+ class LegacyFixtureProvider {
8
9
  dirname;
9
10
  name;
10
- Model;
11
- paginationService;
12
- nango = { secretKey: 'secret-key' };
13
- activityLogId = 'test-activity-log-id';
14
- syncId;
15
- nangoConnectionId;
16
- environmentId = 1;
17
- environmentName;
18
- syncJobId;
19
- abortSignal;
20
- syncConfig;
21
- runnerFlags = {};
22
- scriptType = 'action';
23
- startTime = Date.now();
24
- isCLI = false;
25
- connectionId;
26
- providerConfigKey;
27
- provider;
28
- ActionError = ActionError;
29
- telemetryBag = {
30
- customLogs: 0,
31
- proxyCalls: 0,
32
- durationMs: 0,
33
- memoryGb: 1
34
- };
35
- logger = { level: 'debug' };
36
- setLogger;
37
- log;
38
- getConnection;
39
- getMetadata;
40
- updateMetadata;
41
- paginate;
42
- get;
43
- post;
44
- patch;
45
- put;
46
- delete;
47
- proxy;
48
- getWebhookURL;
49
- getToken;
50
- getIntegration;
51
- setMetadata;
52
- setFieldMapping;
53
- getFieldMapping;
54
- getEnvironmentVariables;
55
- getFlowAttributes;
56
- triggerAction;
57
- triggerSync;
58
- zodValidateInput;
59
- startSync;
60
- uncontrolledFetch;
61
- tryAcquireLock;
62
- releaseLock;
63
- releaseAllLocks;
64
- constructor({ dirname, name, Model }) {
11
+ fileCache = new Map();
12
+ mockFileCache = new Map();
13
+ constructor(dirname, name) {
65
14
  this.dirname = dirname;
66
- this.nango = {
67
- secretKey: 'secret-key'
68
- };
69
- this.providerConfigKey = dirname;
70
- this.connectionId = 'test-connection-id';
71
15
  this.name = name;
72
- this.Model = Model;
73
- this.paginationService = PaginationService;
74
- this.setLogger = vi.fn();
75
- this.log = vi.fn();
76
- this.getConnection = vi.fn(this.getConnectionData.bind(this));
77
- this.getMetadata = vi.fn(this.getMetadataData.bind(this));
78
- this.paginate = vi.fn(this.getProxyPaginateData.bind(this));
79
- this.get = vi.fn(this.proxyGetData.bind(this));
80
- this.post = vi.fn(this.proxyPostData.bind(this));
81
- this.patch = vi.fn(this.proxyPatchData.bind(this));
82
- this.put = vi.fn(this.proxyPutData.bind(this));
83
- this.delete = vi.fn(this.proxyDeleteData.bind(this));
84
- this.proxy = vi.fn(this.proxyData.bind(this));
85
- this.getWebhookURL = vi.fn(() => 'https://example.com/webhook');
86
- this.updateMetadata = vi.fn();
87
- this.getToken = vi.fn();
88
- this.getIntegration = vi.fn();
89
- this.setMetadata = vi.fn();
90
- this.setFieldMapping = vi.fn();
91
- this.getFieldMapping = vi.fn();
92
- this.getEnvironmentVariables = vi.fn();
93
- this.getFlowAttributes = vi.fn();
94
- this.triggerAction = vi.fn();
95
- this.triggerSync = vi.fn();
96
- this.zodValidateInput = vi.fn();
97
- this.startSync = vi.fn();
98
- this.uncontrolledFetch = vi.fn();
99
- this.tryAcquireLock = vi.fn();
100
- this.releaseLock = vi.fn();
101
- this.releaseAllLocks = vi.fn();
102
16
  }
103
17
  async getMockFile(fileName, throwOnMissing, identity) {
104
- const filePath = path.resolve(this.dirname, `mocks/${fileName}.json`);
18
+ const filePath = path.resolve(this.dirname, `../mocks/${fileName}.json`);
105
19
  try {
106
20
  const fileContent = await fs.readFile(filePath, 'utf-8');
107
21
  const data = JSON.parse(fileContent);
108
22
  return data;
109
23
  }
110
24
  catch (err) {
111
- if (identity && identity.requestIdentity && fileName.includes(identity.requestIdentityHash)) {
112
- const normalizedHash = computeNormalizedHash(identity.requestIdentity);
113
- if (normalizedHash !== identity.requestIdentityHash) {
114
- const fallbackFileName = fileName.replace(identity.requestIdentityHash, normalizedHash);
115
- const fallbackFilePath = path.resolve(this.dirname, `mocks/${fallbackFileName}.json`);
116
- try {
117
- const fileContent = await fs.readFile(fallbackFilePath, 'utf-8');
118
- const data = JSON.parse(fileContent);
119
- return data;
120
- // eslint-disable-next-line no-empty
121
- }
122
- catch { }
123
- }
124
- }
125
25
  if (throwOnMissing) {
126
26
  throw new Error(`Failed to load mock data from ${filePath}: ${err.message} ${identity ? JSON.stringify(identity, null, 2) : ''}`);
127
27
  }
28
+ return undefined;
128
29
  }
129
30
  }
130
31
  async hashDirExists(hashDir) {
131
- const filePath = path.resolve(this.dirname, `mocks/${hashDir}/`);
32
+ const filePath = path.resolve(this.dirname, `../mocks/${hashDir}/`);
132
33
  try {
133
34
  await fs.stat(filePath);
134
35
  return true;
135
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
136
36
  }
137
- catch (err) {
37
+ catch (_) {
138
38
  return false;
139
39
  }
140
40
  }
41
+ async getFilesInDir(dir) {
42
+ if (this.fileCache.has(dir)) {
43
+ return this.fileCache.get(dir);
44
+ }
45
+ const dirPath = path.resolve(this.dirname, `../mocks/${dir}`);
46
+ try {
47
+ const entries = await fs.readdir(dirPath);
48
+ const jsonFiles = entries.filter((f) => f.endsWith('.json'));
49
+ this.fileCache.set(dir, jsonFiles);
50
+ return jsonFiles;
51
+ }
52
+ catch (_) {
53
+ this.fileCache.set(dir, []);
54
+ return [];
55
+ }
56
+ }
57
+ async parseMockFile(dir, filename) {
58
+ const cacheKey = `${dir}/${filename}`;
59
+ if (this.mockFileCache.has(cacheKey)) {
60
+ return this.mockFileCache.get(cacheKey);
61
+ }
62
+ const filePath = path.resolve(this.dirname, `../mocks/${dir}/${filename}`);
63
+ try {
64
+ const content = await fs.readFile(filePath, 'utf-8');
65
+ const parsed = JSON.parse(content);
66
+ this.mockFileCache.set(cacheKey, parsed);
67
+ return parsed;
68
+ }
69
+ catch (_) {
70
+ return null;
71
+ }
72
+ }
73
+ paramsMatch(stored, incoming) {
74
+ if (stored.length !== incoming.length) {
75
+ return false;
76
+ }
77
+ const sortedStored = [...stored].sort((a, b) => a[0].localeCompare(b[0]));
78
+ const sortedIncoming = [...incoming].sort((a, b) => a[0].localeCompare(b[0]));
79
+ for (const [index, storedEntry] of sortedStored.entries()) {
80
+ const incomingEntry = sortedIncoming[index];
81
+ if (!incomingEntry) {
82
+ return false;
83
+ }
84
+ if (storedEntry[0] !== incomingEntry[0]) {
85
+ return false;
86
+ }
87
+ if (String(storedEntry[1]) !== String(incomingEntry[1])) {
88
+ return false;
89
+ }
90
+ }
91
+ return true;
92
+ }
93
+ async findMockByParams(dir, identity) {
94
+ const files = await this.getFilesInDir(dir);
95
+ for (const filename of files) {
96
+ const mockFile = await this.parseMockFile(dir, filename);
97
+ if (!mockFile || !mockFile.requestIdentity) {
98
+ continue;
99
+ }
100
+ if (this.paramsMatch(mockFile.requestIdentity.params, identity.requestIdentity.params)) {
101
+ if (mockFile.requestIdentity.headers.length > 0 || identity.requestIdentity.headers.length > 0) {
102
+ if (!this.paramsMatch(mockFile.requestIdentity.headers, identity.requestIdentity.headers)) {
103
+ continue;
104
+ }
105
+ }
106
+ if (mockFile.requestIdentity.data !== undefined || identity.requestIdentity.data !== undefined) {
107
+ const storedDataIdentity = mockFile.requestIdentity.data;
108
+ const incomingDataIdentity = identity.requestIdentity.data;
109
+ if (String(storedDataIdentity) !== String(incomingDataIdentity)) {
110
+ continue;
111
+ }
112
+ }
113
+ return mockFile;
114
+ }
115
+ }
116
+ return null;
117
+ }
141
118
  async getCachedResponse(identity) {
142
119
  const dir = `nango/${identity.method}/proxy/${identity.endpoint}/${this.name}/`;
143
120
  const hashBasedPath = `${dir}/${identity.requestIdentityHash}`;
144
121
  if (await this.hashDirExists(dir)) {
145
- const data = await this.getMockFile(hashBasedPath, true, identity);
146
- return data;
122
+ const exactMatch = await this.getMockFile(hashBasedPath, false, identity);
123
+ if (exactMatch) {
124
+ return exactMatch;
125
+ }
126
+ // Hash didn't match - try to find by params (handles legacy pagination bug)
127
+ const paramMatch = await this.findMockByParams(dir, identity);
128
+ if (paramMatch) {
129
+ return paramMatch;
130
+ }
131
+ throw new Error(`Failed to load mock data for ${identity.method.toUpperCase()} ${identity.endpoint}\n` +
132
+ `Hash ${identity.requestIdentityHash} not found in ${dir}\n` +
133
+ `Request params: ${JSON.stringify(identity.requestIdentity.params)}\n` +
134
+ `This may be due to missing pagination mock files. Re-record with: nango dryrun <sync> <connection> --save`);
147
135
  }
148
136
  else {
149
- return {
150
- response: await this.getMockFile(`nango/${identity.method}/proxy/${identity.endpoint}/${this.name}`, true, identity)
137
+ return { response: await this.getMockFile(`nango/${identity.method}/proxy/${identity.endpoint}/${this.name}`, true, identity) };
138
+ }
139
+ }
140
+ async getBatchSaveData(modelName) {
141
+ return this.getMockFile(`${this.name}/${modelName}/batchSave`, true);
142
+ }
143
+ async getBatchDeleteData(modelName) {
144
+ return this.getMockFile(`${this.name}/${modelName}/batchDelete`, true);
145
+ }
146
+ async getInput() {
147
+ return this.getMockFile(`${this.name}/input`, false);
148
+ }
149
+ async getOutput() {
150
+ return this.getMockFile(`${this.name}/output`, true);
151
+ }
152
+ async getConnectionData() {
153
+ return this.getMockFile(`nango/getConnection`, true);
154
+ }
155
+ async getMetadataData() {
156
+ return this.getMockFile('nango/getMetadata', true);
157
+ }
158
+ async getUpdateMetadata() {
159
+ return this.getMockFile('nango/updateMetadata', false);
160
+ }
161
+ async getDeleteRecordsFromPreviousExecutions() {
162
+ return this.getMockFile('nango/deleteRecordsFromPreviousExecutions', false);
163
+ }
164
+ isUnifiedMocks() {
165
+ return false;
166
+ }
167
+ async getAllMocksForEndpoint(method, endpoint) {
168
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
169
+ const dir = `nango/${method.toLowerCase()}/proxy/${normalizedEndpoint}/${this.name}/`;
170
+ // Check for hash-based directory format (used for paginated endpoints)
171
+ if (await this.hashDirExists(dir)) {
172
+ const files = await this.getFilesInDir(dir);
173
+ const mocks = [];
174
+ for (const filename of files) {
175
+ const mockFile = await this.parseMockFile(dir, filename);
176
+ if (mockFile) {
177
+ mocks.push(mockFile);
178
+ }
179
+ }
180
+ return mocks;
181
+ }
182
+ const nameBasedPath = `nango/${method.toLowerCase()}/proxy/${normalizedEndpoint}/${this.name}`;
183
+ const response = await this.getMockFile(nameBasedPath, false);
184
+ if (response) {
185
+ const syntheticMock = {
186
+ method: method.toLowerCase(),
187
+ endpoint: normalizedEndpoint,
188
+ requestIdentityHash: '',
189
+ requestIdentity: {
190
+ method: method.toLowerCase(),
191
+ endpoint: normalizedEndpoint,
192
+ params: [],
193
+ headers: []
194
+ },
195
+ response
151
196
  };
197
+ return [syntheticMock];
198
+ }
199
+ return [];
200
+ }
201
+ }
202
+ class UnifiedFixtureProvider {
203
+ mockData;
204
+ isMigrated;
205
+ constructor(mockData) {
206
+ this.mockData = mockData;
207
+ this.isMigrated = mockData._migrated === true;
208
+ }
209
+ // eslint-disable-next-line @typescript-eslint/require-await
210
+ async getBatchSaveData(modelName) {
211
+ return this.mockData.nango?.batchSave?.[modelName];
212
+ }
213
+ // eslint-disable-next-line @typescript-eslint/require-await
214
+ async getBatchDeleteData(modelName) {
215
+ return this.mockData.nango?.batchDelete?.[modelName];
216
+ }
217
+ // eslint-disable-next-line @typescript-eslint/require-await
218
+ async getInput() {
219
+ return this.mockData.input;
220
+ }
221
+ // eslint-disable-next-line @typescript-eslint/require-await
222
+ async getOutput() {
223
+ const data = this.mockData.output;
224
+ if (data === undefined) {
225
+ throw new Error(`Missing mock data for output`);
226
+ }
227
+ return data;
228
+ }
229
+ // eslint-disable-next-line @typescript-eslint/require-await
230
+ async getConnectionData() {
231
+ const data = this.mockData.nango?.getConnection;
232
+ if (data === undefined) {
233
+ throw new Error(`Missing mock data for getConnection`);
234
+ }
235
+ return data;
236
+ }
237
+ // eslint-disable-next-line @typescript-eslint/require-await
238
+ async getMetadataData() {
239
+ const data = this.mockData.nango?.getMetadata;
240
+ if (data === undefined) {
241
+ throw new Error(`Missing mock data for getMetadata`);
152
242
  }
243
+ return data;
244
+ }
245
+ // eslint-disable-next-line @typescript-eslint/require-await
246
+ async getUpdateMetadata() {
247
+ return this.mockData.nango?.updateMetadata;
248
+ }
249
+ // eslint-disable-next-line @typescript-eslint/require-await
250
+ async getDeleteRecordsFromPreviousExecutions() {
251
+ return this.mockData.nango?.deleteRecordsFromPreviousExecutions;
252
+ }
253
+ // eslint-disable-next-line @typescript-eslint/require-await
254
+ async getCachedResponse(identity) {
255
+ const { method, endpoint, requestIdentityHash } = identity;
256
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
257
+ let apiMock = this.mockData.api?.[method.toUpperCase()]?.[normalizedEndpoint];
258
+ if (!apiMock) {
259
+ apiMock = this.mockData.api?.[method.toUpperCase()]?.[`/${normalizedEndpoint}`];
260
+ }
261
+ if (apiMock) {
262
+ // Normalize to array for consistent matching logic
263
+ const mocks = Array.isArray(apiMock) ? apiMock : [apiMock];
264
+ const matchedMock = mocks.find((mock) => {
265
+ // Try exact hash match first
266
+ if (mock.hash && mock.hash === requestIdentityHash) {
267
+ return true;
268
+ }
269
+ // Try matching by request params/headers/data
270
+ if (mock.request) {
271
+ const mockParamCount = mock.request.params ? Object.keys(mock.request.params).length : 0;
272
+ const requestParamCount = identity.requestIdentity.params.length;
273
+ // Params must match exactly (same count and same values)
274
+ if (mockParamCount !== requestParamCount) {
275
+ return false;
276
+ }
277
+ if (mock.request.params) {
278
+ for (const [key, value] of Object.entries(mock.request.params)) {
279
+ const actualParam = identity.requestIdentity.params.find(([k]) => k === key);
280
+ if (!actualParam || String(actualParam[1]) !== String(value)) {
281
+ return false;
282
+ }
283
+ }
284
+ }
285
+ const mockHeaderCount = mock.request.headers ? Object.keys(mock.request.headers).length : 0;
286
+ const requestHeaderCount = identity.requestIdentity.headers.length;
287
+ // Headers must match exactly (same count and same values)
288
+ if (mockHeaderCount !== requestHeaderCount) {
289
+ return false;
290
+ }
291
+ if (mock.request.headers) {
292
+ for (const [key, value] of Object.entries(mock.request.headers)) {
293
+ const actualHeader = identity.requestIdentity.headers.find(([k]) => k.toLowerCase() === key.toLowerCase());
294
+ if (!actualHeader || String(actualHeader[1]) !== String(value)) {
295
+ return false;
296
+ }
297
+ }
298
+ }
299
+ if (mock.request.data !== undefined) {
300
+ const expectedDataIdentity = computeDataIdentity({ data: mock.request.data });
301
+ if (expectedDataIdentity !== identity.requestIdentity.data) {
302
+ return false;
303
+ }
304
+ }
305
+ return true;
306
+ }
307
+ // For mocks without request info or hash, only match if there's exactly one mock
308
+ // and no params in the request (i.e., it's an unpaginated endpoint)
309
+ if (mocks.length === 1 && identity.requestIdentity.params.length === 0) {
310
+ return true;
311
+ }
312
+ return false;
313
+ });
314
+ if (matchedMock) {
315
+ return matchedMock;
316
+ }
317
+ }
318
+ const baseError = `No mock found for ${method.toUpperCase()} ${endpoint} (normalized: ${normalizedEndpoint}) with hash ${requestIdentityHash}`;
319
+ if (this.isMigrated) {
320
+ throw new Error(`${baseError}\n\n` +
321
+ `This mock file was migrated from the legacy format.\n` +
322
+ `The migration only captures mocks that were accessed during the test run.\n` +
323
+ `Due to a pagination bug in the legacy format, some pagination responses may not have been recorded.\n\n` +
324
+ `To fix this, re-record your mocks by running:\n` +
325
+ ` nango dryrun <sync-name> <connection-id> --save`);
326
+ }
327
+ throw new Error(`${baseError}\n\n` + `To fix this, record the missing mock by running:\n` + ` nango dryrun <sync-name> <connection-id> --save`);
328
+ }
329
+ isUnifiedMocks() {
330
+ return true;
331
+ }
332
+ }
333
+ class RecordingFixtureProvider {
334
+ delegate;
335
+ outputPath;
336
+ recordedData = {
337
+ input: null,
338
+ output: null,
339
+ nango: {},
340
+ api: {},
341
+ _migrated: true
342
+ };
343
+ constructor(delegate, outputPath) {
344
+ this.delegate = delegate;
345
+ this.outputPath = outputPath;
346
+ }
347
+ async save() {
348
+ const dataToSave = JSON.parse(JSON.stringify(this.recordedData));
349
+ // Prevent empty keys
350
+ if (Object.keys(dataToSave.nango?.batchSave || {}).length === 0)
351
+ delete dataToSave.nango?.batchSave;
352
+ if (Object.keys(dataToSave.nango?.batchDelete || {}).length === 0)
353
+ delete dataToSave.nango?.batchDelete;
354
+ if (Object.keys(dataToSave.nango || {}).length === 0)
355
+ delete dataToSave.nango;
356
+ if (Object.keys(dataToSave.api || {}).length === 0)
357
+ delete dataToSave.api;
358
+ if (dataToSave.input === null)
359
+ delete dataToSave.input;
360
+ if (dataToSave.output === null)
361
+ delete dataToSave.output;
362
+ await fs.writeFile(this.outputPath, JSON.stringify(dataToSave, null, 4));
153
363
  }
154
364
  async getBatchSaveData(modelName) {
155
- const data = await this.getMockFile(`${this.name}/${modelName}/batchSave`, true);
365
+ const data = await this.delegate.getBatchSaveData(modelName);
366
+ if (!this.recordedData.nango)
367
+ this.recordedData.nango = {};
368
+ if (!this.recordedData.nango.batchSave)
369
+ this.recordedData.nango.batchSave = {};
370
+ this.recordedData.nango.batchSave[modelName] = data;
371
+ await this.save();
156
372
  return data;
157
373
  }
158
374
  async getBatchDeleteData(modelName) {
159
- const data = await this.getMockFile(`${this.name}/${modelName}/batchDelete`, true);
375
+ const data = await this.delegate.getBatchDeleteData(modelName);
376
+ if (!this.recordedData.nango)
377
+ this.recordedData.nango = {};
378
+ if (!this.recordedData.nango.batchDelete)
379
+ this.recordedData.nango.batchDelete = {};
380
+ this.recordedData.nango.batchDelete[modelName] = data;
381
+ await this.save();
160
382
  return data;
161
383
  }
162
384
  async getInput() {
163
- const data = await this.getMockFile(`${this.name}/input`, false);
385
+ const data = await this.delegate.getInput();
386
+ this.recordedData.input = data;
387
+ await this.save();
164
388
  return data;
165
389
  }
166
390
  async getOutput() {
167
- const data = await this.getMockFile(`${this.name}/output`, true);
391
+ const data = await this.delegate.getOutput();
392
+ this.recordedData.output = data;
393
+ await this.save();
168
394
  return data;
169
395
  }
170
396
  async getConnectionData() {
171
- const data = await this.getMockFile(`nango/getConnection`, true);
397
+ const data = await this.delegate.getConnectionData();
398
+ if (!this.recordedData.nango)
399
+ this.recordedData.nango = {};
400
+ this.recordedData.nango.getConnection = data;
401
+ await this.save();
172
402
  return data;
173
403
  }
174
404
  async getMetadataData() {
175
- const data = await this.getMockFile('nango/getMetadata', true);
405
+ const data = await this.delegate.getMetadataData();
406
+ if (!this.recordedData.nango)
407
+ this.recordedData.nango = {};
408
+ this.recordedData.nango.getMetadata = data;
409
+ await this.save();
410
+ return data;
411
+ }
412
+ async getUpdateMetadata() {
413
+ const data = await this.delegate.getUpdateMetadata();
414
+ if (!this.recordedData.nango)
415
+ this.recordedData.nango = {};
416
+ this.recordedData.nango.updateMetadata = data;
417
+ await this.save();
418
+ return data;
419
+ }
420
+ async getDeleteRecordsFromPreviousExecutions() {
421
+ const data = await this.delegate.getDeleteRecordsFromPreviousExecutions();
422
+ if (!this.recordedData.nango)
423
+ this.recordedData.nango = {};
424
+ this.recordedData.nango.deleteRecordsFromPreviousExecutions = data;
425
+ await this.save();
426
+ return data;
427
+ }
428
+ async getCachedResponse(identity) {
429
+ const data = await this.delegate.getCachedResponse(identity);
430
+ const method = identity.method.toUpperCase();
431
+ const endpoint = identity.endpoint;
432
+ if (!this.recordedData.api)
433
+ this.recordedData.api = {};
434
+ if (!this.recordedData.api[method])
435
+ this.recordedData.api[method] = {};
436
+ if (!this.recordedData.api[method][endpoint])
437
+ this.recordedData.api[method][endpoint] = [];
438
+ // Fetch ALL mocks for this endpoint from the legacy provider
439
+ // This captures pagination pages that weren't accessed due to the legacy pagination bug
440
+ const allMocksForEndpoint = await this.delegate.getAllMocksForEndpoint(identity.method, endpoint);
441
+ for (const legacyMock of allMocksForEndpoint) {
442
+ const mockParams = Object.fromEntries(legacyMock.requestIdentity.params);
443
+ const mockHeaders = Object.fromEntries(legacyMock.requestIdentity.headers);
444
+ const record = {
445
+ request: {
446
+ params: Object.keys(mockParams).length > 0 ? mockParams : undefined,
447
+ headers: Object.keys(mockHeaders).length > 0 ? mockHeaders : undefined,
448
+ data: legacyMock.requestIdentity.data
449
+ },
450
+ response: legacyMock.response,
451
+ hash: legacyMock.requestIdentityHash,
452
+ ...(legacyMock.headers && { headers: legacyMock.headers }),
453
+ ...(legacyMock.status !== undefined && { status: legacyMock.status })
454
+ };
455
+ const currentMocks = this.recordedData.api[method][endpoint];
456
+ const mocksArray = Array.isArray(currentMocks) ? currentMocks : [currentMocks];
457
+ // Use requestIdentityHash from the file content (not filename) for deduplication
458
+ const exists = mocksArray.some((r) => r.hash === record.hash);
459
+ if (!exists) {
460
+ this.recordedData.api[method][endpoint].push(record);
461
+ }
462
+ }
463
+ await this.save();
176
464
  return data;
177
465
  }
466
+ isUnifiedMocks() {
467
+ return false;
468
+ }
469
+ }
470
+ async function getFixtureProvider(dirname, name) {
471
+ let testFileName = name;
472
+ // Inspect the stack trace to automatically determine the name of the test file that is currently running.
473
+ // This allows it to locate the corresponding `.test.json` mock file (e.g., `my-test.test.ts` uses `my-test.test.json`).
474
+ // The `name` parameter serves as a fallback if the test file cannot be determined from the stack trace, or its missing
475
+ try {
476
+ const stack = new Error().stack;
477
+ if (stack) {
478
+ const lines = stack.split('\n');
479
+ for (const line of lines) {
480
+ if (line.includes(dirname) && line.includes('.test.ts')) {
481
+ const match = line.match(/\/([^/]+\.test\.ts)/);
482
+ if (match && match[1]) {
483
+ testFileName = match[1].replace('.test.ts', '');
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ catch (err) {
491
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
492
+ return Promise.reject(err);
493
+ }
494
+ let unifiedMockPath = path.resolve(dirname, `${testFileName}.test.json`);
495
+ let unifiedFileExists = false;
496
+ try {
497
+ await fs.stat(unifiedMockPath);
498
+ unifiedFileExists = true;
499
+ }
500
+ catch (_) {
501
+ if (testFileName !== name) {
502
+ const fallbackPath = path.resolve(dirname, `${name}.test.json`);
503
+ try {
504
+ await fs.stat(fallbackPath);
505
+ unifiedMockPath = fallbackPath;
506
+ unifiedFileExists = true;
507
+ }
508
+ catch (_) {
509
+ unifiedFileExists = false;
510
+ }
511
+ }
512
+ }
513
+ if (unifiedFileExists) {
514
+ const fileContent = await fs.readFile(unifiedMockPath, 'utf-8');
515
+ return new UnifiedFixtureProvider(JSON.parse(fileContent));
516
+ }
517
+ // This section supports the migration of tests from the legacy mock format (multiple files per test)
518
+ // to the new unified format (a single `.test.json` file). When the `MIGRATE_MOCKS` environment variable is set,
519
+ // it uses the `RecordingFixtureProvider` to intercept calls to the old mock loader, run the test, and then
520
+ // save all the mock data that was accessed into a single new `.test.json` file.
521
+ if (process.env?.['MIGRATE_MOCKS']) {
522
+ const legacyLoader = new LegacyFixtureProvider(dirname, name);
523
+ return new RecordingFixtureProvider(legacyLoader, unifiedMockPath);
524
+ }
525
+ return new LegacyFixtureProvider(dirname, name);
526
+ }
527
+ class NangoActionMock {
528
+ dirname;
529
+ name;
530
+ Model;
531
+ providerConfigKey;
532
+ fixtureProvider;
533
+ log;
534
+ ActionError = vi.fn();
535
+ getConnection;
536
+ getMetadata;
537
+ updateMetadata;
538
+ paginate;
539
+ get;
540
+ post;
541
+ patch;
542
+ put;
543
+ delete;
544
+ proxy;
545
+ getWebhookURL;
546
+ zodValidateInput;
547
+ deleteRecordsFromPreviousExecutions;
548
+ constructor({ dirname, name, Model }) {
549
+ this.dirname = dirname;
550
+ this.providerConfigKey = path.basename(path.dirname(dirname));
551
+ this.name = name;
552
+ this.Model = Model;
553
+ this.fixtureProvider = getFixtureProvider(dirname, name);
554
+ this.log = vi.fn();
555
+ this.getConnection = vi.fn(this.getConnectionData.bind(this));
556
+ this.getMetadata = vi.fn(this.getMetadataData.bind(this));
557
+ this.updateMetadata = vi.fn(this.getUpdateMetadata.bind(this));
558
+ this.deleteRecordsFromPreviousExecutions = vi.fn(this.getDeleteRecordsFromPreviousExecutions.bind(this));
559
+ this.paginate = vi.fn(this.getProxyPaginateData.bind(this));
560
+ this.get = vi.fn(this.proxyGetData.bind(this));
561
+ this.post = vi.fn(this.proxyPostData.bind(this));
562
+ this.patch = vi.fn(this.proxyPatchData.bind(this));
563
+ this.put = vi.fn(this.proxyPutData.bind(this));
564
+ this.delete = vi.fn(this.proxyDeleteData.bind(this));
565
+ this.proxy = vi.fn(this.proxyData.bind(this));
566
+ this.getWebhookURL = vi.fn(() => 'https://example.com/webhook');
567
+ this.zodValidateInput = vi.fn(this.mockZodValidateInput.bind(this));
568
+ }
569
+ mockZodValidateInput({ input }) {
570
+ return {
571
+ data: input
572
+ };
573
+ }
574
+ async getBatchSaveData(modelName) {
575
+ return (await this.fixtureProvider).getBatchSaveData(modelName);
576
+ }
577
+ async getBatchDeleteData(modelName) {
578
+ return (await this.fixtureProvider).getBatchDeleteData(modelName);
579
+ }
580
+ async getInput() {
581
+ return (await this.fixtureProvider).getInput();
582
+ }
583
+ async getOutput() {
584
+ return (await this.fixtureProvider).getOutput();
585
+ }
586
+ async getConnectionData() {
587
+ return (await this.fixtureProvider).getConnectionData();
588
+ }
589
+ async getMetadataData() {
590
+ return (await this.fixtureProvider).getMetadataData();
591
+ }
592
+ async getUpdateMetadata() {
593
+ return (await this.fixtureProvider).getUpdateMetadata();
594
+ }
595
+ async getDeleteRecordsFromPreviousExecutions() {
596
+ return (await this.fixtureProvider).getDeleteRecordsFromPreviousExecutions();
597
+ }
178
598
  async *getProxyPaginateData(args) {
179
599
  const providerConfig = getProvider(this.providerConfigKey);
180
600
  if (!providerConfig) {
@@ -186,36 +606,213 @@ class NangoActionMock {
186
606
  ...args.paginate
187
607
  };
188
608
  const paginateInBody = ['post', 'put', 'patch'].includes(args.method.toLowerCase());
189
- const updatedBodyOrParams = paginateInBody ? args.data || {} : args.params || {};
609
+ const updatedBodyOrParams = (paginateInBody ? args.data || {} : args.params || {});
190
610
  if (args.paginate['limit']) {
191
611
  const limitParameterName = args.paginate.limit_name_in_request;
192
- if (typeof updatedBodyOrParams === 'object') {
193
- updatedBodyOrParams[limitParameterName] = args.paginate['limit'];
194
- }
195
- }
196
- const proxyFunction = async (config) => {
197
- const response = await this.proxyData(config);
198
- return {
199
- data: response.data,
200
- status: response.status,
201
- statusText: 'OK',
202
- headers: response.headers,
203
- config: {}
612
+ updatedBodyOrParams[limitParameterName] = args.paginate['limit'];
613
+ }
614
+ const fixtureProvider = await this.fixtureProvider;
615
+ // For unified mocks, use Nango's pagination implementation to be consistent on how Nango would do it on an actual run
616
+ // For legacy mocks, use the legacy (bugged) pagination implementation to avoid breaking existing tests
617
+ if (fixtureProvider.isUnifiedMocks()) {
618
+ const paginationConfig = args.paginate;
619
+ paginateService.validateConfiguration(paginationConfig);
620
+ const proxyAdapter = async (config) => {
621
+ const response = await this.proxyData(config);
622
+ return {
623
+ data: response.data,
624
+ status: response.status || 200,
625
+ statusText: 'OK',
626
+ headers: response.headers || {},
627
+ config: {}
628
+ };
204
629
  };
205
- };
206
- if (args.paginate.type === 'cursor') {
207
- yield* this.paginationService.cursor(args, args.paginate, updatedBodyOrParams, paginateInBody, proxyFunction);
630
+ switch (paginationConfig.type) {
631
+ case 'cursor':
632
+ return yield* paginateService.cursor(args, paginationConfig, updatedBodyOrParams, paginateInBody, proxyAdapter);
633
+ case 'link':
634
+ return yield* paginateService.link(args, paginationConfig, updatedBodyOrParams, paginateInBody, proxyAdapter);
635
+ case 'offset':
636
+ return yield* paginateService.offset(args, paginationConfig, updatedBodyOrParams, paginateInBody, proxyAdapter);
637
+ default:
638
+ throw new Error(`Invalid pagination type: ${paginationConfig.type}`);
639
+ }
640
+ }
641
+ // Legacy pagination implementation for tests using legacy mocks format
642
+ if (args.paginate?.type === 'cursor') {
643
+ yield* this.cursorPaginate(args, updatedBodyOrParams, paginateInBody);
208
644
  }
209
- else if (args.paginate.type === 'link') {
210
- yield* this.paginationService.link(args, args.paginate, updatedBodyOrParams, paginateInBody, proxyFunction);
645
+ else if (args.paginate?.type === 'link') {
646
+ yield* this.linkPaginate(args, updatedBodyOrParams, paginateInBody);
211
647
  }
212
- else if (args.paginate.type === 'offset') {
213
- yield* this.paginationService.offset(args, args.paginate, updatedBodyOrParams, paginateInBody, proxyFunction);
648
+ else if (args.paginate?.type === 'offset') {
649
+ yield* this.offsetPaginate(args, updatedBodyOrParams, paginateInBody);
214
650
  }
215
651
  else {
216
652
  throw new Error(`Invalid pagination type: ${args.paginate?.type}`);
217
653
  }
218
654
  }
655
+ async *cursorPaginate(args, updatedBodyOrParams, paginateInBody) {
656
+ const cursorPagination = args.paginate;
657
+ let nextCursor;
658
+ do {
659
+ if (typeof nextCursor !== 'undefined') {
660
+ updatedBodyOrParams[cursorPagination.cursor_name_in_request] = nextCursor;
661
+ }
662
+ if (paginateInBody) {
663
+ args.data = updatedBodyOrParams;
664
+ }
665
+ else {
666
+ args.params = updatedBodyOrParams;
667
+ }
668
+ const response = await this.proxyData(args);
669
+ if (!response.headers) {
670
+ const data = response.data;
671
+ const paginate = args.paginate;
672
+ if (Array.isArray(data)) {
673
+ yield data;
674
+ }
675
+ if (paginate && paginate.response_path) {
676
+ yield data[paginate.response_path];
677
+ }
678
+ else {
679
+ const keys = Object.keys(data);
680
+ for (const key of keys) {
681
+ if (Array.isArray(data[key])) {
682
+ yield data[key];
683
+ }
684
+ }
685
+ }
686
+ return;
687
+ }
688
+ const responseData = cursorPagination.response_path ? response.data[cursorPagination.response_path] : response.data;
689
+ if (!responseData || !responseData.length) {
690
+ return;
691
+ }
692
+ yield responseData;
693
+ nextCursor = response.data[cursorPagination.cursor_path_in_response];
694
+ if (typeof nextCursor === 'string') {
695
+ nextCursor = nextCursor.trim();
696
+ if (!nextCursor) {
697
+ nextCursor = undefined;
698
+ }
699
+ }
700
+ else if (typeof nextCursor !== 'number') {
701
+ nextCursor = undefined;
702
+ }
703
+ } while (typeof nextCursor !== 'undefined');
704
+ }
705
+ async *linkPaginate(args, updatedBodyOrParams, paginateInBody) {
706
+ const linkPagination = args.paginate;
707
+ if (paginateInBody) {
708
+ args.data = updatedBodyOrParams;
709
+ }
710
+ else {
711
+ args.params = updatedBodyOrParams;
712
+ }
713
+ while (true) {
714
+ const response = await this.proxyData(args);
715
+ if (!response.headers) {
716
+ const data = response.data;
717
+ const paginate = args.paginate;
718
+ if (Array.isArray(data)) {
719
+ yield data;
720
+ }
721
+ if (paginate && paginate.response_path) {
722
+ yield data[paginate.response_path];
723
+ }
724
+ else {
725
+ const keys = Object.keys(data);
726
+ for (const key of keys) {
727
+ if (Array.isArray(data[key])) {
728
+ yield data[key];
729
+ }
730
+ }
731
+ }
732
+ return;
733
+ }
734
+ const data = response.data;
735
+ const responseData = linkPagination.response_path ? data[linkPagination.response_path] : data;
736
+ if (!responseData.length) {
737
+ return;
738
+ }
739
+ yield responseData;
740
+ const nextPageLink = this.getNextPageLinkFromBodyOrHeaders(linkPagination, response, args.paginate);
741
+ if (!nextPageLink) {
742
+ return;
743
+ }
744
+ if (!isValidHttpUrl(nextPageLink)) {
745
+ args.endpoint = nextPageLink;
746
+ }
747
+ else {
748
+ const url = new URL(nextPageLink);
749
+ args.endpoint = url.pathname + url.search;
750
+ }
751
+ args.params = {};
752
+ }
753
+ }
754
+ getNextPageLinkFromBodyOrHeaders(linkPagination, response, paginationConfig) {
755
+ if (linkPagination.link_rel_in_response_header) {
756
+ const linkHeader = parseLinksHeader(response.headers['link']);
757
+ return linkHeader?.[linkPagination.link_rel_in_response_header]?.url;
758
+ }
759
+ else if (linkPagination.link_path_in_response_body) {
760
+ return get(response.data, linkPagination.link_path_in_response_body);
761
+ }
762
+ throw Error(`Either 'link_rel_in_response_header' or 'link_path_in_response_body' should be specified for '${paginationConfig.type}' pagination`);
763
+ }
764
+ async *offsetPaginate(args, updatedBodyOrParams, paginateInBody) {
765
+ const offsetPagination = args.paginate;
766
+ const offsetParameterName = offsetPagination.offset_name_in_request;
767
+ const offsetCalculationMethod = offsetPagination.offset_calculation_method || 'by-response-size';
768
+ let offset = offsetPagination.offset_start_value || 0;
769
+ while (true) {
770
+ updatedBodyOrParams[offsetParameterName] = paginateInBody ? offset : String(offset);
771
+ if (paginateInBody) {
772
+ args.data = updatedBodyOrParams;
773
+ }
774
+ else {
775
+ args.params = updatedBodyOrParams;
776
+ }
777
+ const response = await this.proxyData(args);
778
+ if (!response.headers) {
779
+ const data = response.data;
780
+ const paginate = args.paginate;
781
+ if (Array.isArray(data)) {
782
+ yield data;
783
+ }
784
+ if (paginate && paginate.response_path) {
785
+ yield data[paginate.response_path];
786
+ }
787
+ else {
788
+ const keys = Object.keys(data);
789
+ for (const key of keys) {
790
+ if (Array.isArray(data[key])) {
791
+ yield data[key];
792
+ }
793
+ }
794
+ }
795
+ return;
796
+ }
797
+ const responseData = args.paginate?.response_path ? get(response.data, args.paginate?.response_path) : response.data;
798
+ if (!responseData || !responseData.length) {
799
+ return;
800
+ }
801
+ yield responseData;
802
+ if (args.paginate?.limit && responseData.length < args.paginate?.limit) {
803
+ return;
804
+ }
805
+ if (responseData.length < 1) {
806
+ return;
807
+ }
808
+ if (offsetCalculationMethod === 'per-page') {
809
+ offset++;
810
+ }
811
+ else {
812
+ offset += responseData.length;
813
+ }
814
+ }
815
+ }
219
816
  async proxyGetData(args) {
220
817
  return this.proxyData({ ...args, method: 'get' });
221
818
  }
@@ -233,39 +830,18 @@ class NangoActionMock {
233
830
  }
234
831
  async proxyData(args) {
235
832
  const identity = computeConfigIdentity(args);
236
- const cached = await this.getCachedResponse(identity);
237
- return {
238
- data: cached.response,
239
- headers: cached.headers,
240
- status: cached.status
241
- };
833
+ const cached = await (await this.fixtureProvider).getCachedResponse(identity);
834
+ return { data: cached.response, headers: cached.headers, status: cached.status };
242
835
  }
243
836
  }
244
837
  class NangoSyncMock extends NangoActionMock {
245
- variant = 'base';
246
- track_deletes = false;
838
+ lastSyncDate = null;
247
839
  batchSave;
248
840
  batchDelete;
249
- batchUpdate;
250
- getRecordsByIds;
251
- deleteRecordsFromPreviousExecutions;
252
- setMergingStrategy;
253
- batchSend;
254
- modelFullName(model) {
255
- if (this.variant === 'base') {
256
- return model;
257
- }
258
- return `${model}::${this.variant}`;
259
- }
260
841
  constructor({ dirname, name, Model }) {
261
842
  super({ dirname, name, Model });
262
843
  this.batchSave = vi.fn();
263
844
  this.batchDelete = vi.fn();
264
- this.batchUpdate = vi.fn();
265
- this.getRecordsByIds = vi.fn();
266
- this.deleteRecordsFromPreviousExecutions = vi.fn();
267
- this.setMergingStrategy = vi.fn();
268
- this.batchSend = vi.fn();
269
845
  }
270
846
  }
271
847
  const FILTER_HEADERS = ['authorization', 'user-agent', 'nango-proxy-user-agent', 'accept-encoding', 'retries', 'host', 'connection-id', 'provider-config-key'];
@@ -295,13 +871,6 @@ function computeConfigIdentity(config) {
295
871
  function sortEntries(entries) {
296
872
  return entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
297
873
  }
298
- function computeNormalizedHash(requestIdentity) {
299
- const normalized = {
300
- ...requestIdentity,
301
- params: requestIdentity.params.map(([k, v]) => [k, String(v)])
302
- };
303
- return crypto.createHash('sha1').update(JSON.stringify(normalized)).digest('hex');
304
- }
305
874
  function computeDataIdentity(config) {
306
875
  const data = config.data;
307
876
  if (!data) {
@@ -334,5 +903,19 @@ function computeDataIdentity(config) {
334
903
  return dataString;
335
904
  }
336
905
  }
337
- export { NangoActionMock, NangoSyncMock };
906
+ function isValidHttpUrl(str) {
907
+ try {
908
+ const url = new URL(str);
909
+ return url.protocol === 'http:' || url.protocol === 'https:';
910
+ }
911
+ catch (_) {
912
+ return false;
913
+ }
914
+ }
915
+ function get(obj, path, defaultValue = undefined) {
916
+ const pathArray = path.split('.').filter(Boolean);
917
+ const result = pathArray.reduce((acc, part) => acc && acc[part], obj);
918
+ return result === undefined ? defaultValue : result;
919
+ }
920
+ export { LegacyFixtureProvider, NangoActionMock, NangoSyncMock, RecordingFixtureProvider, UnifiedFixtureProvider };
338
921
  //# sourceMappingURL=utils.js.map