ti2 1.0.123 → 1.0.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ /* globals afterAll beforeEach describe expect it jest */
2
+
3
+ jest.mock('axios', () => ({ post: jest.fn() }));
4
+ jest.mock('../../worker/queue', () => ({ removeJob: jest.fn() }));
5
+ jest.mock('../../models', () => ({
6
+ sequelize: {
7
+ transaction: jest.fn(callback => callback({ LOCK: { UPDATE: 'UPDATE' } })),
8
+ },
9
+ User: { findOne: jest.fn() },
10
+ UserAppKey: {
11
+ count: jest.fn(),
12
+ destroy: jest.fn(),
13
+ findOne: jest.fn(),
14
+ },
15
+ UserIntegrationSettings: { destroy: jest.fn() },
16
+ CronJobs: { findAll: jest.fn() },
17
+ IntegrationLifecycle: {
18
+ create: jest.fn(),
19
+ findOne: jest.fn(),
20
+ update: jest.fn(),
21
+ },
22
+ }));
23
+
24
+ const axios = require('axios');
25
+ const sqldb = require('../../models');
26
+ const { removeJob } = require('../../worker/queue');
27
+ const {
28
+ activateCatalog,
29
+ completeActivation,
30
+ completeDeletion,
31
+ deleteIntegration,
32
+ prepareActivation,
33
+ touchActivation,
34
+ } = require('../integrationLifecycle');
35
+
36
+ const originalEventsUrl = process.env.ti2_events2url_eventsURL;
37
+ const originalEventsAuthorization = process.env.ti2_events2url_authorization;
38
+
39
+ const lifecycleRecord = overrides => ({
40
+ userId: 'company-a',
41
+ integrationId: 'tourplan',
42
+ hint: 'Desk A',
43
+ generation: 1,
44
+ status: 'active',
45
+ requestId: 'request-1',
46
+ requestedAt: new Date('2026-09-14T20:00:00.000Z'),
47
+ updatedAt: new Date(),
48
+ retryCount: 0,
49
+ save: jest.fn().mockResolvedValue(),
50
+ get: jest.fn(function get() {
51
+ return {
52
+ userId: this.userId,
53
+ integrationId: this.integrationId,
54
+ hint: this.hint,
55
+ generation: this.generation,
56
+ requestId: this.requestId,
57
+ requestedAt: this.requestedAt,
58
+ retryCount: this.retryCount,
59
+ status: this.status,
60
+ artifacts: this.artifacts,
61
+ };
62
+ }),
63
+ ...overrides,
64
+ });
65
+
66
+ describe('integrationLifecycle', () => {
67
+ beforeEach(() => {
68
+ jest.clearAllMocks();
69
+ process.env.ti2_events2url_eventsURL = 'http://filematch.test/ti2events';
70
+ process.env.ti2_events2url_authorization = 'Bearer service-token';
71
+ sqldb.User.findOne.mockResolvedValue({ userId: 'company-a' });
72
+ sqldb.UserAppKey.destroy.mockResolvedValue(1);
73
+ sqldb.UserAppKey.findOne.mockResolvedValue({ id: 1 });
74
+ sqldb.UserAppKey.count.mockResolvedValue(0);
75
+ sqldb.UserIntegrationSettings.destroy.mockResolvedValue(1);
76
+ sqldb.CronJobs.findAll.mockResolvedValue([]);
77
+ sqldb.IntegrationLifecycle.update.mockResolvedValue([1]);
78
+ axios.post.mockResolvedValue({ data: { status: 'deleted' } });
79
+ });
80
+
81
+ afterAll(() => {
82
+ if (originalEventsUrl === undefined) {
83
+ delete process.env.ti2_events2url_eventsURL;
84
+ } else {
85
+ process.env.ti2_events2url_eventsURL = originalEventsUrl;
86
+ }
87
+ if (originalEventsAuthorization === undefined) {
88
+ delete process.env.ti2_events2url_authorization;
89
+ } else {
90
+ process.env.ti2_events2url_authorization = originalEventsAuthorization;
91
+ }
92
+ });
93
+
94
+ it('serializes a re-add behind a failed or active deletion', async () => {
95
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord({ status: 'failed' }));
96
+
97
+ await expect(prepareActivation({
98
+ userId: 'company-a',
99
+ integrationId: 'tourplan',
100
+ hint: 'Desk A',
101
+ transaction: { LOCK: { UPDATE: 'UPDATE' } },
102
+ })).rejects.toMatchObject({ status: 409 });
103
+ });
104
+
105
+ it('assigns a new generation before reactivating a removed integration', async () => {
106
+ const lifecycle = lifecycleRecord({ status: 'complete', generation: 4 });
107
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
108
+
109
+ const result = await prepareActivation({
110
+ userId: 'company-a',
111
+ integrationId: 'tourplan',
112
+ hint: 'Desk A',
113
+ transaction: { LOCK: { UPDATE: 'UPDATE' } },
114
+ });
115
+
116
+ expect(result.generation).toBe(5);
117
+ expect(result.status).toBe('provisioning');
118
+ expect(result.artifacts).toEqual({ requiresCatalogActivation: true });
119
+ expect(lifecycle.save).toHaveBeenCalled();
120
+ });
121
+
122
+ it('cleans exact schedules, credentials, final-hint settings, and catalog state', async () => {
123
+ const lifecycle = lifecycleRecord();
124
+ const schedule = { bullJobId: 'repeat:exact:123', destroy: jest.fn().mockResolvedValue() };
125
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
126
+ sqldb.CronJobs.findAll
127
+ .mockResolvedValueOnce([schedule])
128
+ .mockResolvedValueOnce([schedule]);
129
+
130
+ const result = await deleteIntegration({
131
+ userId: 'company-a',
132
+ integrationId: 'tourplan',
133
+ hint: 'Desk A',
134
+ requestedBy: 'user-a',
135
+ deferCompletion: true,
136
+ });
137
+
138
+ expect(sqldb.CronJobs.findAll).toHaveBeenCalledWith({
139
+ where: {
140
+ userId: 'company-a',
141
+ hint: 'Desk A',
142
+ pluginName: 'tourplan',
143
+ },
144
+ transaction: expect.any(Object),
145
+ lock: 'UPDATE',
146
+ });
147
+ expect(removeJob).toHaveBeenCalledWith('repeat:exact:123');
148
+ expect(schedule.destroy).toHaveBeenCalled();
149
+ expect(sqldb.UserAppKey.destroy).toHaveBeenCalledWith(expect.objectContaining({
150
+ where: {
151
+ userId: 'company-a',
152
+ integrationId: 'tourplan',
153
+ hint: 'Desk A',
154
+ },
155
+ }));
156
+ expect(sqldb.UserIntegrationSettings.destroy).toHaveBeenCalledWith(expect.objectContaining({
157
+ where: { userId: 'company-a', integrationId: 'tourplan' },
158
+ }));
159
+ expect(axios.post).toHaveBeenCalledWith(
160
+ 'http://filematch.test/productSync/integration-lifecycle',
161
+ {
162
+ action: 'delete',
163
+ companyId: 'company-a',
164
+ integrationId: 'tourplan',
165
+ hint: 'Desk A',
166
+ generation: 2,
167
+ },
168
+ expect.objectContaining({
169
+ headers: { Authorization: 'Bearer service-token' },
170
+ timeout: expect.any(Number),
171
+ }),
172
+ );
173
+ expect(result).toEqual(expect.objectContaining({
174
+ status: 'external_cleanup',
175
+ generation: 2,
176
+ requestId: expect.any(String),
177
+ retryCount: 0,
178
+ }));
179
+ });
180
+
181
+ it('blocks deletion while integration schedule provisioning is in progress', async () => {
182
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord({
183
+ status: 'provisioning',
184
+ }));
185
+
186
+ await expect(deleteIntegration({
187
+ userId: 'company-a',
188
+ integrationId: 'tourplan',
189
+ hint: 'Desk A',
190
+ requestedBy: 'user-a',
191
+ })).rejects.toMatchObject({ status: 409 });
192
+ });
193
+
194
+ it('recovers stale provisioning by advancing the deletion generation', async () => {
195
+ const lifecycle = lifecycleRecord({
196
+ status: 'provisioning',
197
+ generation: 3,
198
+ updatedAt: new Date(Date.now() - 10 * 60e3),
199
+ });
200
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
201
+
202
+ const result = await deleteIntegration({
203
+ userId: 'company-a',
204
+ integrationId: 'tourplan',
205
+ hint: 'Desk A',
206
+ requestedBy: 'user-a',
207
+ });
208
+
209
+ expect(result).toEqual(expect.objectContaining({ generation: 4, status: 'complete' }));
210
+ expect(axios.post).toHaveBeenCalledWith(
211
+ expect.any(String),
212
+ expect.objectContaining({ action: 'delete', generation: 4 }),
213
+ expect.any(Object),
214
+ );
215
+ });
216
+
217
+ it('returns 404 without creating lifecycle or catalog work for an unknown hint', async () => {
218
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(null);
219
+ sqldb.UserAppKey.findOne.mockResolvedValue(null);
220
+
221
+ await expect(deleteIntegration({
222
+ userId: 'company-a',
223
+ integrationId: 'tourplan',
224
+ hint: 'Unknown desk',
225
+ requestedBy: 'user-a',
226
+ })).rejects.toMatchObject({ status: 404 });
227
+
228
+ expect(sqldb.IntegrationLifecycle.create).not.toHaveBeenCalled();
229
+ expect(axios.post).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it.each([
233
+ ['superseded', 409],
234
+ ['retry', 503],
235
+ ['unexpected', 502],
236
+ ])('maps catalog activation status %s to HTTP %i', async (status, expectedStatus) => {
237
+ axios.post.mockResolvedValueOnce({ data: { status } });
238
+
239
+ await expect(activateCatalog(lifecycleRecord()))
240
+ .rejects.toMatchObject({ status: expectedStatus });
241
+ });
242
+
243
+ it('maps the catalog retry HTTP response to a retryable activation error', async () => {
244
+ axios.post.mockRejectedValueOnce({
245
+ response: { status: 409, data: { status: 'retry', generation: 1 } },
246
+ });
247
+
248
+ await expect(activateCatalog(lifecycleRecord()))
249
+ .rejects.toMatchObject({ status: 503 });
250
+ });
251
+
252
+ it('refuses to complete an activation after its generation loses ownership', async () => {
253
+ sqldb.IntegrationLifecycle.update.mockResolvedValueOnce([0]);
254
+
255
+ await expect(completeActivation(lifecycleRecord({ status: 'provisioning' })))
256
+ .rejects.toMatchObject({
257
+ status: 409,
258
+ message: 'Integration save was superseded by a newer lifecycle.',
259
+ });
260
+ });
261
+
262
+ it('accepts an unchanged heartbeat timestamp while the fenced activation still exists', async () => {
263
+ sqldb.IntegrationLifecycle.update.mockResolvedValueOnce([0]);
264
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValueOnce(lifecycleRecord({
265
+ status: 'provisioning',
266
+ }));
267
+
268
+ await expect(touchActivation(lifecycleRecord({ status: 'provisioning' })))
269
+ .resolves.toBeUndefined();
270
+ });
271
+
272
+ it('rejects a heartbeat after its activation loses ownership', async () => {
273
+ sqldb.IntegrationLifecycle.update.mockResolvedValueOnce([0]);
274
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValueOnce(null);
275
+
276
+ await expect(touchActivation(lifecycleRecord({ status: 'provisioning' })))
277
+ .rejects.toMatchObject({ status: 409 });
278
+ });
279
+
280
+ it.each([
281
+ ['superseded', 409],
282
+ ['retry', 503],
283
+ ['unexpected', 502],
284
+ ])('maps catalog deletion status %s to HTTP %i', async (status, expectedStatus) => {
285
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord());
286
+ axios.post.mockResolvedValueOnce({ data: { status } });
287
+
288
+ await expect(deleteIntegration({
289
+ userId: 'company-a',
290
+ integrationId: 'tourplan',
291
+ hint: 'Desk A',
292
+ requestedBy: 'user-a',
293
+ })).rejects.toMatchObject({ status: expectedStatus });
294
+ });
295
+
296
+ it('advances the generation before deleting after a timed-out activation', async () => {
297
+ const lifecycle = lifecycleRecord({
298
+ status: 'failed_activation',
299
+ generation: 3,
300
+ });
301
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
302
+
303
+ const result = await deleteIntegration({
304
+ userId: 'company-a',
305
+ integrationId: 'tourplan',
306
+ hint: 'Desk A',
307
+ requestedBy: 'user-a',
308
+ });
309
+
310
+ expect(result.generation).toBe(4);
311
+ expect(result.status).toBe('complete');
312
+ expect(axios.post).toHaveBeenCalledWith(
313
+ expect.any(String),
314
+ expect.objectContaining({ generation: 4 }),
315
+ expect.any(Object),
316
+ );
317
+ });
318
+
319
+ it('keeps the cleanup request identity and increments retries for incomplete work', async () => {
320
+ const lifecycle = lifecycleRecord({
321
+ status: 'failed',
322
+ generation: 3,
323
+ requestId: 'original-request',
324
+ retryCount: 2,
325
+ });
326
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
327
+
328
+ const result = await deleteIntegration({
329
+ userId: 'company-a',
330
+ integrationId: 'tourplan',
331
+ hint: 'Desk A',
332
+ requestedBy: 'user-a',
333
+ });
334
+
335
+ expect(result).toEqual(expect.objectContaining({
336
+ generation: 3,
337
+ requestId: 'original-request',
338
+ retryCount: 3,
339
+ }));
340
+ });
341
+
342
+ it('returns the persisted terminal state when another delete wins completion', async () => {
343
+ const lifecycle = lifecycleRecord();
344
+ const persisted = lifecycleRecord({
345
+ generation: 2,
346
+ status: 'external_cleanup',
347
+ artifacts: { catalog: { status: 'deleted' } },
348
+ });
349
+ sqldb.IntegrationLifecycle.findOne
350
+ .mockResolvedValueOnce(lifecycle)
351
+ .mockResolvedValueOnce(persisted);
352
+ sqldb.IntegrationLifecycle.update
353
+ .mockResolvedValueOnce([1])
354
+ .mockResolvedValueOnce([1])
355
+ .mockResolvedValueOnce([0]);
356
+
357
+ const result = await deleteIntegration({
358
+ userId: 'company-a',
359
+ integrationId: 'tourplan',
360
+ hint: 'Desk A',
361
+ requestedBy: 'user-a',
362
+ deferCompletion: false,
363
+ });
364
+
365
+ expect(result.status).toBe('external_cleanup');
366
+ });
367
+
368
+ it('persists completed artifact counts and the failure stage for a retry', async () => {
369
+ const lifecycle = lifecycleRecord();
370
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
371
+ axios.post.mockRejectedValueOnce(new Error('catalog unavailable'));
372
+
373
+ await expect(deleteIntegration({
374
+ userId: 'company-a',
375
+ integrationId: 'tourplan',
376
+ hint: 'Desk A',
377
+ requestedBy: 'user-a',
378
+ })).rejects.toMatchObject({
379
+ status: 503,
380
+ message: expect.stringContaining('product catalog cleanup'),
381
+ });
382
+
383
+ expect(sqldb.IntegrationLifecycle.update).toHaveBeenLastCalledWith({
384
+ status: 'failed',
385
+ artifacts: {
386
+ credentials: 1,
387
+ cronJobs: 0,
388
+ settings: 1,
389
+ failureStage: 'product catalog cleanup',
390
+ },
391
+ lastError: 'catalog unavailable',
392
+ }, expect.any(Object));
393
+ });
394
+
395
+ it('records completed schedule cleanup when credential cleanup fails', async () => {
396
+ const lifecycle = lifecycleRecord();
397
+ const schedule = { bullJobId: 'repeat:exact:123', destroy: jest.fn().mockResolvedValue() };
398
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
399
+ sqldb.CronJobs.findAll
400
+ .mockResolvedValueOnce([schedule])
401
+ .mockResolvedValueOnce([schedule]);
402
+ sqldb.UserAppKey.destroy.mockRejectedValueOnce(new Error('database unavailable'));
403
+
404
+ await expect(deleteIntegration({
405
+ userId: 'company-a',
406
+ integrationId: 'tourplan',
407
+ hint: 'Desk A',
408
+ requestedBy: 'user-a',
409
+ })).rejects.toMatchObject({
410
+ status: 503,
411
+ message: expect.stringContaining('credential and settings cleanup'),
412
+ });
413
+
414
+ expect(sqldb.IntegrationLifecycle.update).toHaveBeenLastCalledWith({
415
+ status: 'failed',
416
+ artifacts: {
417
+ cronJobs: 1,
418
+ failureStage: 'credential and settings cleanup',
419
+ },
420
+ lastError: 'database unavailable',
421
+ }, expect.any(Object));
422
+ });
423
+
424
+ it('skips catalog lifecycle calls when no service is configured', async () => {
425
+ delete process.env.ti2_events2url_eventsURL;
426
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord());
427
+
428
+ const result = await deleteIntegration({
429
+ userId: 'company-a',
430
+ integrationId: 'tourplan',
431
+ hint: 'Desk A',
432
+ requestedBy: 'user-a',
433
+ });
434
+
435
+ expect(axios.post).not.toHaveBeenCalled();
436
+ expect(result.artifacts.catalog).toEqual(expect.objectContaining({
437
+ status: 'deleted',
438
+ skipped: true,
439
+ reason: 'catalog_lifecycle_not_configured',
440
+ }));
441
+ });
442
+
443
+ it('resumes external cleanup without reopening completed TI2 stages', async () => {
444
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord({
445
+ status: 'external_cleanup',
446
+ generation: 2,
447
+ requestId: 'external-request',
448
+ artifacts: { catalog: { status: 'deleted' } },
449
+ }));
450
+
451
+ const result = await deleteIntegration({
452
+ userId: 'company-a',
453
+ integrationId: 'tourplan',
454
+ hint: 'Desk A',
455
+ requestedBy: 'user-a',
456
+ deferCompletion: true,
457
+ });
458
+
459
+ expect(result).toEqual(expect.objectContaining({
460
+ status: 'external_cleanup',
461
+ generation: 2,
462
+ requestId: 'external-request',
463
+ }));
464
+ expect(sqldb.IntegrationLifecycle.update).not.toHaveBeenCalled();
465
+ expect(axios.post).not.toHaveBeenCalled();
466
+ });
467
+
468
+ it('finalizes the matching generation and records external cleanup artifacts', async () => {
469
+ const lifecycle = lifecycleRecord({
470
+ status: 'external_cleanup',
471
+ generation: 2,
472
+ artifacts: { credentials: 1 },
473
+ });
474
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
475
+
476
+ const result = await completeDeletion({
477
+ userId: 'company-a',
478
+ integrationId: 'tourplan',
479
+ hint: 'Desk A',
480
+ generation: 2,
481
+ requestId: 'request-1',
482
+ externalArtifacts: { partnerMappings: 1 },
483
+ });
484
+
485
+ expect(lifecycle.status).toBe('complete');
486
+ expect(lifecycle.artifacts).toEqual({
487
+ credentials: 1,
488
+ external: { partnerMappings: 1 },
489
+ });
490
+ expect(lifecycle.save).toHaveBeenCalled();
491
+ expect(result.status).toBe('complete');
492
+ });
493
+
494
+ it('rejects finalization when the request no longer owns the generation', async () => {
495
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord({
496
+ status: 'external_cleanup',
497
+ generation: 2,
498
+ requestId: 'request-2',
499
+ }));
500
+
501
+ await expect(completeDeletion({
502
+ userId: 'company-a',
503
+ integrationId: 'tourplan',
504
+ hint: 'Desk A',
505
+ generation: 2,
506
+ requestId: 'stale-request',
507
+ })).rejects.toMatchObject({
508
+ status: 409,
509
+ message: 'Integration cleanup request no longer owns this generation.',
510
+ });
511
+ });
512
+
513
+ it('preserves shared integration settings while another hint remains', async () => {
514
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord());
515
+ sqldb.UserAppKey.count.mockResolvedValue(1);
516
+
517
+ await deleteIntegration({
518
+ userId: 'company-a',
519
+ integrationId: 'tourplan',
520
+ hint: 'Desk A',
521
+ requestedBy: 'user-a',
522
+ });
523
+
524
+ expect(sqldb.UserIntegrationSettings.destroy).not.toHaveBeenCalled();
525
+ });
526
+
527
+ it('keeps a legacy shared Bull schedule while another database reference remains', async () => {
528
+ const lifecycle = lifecycleRecord();
529
+ const schedule = { bullJobId: 'repeat:shared:123', destroy: jest.fn().mockResolvedValue() };
530
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycle);
531
+ sqldb.CronJobs.findAll
532
+ .mockResolvedValueOnce([schedule])
533
+ .mockResolvedValueOnce([schedule, { bullJobId: schedule.bullJobId }]);
534
+
535
+ await deleteIntegration({
536
+ userId: 'company-a',
537
+ integrationId: 'tourplan',
538
+ hint: 'Desk A',
539
+ requestedBy: 'user-a',
540
+ });
541
+
542
+ expect(removeJob).not.toHaveBeenCalled();
543
+ expect(schedule.destroy).toHaveBeenCalled();
544
+ });
545
+
546
+ it('returns a completed tombstone without repeating downstream cleanup', async () => {
547
+ sqldb.IntegrationLifecycle.findOne.mockResolvedValue(lifecycleRecord({
548
+ status: 'complete',
549
+ generation: 2,
550
+ }));
551
+
552
+ const result = await deleteIntegration({
553
+ userId: 'company-a',
554
+ integrationId: 'tourplan',
555
+ hint: 'Desk A',
556
+ requestedBy: 'user-a',
557
+ });
558
+
559
+ expect(result.status).toBe('complete');
560
+ expect(removeJob).not.toHaveBeenCalled();
561
+ expect(axios.post).not.toHaveBeenCalled();
562
+ });
563
+ });