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.
@@ -5,9 +5,11 @@ const { Umzug, SequelizeStorage } = require('umzug');
5
5
  const path = require('path');
6
6
  const Sequelize = require('sequelize');
7
7
  const fs = require('fs'); // Changed to synchronous fs for initial load
8
+ const crypto = require('crypto');
8
9
  const yaml = require('js-yaml');
9
10
  const bb = require('bluebird');
10
11
  const R = require('ramda');
12
+ const integrationLifecycle = require('../services/integrationLifecycle');
11
13
 
12
14
  // Load OpenAPI schema
13
15
  let openApiSchema = null;
@@ -102,6 +104,7 @@ const createAppToken = async (req, res, next) => {
102
104
  },
103
105
  } = req;
104
106
  let transaction;
107
+ let lifecycle;
105
108
  try {
106
109
  const payload = {
107
110
  integrationId,
@@ -117,6 +120,12 @@ const createAppToken = async (req, res, next) => {
117
120
  transaction,
118
121
  lock: transaction.LOCK.UPDATE,
119
122
  });
123
+ lifecycle = await integrationLifecycle.prepareActivation({
124
+ userId,
125
+ integrationId,
126
+ hint,
127
+ transaction,
128
+ });
120
129
  const integrationCount = await sqldb.UserAppKey.count({
121
130
  where: { userId },
122
131
  transaction,
@@ -146,7 +155,8 @@ const createAppToken = async (req, res, next) => {
146
155
  // Credential persistence is already committed, but cron provisioning must
147
156
  // remain caller-visible so missing scheduled functionality is not silent.
148
157
  try {
149
- await bb.each(req.app.plugins, async plugin => {
158
+ const integrationPlugins = req.app.plugins.filter(plugin => plugin.name === integrationId);
159
+ await bb.each(integrationPlugins, async plugin => {
150
160
  if (!Array.isArray(plugin.jobs)) return;
151
161
  const validJobs = plugin.jobs.filter(job => Boolean(job.cron) && Boolean(job.method));
152
162
  await bb.each(validJobs, async job => {
@@ -171,22 +181,25 @@ const createAppToken = async (req, res, next) => {
171
181
  },
172
182
  } : {}),
173
183
  ...(job.params || {}),
184
+ jobId: crypto.createHash('sha256').update(JSON.stringify({
185
+ pluginName: plugin.name,
186
+ pluginJobId: job.method,
187
+ userId,
188
+ hint,
189
+ })).digest('hex'),
174
190
  removeOnComplete: false,
175
191
  };
176
192
  if (existing) {
177
- const bullJob = await queue.getJob(job.bullJobId);
178
- if (!bullJob) {
193
+ if (existing.cron !== job.cron) {
194
+ await removeJob(existing.bullJobId);
179
195
  const rawBullJobId = await addJob(jobPayload, jobParams);
180
196
  existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
181
197
  && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
198
+ existing.cron = job.cron;
182
199
  await existing.save();
183
200
  } else {
184
- const bullCron = R.path(
185
- ['opts', 'repeat', 'cron'],
186
- await queue.getJob(existing.bullJobId),
187
- );
188
- if (bullCron !== job.cron) {
189
- await queue.removeJobs(existing.bullJobId);
201
+ const bullJob = await queue.getJob(existing.bullJobId);
202
+ if (!bullJob) {
190
203
  const rawBullJobId = await addJob(jobPayload, jobParams);
191
204
  existing.bullJobId = (rawBullJobId && typeof rawBullJobId === 'object'
192
205
  && rawBullJobId.id) ? rawBullJobId.id : rawBullJobId;
@@ -206,6 +219,12 @@ const createAppToken = async (req, res, next) => {
206
219
  throw cronErr;
207
220
  }
208
221
 
222
+ await integrationLifecycle.touchActivation(lifecycle);
223
+ if (R.pathOr(false, ['artifacts', 'requiresCatalogActivation'], lifecycle)) {
224
+ await integrationLifecycle.activateCatalog(lifecycle);
225
+ }
226
+ await integrationLifecycle.completeActivation(lifecycle);
227
+
209
228
  return res.json(responsePayload);
210
229
  } catch (err) {
211
230
  if (transaction && !transaction.finished) {
@@ -215,6 +234,13 @@ const createAppToken = async (req, res, next) => {
215
234
  console.error('Unable to roll back integration credential transaction', rollbackErr);
216
235
  }
217
236
  }
237
+ if (lifecycle && (!transaction || transaction.finished)) {
238
+ try {
239
+ await integrationLifecycle.failActivation(lifecycle, err);
240
+ } catch (lifecycleErr) {
241
+ console.error('Unable to record integration activation failure', lifecycleErr);
242
+ }
243
+ }
218
244
  return next(err);
219
245
  }
220
246
  };
@@ -250,24 +276,69 @@ const deleteAppToken = async (req, res, next) => {
250
276
  const {
251
277
  body: {
252
278
  tokenHint: hint,
279
+ deferCompletion = false,
253
280
  },
254
281
  params: {
255
282
  app: integrationId,
256
283
  userId,
257
284
  },
258
285
  } = req;
259
- // check if the user exists
260
- const userRecord = await sqldb.User.findOne({ where: { userId } });
261
- if (!userRecord) return next({ status: 404, message: 'User does not exists' });
262
- const retVal = await sqldb.UserAppKey.destroy({
263
- where: {
264
- integrationId,
286
+ try {
287
+ if (typeof hint !== 'string' || hint.length === 0 || hint.length > 256) {
288
+ return next({ status: 400, message: 'A valid tokenHint is required' });
289
+ }
290
+ const userRecord = await sqldb.User.findOne({ where: { userId } });
291
+ if (!userRecord) return next({ status: 404, message: 'User does not exists' });
292
+ const result = await integrationLifecycle.deleteIntegration({
265
293
  userId,
294
+ integrationId,
266
295
  hint,
296
+ requestedBy: R.pathOr('api', ['auth', 'subject'], req),
297
+ deferCompletion,
298
+ });
299
+ return res.json({
300
+ message: result.status === 'external_cleanup'
301
+ ? 'Integration cleanup is awaiting external finalization.'
302
+ : 'Integration cleanup complete.',
303
+ cleanup: result,
304
+ });
305
+ } catch (err) {
306
+ return next(err);
307
+ }
308
+ };
309
+
310
+ const finalizeIntegrationCleanup = async (req, res, next) => {
311
+ const {
312
+ body: {
313
+ tokenHint: hint,
314
+ generation,
315
+ requestId,
316
+ artifacts,
267
317
  },
268
- });
269
- if (retVal === 0) return next({ status: 404, message: 'Key not found' });
270
- return res.json({ message: `${retVal} erased` });
318
+ params: {
319
+ app: integrationId,
320
+ userId,
321
+ },
322
+ } = req;
323
+ try {
324
+ if (typeof hint !== 'string' || hint.length === 0 || hint.length > 256) {
325
+ return next({ status: 400, message: 'A valid tokenHint is required' });
326
+ }
327
+ if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 64) {
328
+ return next({ status: 400, message: 'A valid requestId is required' });
329
+ }
330
+ const cleanup = await integrationLifecycle.completeDeletion({
331
+ userId,
332
+ integrationId,
333
+ hint,
334
+ generation,
335
+ requestId,
336
+ externalArtifacts: artifacts,
337
+ });
338
+ return res.json({ message: 'Integration cleanup finalized.', cleanup });
339
+ } catch (err) {
340
+ return next(err);
341
+ }
271
342
  };
272
343
 
273
344
  const validateAppToken = plugins => async (req, res, next) => {
@@ -807,6 +878,7 @@ module.exports = plugins => ({
807
878
  getAppSettings,
808
879
  updateAppToken,
809
880
  deleteAppToken,
881
+ finalizeIntegrationCleanup,
810
882
  deleteAppSettings,
811
883
  getAppScheduledJobs,
812
884
  getJobStatus,
@@ -193,6 +193,14 @@ const createScopedCatalogRefreshError = () => {
193
193
  return err;
194
194
  };
195
195
 
196
+ const createManualCatalogRefreshReasonError = () => {
197
+ const err = new Error(
198
+ 'A manual catalog refresh requires a non-blank admissionOverrideReason',
199
+ );
200
+ err.status = 400;
201
+ return err;
202
+ };
203
+
196
204
  const createUnsupportedCatalogRefreshError = appKey => {
197
205
  const err = new Error(
198
206
  `${appKey} does not support complete product catalog refreshes`,
@@ -402,7 +410,7 @@ const $bookingsProductSearch = plugins => async ({
402
410
  requestedFullSyncTrigger,
403
411
  ) >= 0
404
412
  ? requestedFullSyncTrigger
405
- : (forceRefresh ? 'scheduled' : 'organic');
413
+ : (forceRefresh ? 'manual' : 'organic');
406
414
  const admissionOverrideReason = typeof requestedOverrideReason === 'string'
407
415
  ? requestedOverrideReason.trim() || undefined
408
416
  : undefined;
@@ -431,6 +439,14 @@ const $bookingsProductSearch = plugins => async ({
431
439
  const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
432
440
  const isDeclaredFullCatalogRefresh = forceRefresh
433
441
  && ['scheduled', 'manual'].indexOf(fullSyncTrigger) >= 0;
442
+ if (
443
+ forceRefresh
444
+ && !cacheOnly
445
+ && fullSyncTrigger === 'manual'
446
+ && !admissionOverrideReason
447
+ ) {
448
+ throw createManualCatalogRefreshReasonError();
449
+ }
434
450
  if (isDeclaredFullCatalogRefresh && isScopedSearch) {
435
451
  throw createScopedCatalogRefreshError();
436
452
  }
@@ -1,16 +1,44 @@
1
1
  // controllers/user.js
2
- const { omit } = require('ramda');
2
+ const { Op } = require('sequelize');
3
3
 
4
- const { UserAppKey } = require('../models');
4
+ const { IntegrationLifecycle, UserAppKey } = require('../models');
5
+
6
+ const pendingCleanupStatuses = ['deleting', 'external_cleanup', 'failed'];
7
+ const integrationKey = ({ integrationId, hint }) => JSON.stringify([integrationId, hint]);
5
8
 
6
9
  const userAppList = async (req, res, next) => {
7
10
  const { params: { userId } } = req;
8
11
  try {
9
- const userAppKeys = await UserAppKey.findAll({
10
- where: { userId },
11
- raw: true,
12
- attributes: ['integrationId', 'userId', 'hint', 'createdAt', 'updatedAt'],
13
- })
12
+ const [credentials, pendingCleanups] = await Promise.all([
13
+ UserAppKey.findAll({
14
+ where: { userId },
15
+ raw: true,
16
+ attributes: ['integrationId', 'userId', 'hint', 'createdAt', 'updatedAt'],
17
+ }),
18
+ IntegrationLifecycle.findAll({
19
+ where: {
20
+ userId,
21
+ status: { [Op.in]: pendingCleanupStatuses },
22
+ },
23
+ raw: true,
24
+ attributes: ['integrationId', 'userId', 'hint', 'status'],
25
+ }),
26
+ ]);
27
+ const cleanupByIntegration = new Map(
28
+ pendingCleanups.map(cleanup => [integrationKey(cleanup), cleanup]),
29
+ );
30
+ const userAppKeys = credentials.map(credential => {
31
+ const cleanup = cleanupByIntegration.get(integrationKey(credential));
32
+ if (!cleanup) return credential;
33
+ cleanupByIntegration.delete(integrationKey(credential));
34
+ return { ...credential, cleanupStatus: cleanup.status };
35
+ });
36
+ cleanupByIntegration.forEach(cleanup => userAppKeys.push({
37
+ integrationId: cleanup.integrationId,
38
+ userId: cleanup.userId,
39
+ hint: cleanup.hint,
40
+ cleanupStatus: cleanup.status,
41
+ }));
14
42
  return res.json({ userAppKeys });
15
43
  } catch (err) {
16
44
  return next(err);
@@ -0,0 +1,67 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ await queryInterface.createTable('IntegrationLifecycles', {
4
+ userId: {
5
+ type: Sequelize.STRING(64),
6
+ allowNull: false,
7
+ primaryKey: true,
8
+ },
9
+ integrationId: {
10
+ type: Sequelize.STRING(100),
11
+ allowNull: false,
12
+ primaryKey: true,
13
+ },
14
+ hint: {
15
+ type: Sequelize.STRING(256),
16
+ allowNull: false,
17
+ primaryKey: true,
18
+ },
19
+ generation: {
20
+ type: Sequelize.INTEGER.UNSIGNED,
21
+ allowNull: false,
22
+ defaultValue: 1,
23
+ },
24
+ status: {
25
+ type: Sequelize.STRING,
26
+ allowNull: false,
27
+ },
28
+ requestId: {
29
+ type: Sequelize.STRING(64),
30
+ allowNull: false,
31
+ },
32
+ retryCount: {
33
+ type: Sequelize.INTEGER.UNSIGNED,
34
+ allowNull: false,
35
+ defaultValue: 0,
36
+ },
37
+ requestedBy: {
38
+ type: Sequelize.STRING,
39
+ allowNull: true,
40
+ },
41
+ requestedAt: {
42
+ type: Sequelize.DATE,
43
+ allowNull: false,
44
+ },
45
+ artifacts: {
46
+ type: Sequelize.JSON,
47
+ allowNull: true,
48
+ },
49
+ lastError: {
50
+ type: Sequelize.TEXT,
51
+ allowNull: true,
52
+ },
53
+ createdAt: {
54
+ type: Sequelize.DATE,
55
+ allowNull: false,
56
+ },
57
+ updatedAt: {
58
+ type: Sequelize.DATE,
59
+ allowNull: false,
60
+ },
61
+ });
62
+ },
63
+
64
+ down: async queryInterface => {
65
+ await queryInterface.dropTable('IntegrationLifecycles');
66
+ },
67
+ };
@@ -0,0 +1,44 @@
1
+ const Sequelize = require('sequelize');
2
+ const db = require('./db');
3
+
4
+ const IntegrationLifecycle = db.define('IntegrationLifecycle', {
5
+ userId: {
6
+ type: Sequelize.STRING(64),
7
+ primaryKey: true,
8
+ },
9
+ integrationId: {
10
+ type: Sequelize.STRING(100),
11
+ primaryKey: true,
12
+ },
13
+ hint: {
14
+ type: Sequelize.STRING(256),
15
+ primaryKey: true,
16
+ },
17
+ generation: {
18
+ type: Sequelize.INTEGER.UNSIGNED,
19
+ allowNull: false,
20
+ defaultValue: 1,
21
+ },
22
+ status: {
23
+ type: Sequelize.STRING,
24
+ allowNull: false,
25
+ },
26
+ requestId: {
27
+ type: Sequelize.STRING(64),
28
+ allowNull: false,
29
+ },
30
+ retryCount: {
31
+ type: Sequelize.INTEGER.UNSIGNED,
32
+ allowNull: false,
33
+ defaultValue: 0,
34
+ },
35
+ requestedBy: Sequelize.STRING,
36
+ requestedAt: {
37
+ type: Sequelize.DATE,
38
+ allowNull: false,
39
+ },
40
+ artifacts: Sequelize.JSON,
41
+ lastError: Sequelize.TEXT,
42
+ }, {});
43
+
44
+ module.exports = IntegrationLifecycle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.123",
3
+ "version": "1.0.125",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,251 @@
1
+ /* globals afterAll afterEach beforeEach describe expect it jest */
2
+
3
+ jest.mock('axios', () => ({ post: jest.fn() }));
4
+ jest.mock('../../worker/queue', () => ({ removeJob: jest.fn() }));
5
+
6
+ const { v4: uuidv4 } = require('uuid');
7
+ const axios = require('axios');
8
+
9
+ const sqldb = require('../../models');
10
+ const { removeJob } = require('../../worker/queue');
11
+ const { deleteIntegration } = require('../integrationLifecycle');
12
+
13
+ const originalEventsUrl = process.env.ti2_events2url_eventsURL;
14
+ const originalEventsAuthorization = process.env.ti2_events2url_authorization;
15
+
16
+ const deferred = () => {
17
+ let resolve;
18
+ let reject;
19
+ const promise = new Promise((resolvePromise, rejectPromise) => {
20
+ resolve = resolvePromise;
21
+ reject = rejectPromise;
22
+ });
23
+ return { promise, reject, resolve };
24
+ };
25
+
26
+ describe('integrationLifecycle database transitions', () => {
27
+ let userId;
28
+ const hint = 'Shared desk';
29
+
30
+ beforeEach(async () => {
31
+ jest.clearAllMocks();
32
+ process.env.ti2_events2url_eventsURL = 'http://filematch.test/ti2events';
33
+ process.env.ti2_events2url_authorization = 'Bearer service-token';
34
+ userId = `tc-1460-${uuidv4()}`;
35
+ await sqldb.User.create({ userId });
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await sqldb.CronJobs.destroy({ where: { userId } });
40
+ await sqldb.UserAppKey.destroy({ where: { userId } });
41
+ await sqldb.UserIntegrationSettings.destroy({ where: { userId } });
42
+ await sqldb.IntegrationLifecycle.destroy({ where: { userId } });
43
+ await sqldb.User.destroy({ where: { userId } });
44
+ });
45
+
46
+ afterAll(() => {
47
+ if (originalEventsUrl === undefined) {
48
+ delete process.env.ti2_events2url_eventsURL;
49
+ } else {
50
+ process.env.ti2_events2url_eventsURL = originalEventsUrl;
51
+ }
52
+ if (originalEventsAuthorization === undefined) {
53
+ delete process.env.ti2_events2url_authorization;
54
+ } else {
55
+ process.env.ti2_events2url_authorization = originalEventsAuthorization;
56
+ }
57
+ });
58
+
59
+ const createActiveLifecycle = () => sqldb.IntegrationLifecycle.create({
60
+ userId,
61
+ integrationId: 'tourplan',
62
+ hint,
63
+ generation: 1,
64
+ status: 'active',
65
+ requestId: uuidv4(),
66
+ retryCount: 0,
67
+ requestedAt: new Date(),
68
+ });
69
+
70
+ it('returns 404 without lifecycle or catalog work for an unknown hint', async () => {
71
+ await expect(deleteIntegration({
72
+ userId,
73
+ integrationId: 'tourplan',
74
+ hint,
75
+ requestedBy: 'user:test',
76
+ })).rejects.toMatchObject({ status: 404 });
77
+
78
+ expect(axios.post).not.toHaveBeenCalled();
79
+ expect(await sqldb.IntegrationLifecycle.count({ where: { userId } })).toBe(0);
80
+ });
81
+
82
+ it('removes only schedules owned by the deleted integration', async () => {
83
+ await createActiveLifecycle();
84
+ await sqldb.CronJobs.bulkCreate([
85
+ {
86
+ pluginName: 'tourplan',
87
+ pluginJobId: 'dailyReport',
88
+ userId,
89
+ hint,
90
+ bullJobId: 'repeat:tourplan:1',
91
+ cron: '0 9 * * *',
92
+ },
93
+ {
94
+ pluginName: 'sellouts',
95
+ pluginJobId: 'dailyReport',
96
+ userId,
97
+ hint,
98
+ bullJobId: 'repeat:sellouts:1',
99
+ cron: '0 9 * * *',
100
+ },
101
+ ]);
102
+ axios.post.mockResolvedValue({ data: { status: 'deleted' } });
103
+
104
+ await deleteIntegration({
105
+ userId,
106
+ integrationId: 'tourplan',
107
+ hint,
108
+ requestedBy: 'user:test',
109
+ });
110
+
111
+ expect(removeJob).toHaveBeenCalledTimes(1);
112
+ expect(removeJob).toHaveBeenCalledWith('repeat:tourplan:1');
113
+ expect(await sqldb.CronJobs.count({
114
+ where: { userId, hint, pluginName: 'tourplan' },
115
+ })).toBe(0);
116
+ expect(await sqldb.CronJobs.count({
117
+ where: { userId, hint, pluginName: 'sellouts' },
118
+ })).toBe(1);
119
+ });
120
+
121
+ it('does not let a late failure overwrite external cleanup', async () => {
122
+ await createActiveLifecycle();
123
+ const successfulCatalog = deferred();
124
+ const failedCatalog = deferred();
125
+ const successfulCatalogCalled = deferred();
126
+ const failedCatalogCalled = deferred();
127
+ axios.post
128
+ .mockImplementationOnce(() => {
129
+ successfulCatalogCalled.resolve();
130
+ return successfulCatalog.promise;
131
+ })
132
+ .mockImplementationOnce(() => {
133
+ failedCatalogCalled.resolve();
134
+ return failedCatalog.promise;
135
+ });
136
+
137
+ const successfulDeletion = deleteIntegration({
138
+ userId,
139
+ integrationId: 'tourplan',
140
+ hint,
141
+ requestedBy: 'user:first',
142
+ deferCompletion: true,
143
+ });
144
+ await successfulCatalogCalled.promise;
145
+ const failedDeletion = deleteIntegration({
146
+ userId,
147
+ integrationId: 'tourplan',
148
+ hint,
149
+ requestedBy: 'user:second',
150
+ deferCompletion: true,
151
+ });
152
+ await failedCatalogCalled.promise;
153
+
154
+ successfulCatalog.resolve({ data: { status: 'deleted' } });
155
+ await expect(successfulDeletion).resolves.toEqual(expect.objectContaining({
156
+ status: 'external_cleanup',
157
+ }));
158
+ failedCatalog.reject(new Error('catalog unavailable'));
159
+ await expect(failedDeletion).rejects.toMatchObject({ status: 503 });
160
+
161
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
162
+ where: { userId, integrationId: 'tourplan', hint },
163
+ });
164
+ expect(lifecycle.status).toBe('external_cleanup');
165
+ });
166
+
167
+ it('lets a successful concurrent attempt recover a failed attempt', async () => {
168
+ await createActiveLifecycle();
169
+ const failedCatalog = deferred();
170
+ const successfulCatalog = deferred();
171
+ const failedCatalogCalled = deferred();
172
+ const successfulCatalogCalled = deferred();
173
+ axios.post
174
+ .mockImplementationOnce(() => {
175
+ failedCatalogCalled.resolve();
176
+ return failedCatalog.promise;
177
+ })
178
+ .mockImplementationOnce(() => {
179
+ successfulCatalogCalled.resolve();
180
+ return successfulCatalog.promise;
181
+ });
182
+
183
+ const failedDeletion = deleteIntegration({
184
+ userId,
185
+ integrationId: 'tourplan',
186
+ hint,
187
+ requestedBy: 'user:first',
188
+ deferCompletion: true,
189
+ });
190
+ await failedCatalogCalled.promise;
191
+ const successfulDeletion = deleteIntegration({
192
+ userId,
193
+ integrationId: 'tourplan',
194
+ hint,
195
+ requestedBy: 'user:second',
196
+ deferCompletion: true,
197
+ });
198
+ await successfulCatalogCalled.promise;
199
+
200
+ failedCatalog.reject(new Error('catalog unavailable'));
201
+ await expect(failedDeletion).rejects.toMatchObject({ status: 503 });
202
+ successfulCatalog.resolve({ data: { status: 'deleted' } });
203
+ await expect(successfulDeletion).resolves.toEqual(expect.objectContaining({
204
+ status: 'external_cleanup',
205
+ }));
206
+
207
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
208
+ where: { userId, integrationId: 'tourplan', hint },
209
+ });
210
+ expect(lifecycle.status).toBe('external_cleanup');
211
+ });
212
+
213
+ it('reports the persisted winner when concurrent deletes disagree on finalization', async () => {
214
+ await createActiveLifecycle();
215
+ const deferredCatalog = deferred();
216
+ const immediateCatalogCalled = deferred();
217
+ axios.post
218
+ .mockImplementationOnce(() => deferredCatalog.promise)
219
+ .mockImplementationOnce(() => {
220
+ immediateCatalogCalled.resolve();
221
+ return Promise.resolve({ data: { status: 'deleted' } });
222
+ });
223
+
224
+ const deferredDeletion = deleteIntegration({
225
+ userId,
226
+ integrationId: 'tourplan',
227
+ hint,
228
+ requestedBy: 'user:first',
229
+ deferCompletion: true,
230
+ });
231
+ const immediateDeletion = deleteIntegration({
232
+ userId,
233
+ integrationId: 'tourplan',
234
+ hint,
235
+ requestedBy: 'user:second',
236
+ deferCompletion: false,
237
+ });
238
+ await immediateCatalogCalled.promise;
239
+
240
+ const immediateResult = await immediateDeletion;
241
+ deferredCatalog.resolve({ data: { status: 'deleted' } });
242
+ const deferredResult = await deferredDeletion;
243
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
244
+ where: { userId, integrationId: 'tourplan', hint },
245
+ });
246
+
247
+ expect(immediateResult.status).toBe('complete');
248
+ expect(deferredResult.status).toBe('complete');
249
+ expect(lifecycle.status).toBe('complete');
250
+ });
251
+ });