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.
- package/README.md +22 -5
- package/api.yml +111 -4
- package/auth/authHandler.js +3 -0
- package/controllers/README.md +1 -0
- package/controllers/__tests__/app.js +48 -3
- package/controllers/__tests__/bookings-searchProducts.js +31 -14
- package/controllers/__tests__/user.js +87 -1
- package/controllers/app.js +90 -18
- package/controllers/bookings.js +17 -1
- package/controllers/user.js +35 -7
- package/migrations/20260914180000-create-integration-lifecycles.js +67 -0
- package/models/integrationlifecycle.js +44 -0
- package/package.json +1 -1
- package/services/__tests__/integrationLifecycle.db.js +251 -0
- package/services/__tests__/integrationLifecycle.js +563 -0
- package/services/integrationLifecycle.js +496 -0
- package/worker/__tests__/queue-remove.js +77 -0
- package/worker/queue.js +38 -49
package/controllers/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
where: {
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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,
|
package/controllers/bookings.js
CHANGED
|
@@ -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 ? '
|
|
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
|
}
|
package/controllers/user.js
CHANGED
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
// controllers/user.js
|
|
2
|
-
const {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
@@ -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
|
+
});
|