nango 0.72.2 → 0.72.3

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.
@@ -115,6 +115,13 @@ const noFunctionChanges = {
115
115
  unchanged: [{ integrationId: 'github', name: 'fetch' }],
116
116
  deleted: []
117
117
  };
118
+ const emptyFunctionChanges = { created: [], updated: [], unchanged: [], deleted: [] };
119
+ const apiRoutes = {
120
+ legacyPreview: '/sync/deploy/confirmation',
121
+ legacyDeploy: '/sync/deploy',
122
+ functionsPreview: '/functions/deployments/bundle/preview',
123
+ functionsDeploy: '/functions/deployments/bundle'
124
+ };
118
125
  describe('deploy', () => {
119
126
  let fullPath;
120
127
  let fetchMock;
@@ -155,6 +162,10 @@ describe('deploy', () => {
155
162
  '/sync/deploy',
156
163
  '/functions/deployments/bundle'
157
164
  ]);
165
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
166
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
167
+ expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
168
+ expect(requestBody(fetchMock, 'legacyDeploy')).not.toHaveProperty('deployMode');
158
169
  const output = logOutput(logSpy);
159
170
  expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
160
171
  expect(output.match(/Summary/g)).toHaveLength(1);
@@ -170,11 +181,12 @@ describe('deploy', () => {
170
181
  expect(promptly.confirm).not.toHaveBeenCalled();
171
182
  });
172
183
  it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
173
- fetchMock
174
- .mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation))
175
- .mockResolvedValueOnce(jsonResponse(noFunctionChanges))
176
- .mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
177
- .mockResolvedValueOnce(jsonResponse(noFunctionChanges));
184
+ mockApi(fetchMock, {
185
+ legacyPreview: legacyChangeConfirmation,
186
+ legacyDeploy: legacyDeploymentResult,
187
+ functionsPreview: noFunctionChanges,
188
+ functionsDeploy: noFunctionChanges
189
+ });
178
190
  const result = await deploy({
179
191
  fullPath,
180
192
  environmentName: 'test',
@@ -182,26 +194,27 @@ describe('deploy', () => {
182
194
  options: deployOptions({ integration: 'github', version: '42' })
183
195
  });
184
196
  expect(result.isOk()).toBe(true);
185
- const bodies = fetchMock.mock.calls.map(([, request]) => JSON.parse(request.body));
186
- expect(bodies[0]).toMatchObject({ deployMode: 'integration', flowConfigs: [{ providerConfigKey: 'github', version: '42' }] });
187
- expect(bodies[1]).toMatchObject({
197
+ const legacyPreviewBody = requestBody(fetchMock, 'legacyPreview');
198
+ const functionsPreviewBody = requestBody(fetchMock, 'functionsPreview');
199
+ expect(legacyPreviewBody).toMatchObject({
200
+ reconciliationScope: { kind: 'integration', integrationId: 'github' },
201
+ flowConfigs: [{ providerConfigKey: 'github', version: '42' }]
202
+ });
203
+ expect(functionsPreviewBody).toMatchObject({
188
204
  reconciliationScope: { kind: 'integration', integrationId: 'github' },
189
205
  functions: [{ integrationId: 'github', name: 'fetch' }]
190
206
  });
191
- expect(bodies[2]).toMatchObject({ flowConfigs: bodies[0]?.['flowConfigs'] });
192
- expect(bodies[3]).toEqual(bodies[1]);
207
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ flowConfigs: legacyPreviewBody?.['flowConfigs'] });
208
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual(functionsPreviewBody);
193
209
  expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
194
210
  expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
195
211
  expect(logOutput(logSpy)).not.toContain('No changes');
196
212
  expect(logOutput(logSpy)).toContain('- github → legacy@v42');
197
213
  });
198
- it.each([
199
- { name: 'unknown integration', options: { integration: 'missing' }, message: 'No local definitions found for integration "missing"' },
200
- { name: 'conflicting selectors', options: { sync: 'contacts', action: 'legacy' }, message: 'The --sync and --action options cannot be used together' }
201
- ])('rejects $name without contacting the API', async ({ options, message }) => {
202
- const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(options) });
214
+ it('rejects conflicting selectors without contacting the API', async () => {
215
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ sync: 'contacts', action: 'legacy' }) });
203
216
  expect(result.isErr()).toBe(true);
204
- expect(logOutput(logSpy)).toContain(message);
217
+ expect(logOutput(logSpy)).toContain('The --sync and --action options cannot be used together');
205
218
  expect(fetchMock).not.toHaveBeenCalled();
206
219
  });
207
220
  it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
@@ -354,6 +367,9 @@ describe('deploy', () => {
354
367
  expect(output).toContain('✓ Deployed');
355
368
  expect(output).toContain('No destructive changes, proceeding without confirmation');
356
369
  expect(output).toContain('--sync and --action are deprecated');
370
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/sync/deploy']);
371
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'function', integrationId: 'github', name: 'legacy' } });
372
+ expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
357
373
  });
358
374
  describe('with a file', () => {
359
375
  const functionChanges = {
@@ -399,6 +415,19 @@ describe('deploy', () => {
399
415
  expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
400
416
  expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
401
417
  });
418
+ it.each([
419
+ { env: undefined, source: 'repo' },
420
+ { env: 'standalone', source: 'standalone' }
421
+ ])('sends source $source on every function when NANGO_DEPLOY_SOURCE is $env', async ({ env, source }) => {
422
+ vi.stubEnv('NANGO_DEPLOY_SOURCE', env);
423
+ fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
424
+ await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
425
+ const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
426
+ expect(bodies).toHaveLength(2);
427
+ for (const body of bodies) {
428
+ expect(body.functions.map((fn) => fn.source)).toEqual([source]);
429
+ }
430
+ });
402
431
  it('deploys a legacy action as a single script', async () => {
403
432
  fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
404
433
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
@@ -407,7 +436,7 @@ describe('deploy', () => {
407
436
  expect(output).not.toContain('--sync and --action are deprecated');
408
437
  const confirmationRequest = fetchMock.mock.calls[0]?.[1];
409
438
  const confirmationBody = JSON.parse(confirmationRequest.body);
410
- expect(confirmationBody.deployMode).toBe('single');
439
+ expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'legacy' });
411
440
  expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
412
441
  expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
413
442
  });
@@ -427,7 +456,7 @@ describe('deploy', () => {
427
456
  expect(logOutput(logSpy)).toContain('✓ Deployed');
428
457
  const confirmationRequest = fetchMock.mock.calls[0]?.[1];
429
458
  const confirmationBody = JSON.parse(confirmationRequest.body);
430
- expect(confirmationBody.deployMode).toBe('single');
459
+ expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'contacts' });
431
460
  expect(confirmationBody.flowConfigs).toEqual([
432
461
  expect.objectContaining({
433
462
  syncName: 'contacts',
@@ -437,6 +466,26 @@ describe('deploy', () => {
437
466
  })
438
467
  ]);
439
468
  });
469
+ it('deploys each match of a deprecated action selector in its own function scope', async () => {
470
+ fetchMock.mockReset();
471
+ mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
472
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ action: 'legacy' }) });
473
+ expect(result.isOk()).toBe(true);
474
+ const scopes = fetchMock.mock.calls.map(([url, init]) => {
475
+ const body = JSON.parse(init.body);
476
+ expect(body).not.toHaveProperty('deployMode');
477
+ expect(body.flowConfigs).toHaveLength(1);
478
+ const [flow] = body.flowConfigs;
479
+ expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: flow?.providerConfigKey, name: 'legacy' });
480
+ return { path: url.pathname, integrationId: flow?.providerConfigKey };
481
+ });
482
+ expect(scopes).toEqual([
483
+ { path: apiRoutes.legacyPreview, integrationId: 'github' },
484
+ { path: apiRoutes.legacyPreview, integrationId: 'slack' },
485
+ { path: apiRoutes.legacyDeploy, integrationId: 'github' },
486
+ { path: apiRoutes.legacyDeploy, integrationId: 'slack' }
487
+ ]);
488
+ });
440
489
  it("deploys only the file's integration when another integration has an action with the same name", async () => {
441
490
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
442
491
  expect(logOutput(logSpy)).toContain('✓ Deployed');
@@ -532,7 +581,7 @@ describe('deploy', () => {
532
581
  unchanged: [],
533
582
  deleted: []
534
583
  };
535
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
584
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
536
585
  await deploy({
537
586
  fullPath,
538
587
  environmentName: 'test',
@@ -542,13 +591,16 @@ describe('deploy', () => {
542
591
  const output = logOutput(logSpy);
543
592
  expect(output).toContain('↳ Functions');
544
593
  expect(output).toContain('✓ Deployed');
545
- const previewRequest = fetchMock.mock.calls[0]?.[1];
546
- const previewBody = JSON.parse(previewRequest.body);
594
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
595
+ flowConfigs: [],
596
+ onEventScriptsByProvider: [],
597
+ reconciliationScope: { kind: 'integration', integrationId: 'slack' }
598
+ });
599
+ const previewBody = requestBody(fetchMock, 'functionsPreview');
547
600
  expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
548
601
  expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
549
602
  });
550
- // TODO: Re-enable these tests when empty deployments can delete the last definition for both legacy scripts and functions.
551
- it.skip('deletes the functions of an integration when its last function was removed', async () => {
603
+ it('deletes the functions of an integration when its last function was removed', async () => {
552
604
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
553
605
  const functionChanges = {
554
606
  created: [],
@@ -556,7 +608,7 @@ describe('deploy', () => {
556
608
  unchanged: [],
557
609
  deleted: [{ integrationId: 'slack', name: 'other' }]
558
610
  };
559
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
611
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
560
612
  await deploy({
561
613
  fullPath,
562
614
  environmentName: 'test',
@@ -567,8 +619,15 @@ describe('deploy', () => {
567
619
  expect(output).toContain('- slack → other');
568
620
  expect(output).toContain('✓ Deployed');
569
621
  expect(output).toContain('Successfully removed:');
622
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'integration', integrationId: 'slack' }, functions: [] });
623
+ expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
624
+ flowConfigs: [],
625
+ onEventScriptsByProvider: [],
626
+ reconciliationScope: { kind: 'integration', integrationId: 'slack' }
627
+ });
628
+ expect(requestPaths(fetchMock)).not.toContain(apiRoutes.legacyDeploy);
570
629
  });
571
- it.skip('deletes every function of the environment when the last function was removed', async () => {
630
+ it('deletes every function of the environment when the last function was removed', async () => {
572
631
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
573
632
  const functionChanges = {
574
633
  created: [],
@@ -579,17 +638,70 @@ describe('deploy', () => {
579
638
  { integrationId: 'slack', name: 'other' }
580
639
  ]
581
640
  };
582
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
641
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
583
642
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
584
643
  const output = logOutput(logSpy);
585
644
  expect(output).toContain('- github → fetch');
586
645
  expect(output).toContain('- slack → other');
587
646
  expect(output).toContain('✓ Deployed');
588
647
  expect(output).toContain('Successfully removed:');
648
+ expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'environment' }, functions: [] });
649
+ });
650
+ it('deletes every legacy script of the environment when the last one was removed', async () => {
651
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
652
+ const changes = {
653
+ ...legacyConfirmation,
654
+ deletedActions: [{ providerConfigKey: 'github', name: 'legacy' }],
655
+ deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
656
+ };
657
+ mockApi(fetchMock, { legacyPreview: changes });
658
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
659
+ expect(result.isOk()).toBe(true);
660
+ const output = logOutput(logSpy);
661
+ expect(output).toContain('Successfully removed:');
662
+ expect(output).toContain('- github → legacy');
663
+ expect(output).toContain('- github → old-event');
664
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
665
+ flowConfigs: [],
666
+ onEventScriptsByProvider: [],
667
+ reconciliationScope: { kind: 'environment' }
668
+ });
669
+ });
670
+ it('deploys a project that only has on-event scripts', async () => {
671
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
672
+ ...functionsOnlyParsed,
673
+ integrations: functionsOnlyParsed.integrations.map((integration) => ({
674
+ ...integration,
675
+ onEventScripts: { ...integration.onEventScripts, 'post-connection-creation': ['on-create'] }
676
+ })),
677
+ functions: []
678
+ }));
679
+ await fs.mkdir(path.join(fullPath, 'github', 'on-events'), { recursive: true });
680
+ await fs.writeFile(path.join(fullPath, 'build', 'github_on-events_on-create.cjs'), 'compiled on-event');
681
+ await fs.writeFile(path.join(fullPath, 'github', 'on-events', 'on-create.ts'), 'source on-event');
682
+ mockApi(fetchMock, {
683
+ legacyPreview: {
684
+ ...legacyConfirmation,
685
+ newOnEventScripts: [{ providerConfigKey: 'github', name: 'on-create', event: 'post-connection-creation' }]
686
+ },
687
+ legacyDeploy: [{ name: 'on-create', version: '0.0.1', providerConfigKey: 'github', type: 'on-event', models: [] }]
688
+ });
689
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
690
+ expect(result.isOk()).toBe(true);
691
+ expect(logOutput(logSpy)).toContain('- github → on-create@v0.0.1');
692
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
693
+ flowConfigs: [],
694
+ onEventScriptsByProvider: [
695
+ {
696
+ providerConfigKey: 'github',
697
+ scripts: [{ name: 'on-create', event: 'post-connection-creation', fileBody: { js: 'compiled on-event', ts: 'source on-event' } }]
698
+ }
699
+ ]
700
+ });
589
701
  });
590
702
  it('warns about --version for function deployments and still deploys', async () => {
591
703
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
592
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
704
+ mockApi(fetchMock);
593
705
  const result = await deploy({
594
706
  fullPath,
595
707
  environmentName: 'test',
@@ -598,7 +710,15 @@ describe('deploy', () => {
598
710
  });
599
711
  expect(result.isOk()).toBe(true);
600
712
  expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
601
- expect(fetchMock).toHaveBeenCalledTimes(2);
713
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
714
+ });
715
+ it('only previews when no deployment has anything to deploy or delete', async () => {
716
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
717
+ mockApi(fetchMock);
718
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
719
+ expect(result.isOk()).toBe(true);
720
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview]);
721
+ expect(logOutput(logSpy)).toContain('No changes were deployed.');
602
722
  });
603
723
  it('prints only the Functions summary row for a functions-only project', async () => {
604
724
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -608,7 +728,7 @@ describe('deploy', () => {
608
728
  unchanged: [{ integrationId: 'slack', name: 'other' }],
609
729
  deleted: []
610
730
  };
611
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
731
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
612
732
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
613
733
  const output = logOutput(logSpy);
614
734
  expect(output).not.toContain('↳ Syncs');
@@ -631,11 +751,10 @@ describe('deploy', () => {
631
751
  unchanged: [],
632
752
  deleted: []
633
753
  };
634
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
754
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
635
755
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
636
756
  expect(logOutput(logSpy)).toContain('✓ Deployed');
637
- const previewRequest = fetchMock.mock.calls[0]?.[1];
638
- const previewBody = JSON.parse(previewRequest.body);
757
+ const previewBody = requestBody(fetchMock, 'functionsPreview');
639
758
  expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
640
759
  });
641
760
  it('rejects function source paths outside the project directory', async () => {
@@ -657,7 +776,7 @@ describe('deploy', () => {
657
776
  });
658
777
  it('prints only legacy summary rows when no functions bundle', async () => {
659
778
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
660
- fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
779
+ mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
661
780
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
662
781
  const output = logOutput(logSpy);
663
782
  expect(output).toContain('↳ Syncs');
@@ -670,16 +789,9 @@ describe('deploy', () => {
670
789
  { name: 'legacy', definitions: legacyOnlyParsed },
671
790
  { name: 'functions', definitions: functionsOnlyParsed },
672
791
  { name: 'mixed', definitions: parsed }
673
- ])('prints No changes when the $name deployment is unchanged', async ({ name, definitions }) => {
792
+ ])('prints No changes when the $name deployment is unchanged', async ({ definitions }) => {
674
793
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
675
- fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : legacyConfirmation));
676
- if (name === 'mixed') {
677
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
678
- }
679
- fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : []));
680
- if (name === 'mixed') {
681
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
682
- }
794
+ mockApi(fetchMock, { functionsPreview: noFunctionChanges, functionsDeploy: noFunctionChanges });
683
795
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
684
796
  const output = logOutput(logSpy);
685
797
  expect(result.isOk()).toBe(true);
@@ -692,38 +804,43 @@ describe('deploy', () => {
692
804
  });
693
805
  it('prints Function preview errors', async () => {
694
806
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
695
- fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Preview failed' } }));
807
+ mockApi(fetchMock, { functionsPreview: { error: { code: 'functions_deployment_error', message: 'Preview failed' } } });
696
808
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
697
809
  expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
698
- expect(fetchMock).toHaveBeenCalledTimes(1);
810
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
699
811
  });
700
812
  it('prints Function deploy errors', async () => {
701
813
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
702
- fetchMock
703
- .mockResolvedValueOnce(jsonResponse(noFunctionChanges))
704
- .mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
814
+ mockApi(fetchMock, { functionsDeploy: { error: { code: 'functions_deployment_error', message: 'Apply failed' } } });
705
815
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
706
816
  expect(logOutput(logSpy)).toContain('Error deploying functions');
707
817
  expect(logOutput(logSpy)).not.toContain('✓ Deployed');
708
- expect(fetchMock).toHaveBeenCalledTimes(2);
818
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
709
819
  });
710
820
  it('does not deploy legacy scripts when the preview fails', async () => {
711
821
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
712
- fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } }));
822
+ mockApi(fetchMock, { legacyPreview: { error: { code: 'server_error', message: 'Preview failed' } } });
713
823
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
714
824
  expect(result.isErr()).toBe(true);
715
- expect(fetchMock).toHaveBeenCalledTimes(1);
825
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
716
826
  expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
717
827
  expect(logOutput(logSpy)).not.toContain('✓ Deployed');
718
828
  });
829
+ it('does not deploy when the selected integration does not exist', async () => {
830
+ vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
831
+ mockApi(fetchMock, { functionsPreview: { error: { code: 'integration_not_found', message: 'Integration(s) not found: unknown' } } });
832
+ const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ integration: 'unknown' }) });
833
+ expect(result.isErr()).toBe(true);
834
+ expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
835
+ expect(logOutput(logSpy)).toContain('Integration(s) not found: unknown');
836
+ expect(logOutput(logSpy)).not.toContain('✓ Deployed');
837
+ });
719
838
  it('reports legacy deployment errors', async () => {
720
839
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
721
- fetchMock
722
- .mockResolvedValueOnce(jsonResponse(legacyConfirmation))
723
- .mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
840
+ mockApi(fetchMock, { legacyDeploy: { error: { code: 'server_error', message: 'Apply failed' } } });
724
841
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
725
842
  expect(result.isErr()).toBe(true);
726
- expect(fetchMock).toHaveBeenCalledTimes(2);
843
+ expect(fetchMock).toHaveBeenCalledTimes(3);
727
844
  expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
728
845
  expect(logOutput(logSpy)).not.toContain('✓ Deployed');
729
846
  });
@@ -732,34 +849,34 @@ describe('deploy', () => {
732
849
  { name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
733
850
  ])('requires destructive confirmation when removing $name', async ({ changes }) => {
734
851
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
735
- fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, ...changes }));
852
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, ...changes } });
736
853
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
737
854
  expect(result.isErr()).toBe(true);
738
- expect(fetchMock).toHaveBeenCalledTimes(1);
855
+ expect(fetchMock).toHaveBeenCalledTimes(2);
739
856
  expect(logOutput(logSpy)).not.toContain('✓ Deployed');
740
857
  });
741
858
  it('deploys legacy destructive changes when confirmation flags are provided', async () => {
742
859
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
743
- fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] })).mockResolvedValueOnce(jsonResponse([]));
860
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
744
861
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
745
862
  expect(result.isOk()).toBe(true);
746
- expect(fetchMock).toHaveBeenCalledTimes(2);
863
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
747
864
  expect(promptly.confirm).not.toHaveBeenCalled();
748
865
  expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
749
866
  expect(logOutput(logSpy)).toContain('✓ Deployed');
750
867
  });
751
868
  it('explains the required legacy confirmation flags when non-interactive', async () => {
752
869
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
753
- fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
870
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
754
871
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
755
872
  expect(result.isErr()).toBe(true);
756
873
  expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
757
874
  expect(promptly.confirm).not.toHaveBeenCalled();
758
- expect(fetchMock).toHaveBeenCalledTimes(1);
875
+ expect(fetchMock).toHaveBeenCalledTimes(2);
759
876
  });
760
877
  it('prints the legacy abort message when confirmation is declined', async () => {
761
878
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
762
- fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
879
+ mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
763
880
  const result = await deploy({
764
881
  fullPath,
765
882
  environmentName: 'test',
@@ -769,58 +886,57 @@ describe('deploy', () => {
769
886
  expect(result.isErr()).toBe(true);
770
887
  expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
771
888
  expect(promptly.confirm).toHaveBeenCalledTimes(1);
772
- expect(fetchMock).toHaveBeenCalledTimes(1);
889
+ expect(fetchMock).toHaveBeenCalledTimes(2);
773
890
  });
774
891
  it('preserves the legacy version override in both preview and deployment', async () => {
775
892
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
776
- fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
893
+ mockApi(fetchMock);
777
894
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
778
895
  expect(result.isOk()).toBe(true);
779
- expect(fetchMock).toHaveBeenCalledTimes(2);
780
- const previewRequest = fetchMock.mock.calls[0]?.[1];
781
- const deployRequest = fetchMock.mock.calls[1]?.[1];
782
- const previewBody = JSON.parse(previewRequest.body);
783
- const deployBody = JSON.parse(deployRequest.body);
896
+ expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
897
+ const previewBody = requestBody(fetchMock, 'legacyPreview');
784
898
  expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
785
- expect(deployBody).toMatchObject({ reconcile: true, flowConfigs: previewBody.flowConfigs });
899
+ expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconcile: true, flowConfigs: previewBody?.['flowConfigs'] });
786
900
  expect(logOutput(logSpy)).not.toContain('The --version option');
787
901
  });
788
902
  it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
789
903
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
790
- if (stage === 'deploy') {
791
- fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
792
- }
793
- fetchMock.mockResolvedValueOnce(jsonResponse({
794
- error: {
795
- code: 'invalid_body',
796
- errors: [
797
- { code: 'invalid_union', path: ['functions', 0, 'tags', 'required_scopes'], message: 'Expected a structured scope requirement' },
798
- { code: 'custom', path: [], message: 'Another validation error' }
799
- ]
904
+ mockApi(fetchMock, {
905
+ functionsPreview: noFunctionChanges,
906
+ [stage === 'preview' ? 'functionsPreview' : 'functionsDeploy']: {
907
+ error: {
908
+ code: 'invalid_body',
909
+ errors: [
910
+ { code: 'invalid_union', path: ['functions', 0, 'tags', 'required_scopes'], message: 'Expected a structured scope requirement' },
911
+ { code: 'custom', path: [], message: 'Another validation error' }
912
+ ]
913
+ }
800
914
  }
801
- }));
915
+ });
802
916
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
803
917
  const output = logOutput(logSpy);
804
918
  expect(output).toContain('invalid_body');
805
919
  expect(output).toContain('- functions.0.tags.required_scopes: Expected a structured scope requirement');
806
920
  expect(output).toContain('- Another validation error');
807
921
  expect(output).not.toContain('✓ Deployed');
808
- expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 1 : 2);
922
+ expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 2 : 3);
809
923
  });
810
924
  it('prints an aborted deployment when function deletions are not allowed', async () => {
811
925
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
812
- fetchMock.mockResolvedValueOnce(jsonResponse({
813
- created: [],
814
- updated: [],
815
- unchanged: [{ integrationId: 'github', name: 'fetch' }],
816
- deleted: [{ integrationId: 'github', name: 'removed' }]
817
- }));
926
+ mockApi(fetchMock, {
927
+ functionsPreview: {
928
+ created: [],
929
+ updated: [],
930
+ unchanged: [{ integrationId: 'github', name: 'fetch' }],
931
+ deleted: [{ integrationId: 'github', name: 'removed' }]
932
+ }
933
+ });
818
934
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
819
935
  const output = logOutput(logSpy);
820
936
  expect(output).toContain('↳ Functions');
821
937
  expect(output).toContain('Nothing was deployed.');
822
938
  expect(output).not.toContain('✓ Deployed');
823
- expect(fetchMock).toHaveBeenCalledTimes(1);
939
+ expect(fetchMock).toHaveBeenCalledTimes(2);
824
940
  });
825
941
  it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
826
942
  vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
@@ -830,7 +946,7 @@ describe('deploy', () => {
830
946
  unchanged: [{ integrationId: 'github', name: 'fetch' }],
831
947
  deleted: [{ integrationId: 'github', name: 'removed' }]
832
948
  };
833
- fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
949
+ mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
834
950
  await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
835
951
  const output = logOutput(logSpy);
836
952
  expect(output).toContain('allowDestructive flag is on');
@@ -854,7 +970,7 @@ describe('deploy', () => {
854
970
  deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
855
971
  deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
856
972
  };
857
- fetchMock.mockResolvedValueOnce(jsonResponse(changes)).mockResolvedValueOnce(jsonResponse(withDeployments ? legacyDeploymentResult : []));
973
+ mockApi(fetchMock, { legacyPreview: changes, legacyDeploy: withDeployments ? legacyDeploymentResult : [] });
858
974
  const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
859
975
  const output = logOutput(logSpy);
860
976
  expect(result.isOk()).toBe(true);
@@ -916,6 +1032,28 @@ async function writeProject(fullPath) {
916
1032
  function jsonResponse(value) {
917
1033
  return { json: vi.fn().mockResolvedValue(value) };
918
1034
  }
1035
+ function mockApi(fetchMock, responses = {}) {
1036
+ const defaults = {
1037
+ legacyPreview: legacyConfirmation,
1038
+ legacyDeploy: [],
1039
+ functionsPreview: emptyFunctionChanges,
1040
+ functionsDeploy: emptyFunctionChanges
1041
+ };
1042
+ fetchMock.mockImplementation((url) => {
1043
+ const route = Object.keys(apiRoutes).find((key) => apiRoutes[key] === url.pathname);
1044
+ if (!route) {
1045
+ return Promise.reject(new Error(`Unexpected request to ${url.pathname}`));
1046
+ }
1047
+ return Promise.resolve(jsonResponse(responses[route] ?? defaults[route]));
1048
+ });
1049
+ }
1050
+ function requestPaths(fetchMock) {
1051
+ return fetchMock.mock.calls.map(([url]) => url.pathname);
1052
+ }
1053
+ function requestBody(fetchMock, route) {
1054
+ const call = fetchMock.mock.calls.find(([url]) => url.pathname === apiRoutes[route]);
1055
+ return call ? JSON.parse(call[1].body) : undefined;
1056
+ }
919
1057
  function logOutput(logSpy) {
920
1058
  const calls = logSpy.mock.calls;
921
1059
  return calls.map((args) => args.map(String).join(' ')).join('\n');