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/README.md
CHANGED
|
@@ -73,11 +73,12 @@ calling the integration; `cacheFound` distinguishes a cache miss from a cached
|
|
|
73
73
|
empty catalog.
|
|
74
74
|
|
|
75
75
|
A forced request is a full-catalog refresh by default. `fullSyncTrigger` may be
|
|
76
|
-
`scheduled`, `manual`, or `organic`;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
`
|
|
76
|
+
`scheduled`, `manual`, or `organic`; an omitted trigger on a forced request is
|
|
77
|
+
treated as manual. Manual refreshes require a non-blank `admissionOverrideReason`.
|
|
78
|
+
Scheduled and manual refreshes reject `searchInput`, `optionId`, `productId`,
|
|
79
|
+
`productName`, or `lastUpdatedFrom` selectors. An empty `searchInput` or
|
|
80
|
+
`searchInput: "*"` represents the full catalog. Scheduler-owned requests may
|
|
81
|
+
also carry `fullSyncStartedAt` and `fullSyncAdmissionToken`.
|
|
81
82
|
|
|
82
83
|
Unscoped forced responses include `catalogRefreshOutcome`, `cacheUpdated`,
|
|
83
84
|
`cachePreserved`, and `cachedProductCount`. Consumers that update a downstream
|
|
@@ -93,6 +94,22 @@ The product cache refresh interval defaults to seven days when no TTR is set.
|
|
|
93
94
|
Any explicit `ttlForProducts` or plugin `cacheSettings.bookingsProductSearch.ttr`
|
|
94
95
|
value is honored, including `86400` for a one-day interval.
|
|
95
96
|
|
|
97
|
+
### Optional catalog lifecycle service
|
|
98
|
+
|
|
99
|
+
Ti2 synchronously asks Filematch to update catalog lifecycle state when an
|
|
100
|
+
integration is removed or reactivated. The call reuses
|
|
101
|
+
`ti2_events2url_eventsURL` and `ti2_events2url_authorization`; Filematch then
|
|
102
|
+
forwards the request to Pyfilematch. Without the events URL, Ti2 cleans its own
|
|
103
|
+
credentials and schedules without making an external catalog request.
|
|
104
|
+
`INTEGRATION_LIFECYCLE_TIMEOUT_MS` controls the request timeout and defaults to
|
|
105
|
+
30 seconds. A save left in `provisioning` blocks deletion until
|
|
106
|
+
it is older than `INTEGRATION_PROVISIONING_TIMEOUT_MS` (five minutes by
|
|
107
|
+
default); deletion then advances the generation before removing local and
|
|
108
|
+
catalog state. Catalog lifecycle consumers must accept newer generations so a
|
|
109
|
+
failed activation followed by deletion can move from generation N to N+1. The
|
|
110
|
+
timeout is a provisioning lease: saves refresh it before catalog activation and
|
|
111
|
+
must still own their generation when marking activation complete.
|
|
112
|
+
|
|
96
113
|
## Contributing
|
|
97
114
|
|
|
98
115
|
Contributions are welcome and ecouraged.
|
package/api.yml
CHANGED
|
@@ -101,6 +101,53 @@ components:
|
|
|
101
101
|
properties:
|
|
102
102
|
value:
|
|
103
103
|
type: string
|
|
104
|
+
IntegrationCleanup:
|
|
105
|
+
type: object
|
|
106
|
+
required:
|
|
107
|
+
- userId
|
|
108
|
+
- integrationId
|
|
109
|
+
- hint
|
|
110
|
+
- generation
|
|
111
|
+
- requestId
|
|
112
|
+
- status
|
|
113
|
+
properties:
|
|
114
|
+
userId:
|
|
115
|
+
type: string
|
|
116
|
+
integrationId:
|
|
117
|
+
type: string
|
|
118
|
+
hint:
|
|
119
|
+
type: string
|
|
120
|
+
generation:
|
|
121
|
+
type: integer
|
|
122
|
+
minimum: 1
|
|
123
|
+
requestId:
|
|
124
|
+
type: string
|
|
125
|
+
requestedAt:
|
|
126
|
+
type: string
|
|
127
|
+
format: date-time
|
|
128
|
+
retryCount:
|
|
129
|
+
type: integer
|
|
130
|
+
minimum: 0
|
|
131
|
+
status:
|
|
132
|
+
type: string
|
|
133
|
+
enum:
|
|
134
|
+
- deleting
|
|
135
|
+
- failed
|
|
136
|
+
- external_cleanup
|
|
137
|
+
- complete
|
|
138
|
+
artifacts:
|
|
139
|
+
type: object
|
|
140
|
+
additionalProperties: true
|
|
141
|
+
IntegrationCleanupResponse:
|
|
142
|
+
type: object
|
|
143
|
+
required:
|
|
144
|
+
- message
|
|
145
|
+
- cleanup
|
|
146
|
+
properties:
|
|
147
|
+
message:
|
|
148
|
+
type: string
|
|
149
|
+
cleanup:
|
|
150
|
+
$ref: '#/components/schemas/IntegrationCleanup'
|
|
104
151
|
CreateAppTokenResponse:
|
|
105
152
|
type: object
|
|
106
153
|
required:
|
|
@@ -186,6 +233,9 @@ components:
|
|
|
186
233
|
type: string
|
|
187
234
|
description: An identifier for this token, it should NOT be the token itself but a short safe version of it
|
|
188
235
|
example: 'expediaTestLocation-23'
|
|
236
|
+
deferCompletion:
|
|
237
|
+
type: boolean
|
|
238
|
+
description: Keep re-creation blocked until the caller finalizes external cleanup
|
|
189
239
|
UserAppValidate:
|
|
190
240
|
description: A user + App configuration to validate
|
|
191
241
|
type: object
|
|
@@ -254,6 +304,13 @@ components:
|
|
|
254
304
|
type: string
|
|
255
305
|
updatedAt:
|
|
256
306
|
type: string
|
|
307
|
+
cleanupStatus:
|
|
308
|
+
type: string
|
|
309
|
+
description: Present when removal is incomplete and may be retried.
|
|
310
|
+
enum:
|
|
311
|
+
- deleting
|
|
312
|
+
- external_cleanup
|
|
313
|
+
- failed
|
|
257
314
|
appRequest:
|
|
258
315
|
type: object
|
|
259
316
|
properties:
|
|
@@ -455,7 +512,7 @@ components:
|
|
|
455
512
|
forceRefresh:
|
|
456
513
|
type: boolean
|
|
457
514
|
default: false
|
|
458
|
-
description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a
|
|
515
|
+
description: Call the plugin directly. Full-catalog refreshes require the app's weeklyProductCatalogSync capability and default to a manual trigger when fullSyncTrigger is omitted. Manual refreshes require admissionOverrideReason.
|
|
459
516
|
cacheOnly:
|
|
460
517
|
type: boolean
|
|
461
518
|
default: false
|
|
@@ -466,14 +523,14 @@ components:
|
|
|
466
523
|
description: Execute the request through the Ti2 worker.
|
|
467
524
|
fullSyncTrigger:
|
|
468
525
|
type: string
|
|
469
|
-
description: Catalog refresh owner. Scheduled and manual triggers reject selector fields.
|
|
526
|
+
description: Catalog refresh owner. Forced requests default to manual when omitted. Scheduled and manual triggers reject selector fields.
|
|
470
527
|
enum:
|
|
471
528
|
- organic
|
|
472
529
|
- scheduled
|
|
473
530
|
- manual
|
|
474
531
|
admissionOverrideReason:
|
|
475
532
|
type: string
|
|
476
|
-
description:
|
|
533
|
+
description: Required non-blank audit reason for a manual refresh override.
|
|
477
534
|
fullSyncStartedAt:
|
|
478
535
|
type: number
|
|
479
536
|
description: Start time supplied by the full-sync admission owner.
|
|
@@ -982,7 +1039,7 @@ paths:
|
|
|
982
1039
|
content:
|
|
983
1040
|
application/json:
|
|
984
1041
|
schema:
|
|
985
|
-
$ref: '#/components/schemas/
|
|
1042
|
+
$ref: '#/components/schemas/IntegrationCleanupResponse'
|
|
986
1043
|
put:
|
|
987
1044
|
tags:
|
|
988
1045
|
- app
|
|
@@ -1020,6 +1077,56 @@ paths:
|
|
|
1020
1077
|
application/json:
|
|
1021
1078
|
schema:
|
|
1022
1079
|
$ref: '#/components/schemas/Message'
|
|
1080
|
+
/{app}/{userId}/cleanup/finalize:
|
|
1081
|
+
post:
|
|
1082
|
+
tags:
|
|
1083
|
+
- app
|
|
1084
|
+
- user
|
|
1085
|
+
summary: Finalize an integration cleanup after external artifacts are removed
|
|
1086
|
+
security:
|
|
1087
|
+
- bearerAuth: ['admin', 'app', 'user']
|
|
1088
|
+
operationId: finalizeIntegrationCleanup
|
|
1089
|
+
parameters:
|
|
1090
|
+
- name: app
|
|
1091
|
+
in: path
|
|
1092
|
+
required: true
|
|
1093
|
+
schema:
|
|
1094
|
+
type: string
|
|
1095
|
+
- name: userId
|
|
1096
|
+
in: path
|
|
1097
|
+
required: true
|
|
1098
|
+
schema:
|
|
1099
|
+
type: string
|
|
1100
|
+
requestBody:
|
|
1101
|
+
required: true
|
|
1102
|
+
content:
|
|
1103
|
+
application/json:
|
|
1104
|
+
schema:
|
|
1105
|
+
type: object
|
|
1106
|
+
required:
|
|
1107
|
+
- tokenHint
|
|
1108
|
+
- generation
|
|
1109
|
+
- requestId
|
|
1110
|
+
properties:
|
|
1111
|
+
tokenHint:
|
|
1112
|
+
type: string
|
|
1113
|
+
generation:
|
|
1114
|
+
type: integer
|
|
1115
|
+
minimum: 1
|
|
1116
|
+
requestId:
|
|
1117
|
+
type: string
|
|
1118
|
+
minLength: 1
|
|
1119
|
+
maxLength: 64
|
|
1120
|
+
artifacts:
|
|
1121
|
+
type: object
|
|
1122
|
+
additionalProperties: true
|
|
1123
|
+
responses:
|
|
1124
|
+
200:
|
|
1125
|
+
description: success
|
|
1126
|
+
content:
|
|
1127
|
+
application/json:
|
|
1128
|
+
schema:
|
|
1129
|
+
$ref: '#/components/schemas/IntegrationCleanupResponse'
|
|
1023
1130
|
/{app}/{userId}/{hint}/token:
|
|
1024
1131
|
get:
|
|
1025
1132
|
tags:
|
package/auth/authHandler.js
CHANGED
|
@@ -38,6 +38,7 @@ const appCheck = async req => {
|
|
|
38
38
|
return 'Invalid api key';
|
|
39
39
|
}
|
|
40
40
|
req.appRecord = appRecord.dataValues;
|
|
41
|
+
req.auth = { subject: `app:${req.pathParams.app}` };
|
|
41
42
|
return undefined;
|
|
42
43
|
};
|
|
43
44
|
|
|
@@ -53,6 +54,7 @@ const app = async (req, res, next) => {
|
|
|
53
54
|
const adminCheck = async req => {
|
|
54
55
|
const token = getToken({ req });
|
|
55
56
|
if (token !== adminKey) return 'Invalid admin key';
|
|
57
|
+
req.auth = { subject: 'admin' };
|
|
56
58
|
return undefined;
|
|
57
59
|
};
|
|
58
60
|
|
|
@@ -78,6 +80,7 @@ const userCheck = async req => {
|
|
|
78
80
|
if (req.params.userId && userId !== req.params.userId) {
|
|
79
81
|
return 'Non matching user id';
|
|
80
82
|
}
|
|
83
|
+
req.auth = { subject: `user:${userId}` };
|
|
81
84
|
return undefined;
|
|
82
85
|
};
|
|
83
86
|
|
package/controllers/README.md
CHANGED
|
@@ -29,6 +29,7 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
|
|
|
29
29
|
* **Condition 3: `forceRefresh` is true**:
|
|
30
30
|
* This is the catalog refresh owner. The system fetches fresh data from the plugin.
|
|
31
31
|
* Ti2 rejects the request unless the host declared `weeklyProductCatalogSync: true` for the plugin. Manual audit reasons may override timing admission, but not catalog-completeness eligibility.
|
|
32
|
+
* An omitted `fullSyncTrigger` is treated as `manual`, and manual refreshes require a non-blank `admissionOverrideReason`. Weekly scheduler requests must explicitly send `fullSyncTrigger: scheduled`.
|
|
32
33
|
* Unless `fullSyncTrigger: organic` is explicit, a force refresh is a scheduled or manual full-catalog request and rejects `searchInput`, `optionId`, `productId`, `productName`, and `lastUpdatedFrom` selectors. `searchInput: "*"` is unscoped.
|
|
33
34
|
* The `fetchFromPluginAndCache` helper function is invoked. This function:
|
|
34
35
|
* Sets the `pluginExecutionLockKey` before calling the plugin to prevent other concurrent direct calls.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/* globals beforeAll describe it expect */
|
|
2
2
|
const chance = require('chance').Chance();
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const jwt = require('jwt-promise');
|
|
4
5
|
const R = require('ramda');
|
|
5
6
|
|
|
6
7
|
const slugify = require('../../test/slugify');
|
|
8
|
+
const { queue } = require('../../worker/queue');
|
|
7
9
|
let appController = require('../app');
|
|
8
10
|
|
|
9
11
|
const { env: { adminKey, jwtSecret } } = process;
|
|
@@ -13,6 +15,7 @@ describe('app', () => {
|
|
|
13
15
|
const appName = slugify(
|
|
14
16
|
chance.company(),
|
|
15
17
|
);
|
|
18
|
+
const otherAppName = slugify(chance.company());
|
|
16
19
|
const newApp = {
|
|
17
20
|
name: appName,
|
|
18
21
|
packageName: `ti2-${appName}`,
|
|
@@ -21,6 +24,8 @@ describe('app', () => {
|
|
|
21
24
|
let doApiPost;
|
|
22
25
|
let doApiGet;
|
|
23
26
|
let doApiPut;
|
|
27
|
+
let db;
|
|
28
|
+
let plugins;
|
|
24
29
|
let appKey;
|
|
25
30
|
const userId = chance.guid();
|
|
26
31
|
const apiKey = chance.guid();
|
|
@@ -34,8 +39,10 @@ describe('app', () => {
|
|
|
34
39
|
};
|
|
35
40
|
let encodedKey;
|
|
36
41
|
beforeAll(async () => {
|
|
37
|
-
({
|
|
38
|
-
plugins:
|
|
42
|
+
({
|
|
43
|
+
doApiPost, doApiGet, doApiPut, plugins, sqldb: db,
|
|
44
|
+
} = await testUtils({
|
|
45
|
+
plugins: [appName, otherAppName],
|
|
39
46
|
}));
|
|
40
47
|
appController = appController([appName]);
|
|
41
48
|
});
|
|
@@ -69,6 +76,12 @@ describe('app', () => {
|
|
|
69
76
|
},
|
|
70
77
|
});
|
|
71
78
|
expect(parseInt(value, 10)).toBeGreaterThan(0);
|
|
79
|
+
expect(await db.models.CronJobs.count({
|
|
80
|
+
where: { userId, hint: apiKey.split('-')[0], pluginName: appName },
|
|
81
|
+
})).toBeGreaterThan(0);
|
|
82
|
+
expect(await db.models.CronJobs.count({
|
|
83
|
+
where: { userId, hint: apiKey.split('-')[0], pluginName: otherAppName },
|
|
84
|
+
})).toBe(0);
|
|
72
85
|
});
|
|
73
86
|
it('should be able to test a user token for the app', async () => {
|
|
74
87
|
const { valid } = await doApiPost({
|
|
@@ -96,7 +109,39 @@ describe('app', () => {
|
|
|
96
109
|
cron: '0 9 * * *',
|
|
97
110
|
});
|
|
98
111
|
});
|
|
99
|
-
it
|
|
112
|
+
it('replaces the repeatable definition when a cron pattern changes', async () => {
|
|
113
|
+
const plugin = plugins.find(({ name }) => name === appName);
|
|
114
|
+
plugin.jobs[0].cron = '0 10 * * *';
|
|
115
|
+
|
|
116
|
+
await doApiPost({
|
|
117
|
+
url: `/${appName}/${userId}`,
|
|
118
|
+
token: appKey,
|
|
119
|
+
payload: {
|
|
120
|
+
tokenHint: apiKey.split('-')[0],
|
|
121
|
+
token,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const jobs = R.path(
|
|
126
|
+
['jobs'],
|
|
127
|
+
await appController.getAppScheduledJobs({ integrationId: appName, userId }),
|
|
128
|
+
);
|
|
129
|
+
expect(R.head(R.project(['pluginJobId', 'cron'], jobs))).toEqual({
|
|
130
|
+
pluginJobId: 'dailyReport',
|
|
131
|
+
cron: '0 10 * * *',
|
|
132
|
+
});
|
|
133
|
+
const repeatId = crypto.createHash('sha256').update(JSON.stringify({
|
|
134
|
+
pluginName: appName,
|
|
135
|
+
pluginJobId: 'dailyReport',
|
|
136
|
+
userId,
|
|
137
|
+
hint: apiKey.split('-')[0],
|
|
138
|
+
})).digest('hex');
|
|
139
|
+
const repeatDefinitions = (await queue.getRepeatableJobs())
|
|
140
|
+
.filter(({ id }) => id === repeatId);
|
|
141
|
+
expect(repeatDefinitions).toEqual([
|
|
142
|
+
expect.objectContaining({ cron: '0 10 * * *' }),
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
100
145
|
let jobId;
|
|
101
146
|
it('should be able to run a job', async () => {
|
|
102
147
|
let status;
|
|
@@ -134,7 +134,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
134
134
|
const response = await doApiPost({
|
|
135
135
|
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
136
136
|
token: userToken,
|
|
137
|
-
payload: { forceRefresh: true },
|
|
137
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
138
138
|
expectStatusCode: 500,
|
|
139
139
|
});
|
|
140
140
|
|
|
@@ -146,6 +146,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
146
146
|
// NOTE: we SHOULD NOT need to remove the cache first, since we are forceRefreshing, we are testing the endpoint get's called while having a cache created
|
|
147
147
|
const payload = {
|
|
148
148
|
forceRefresh: true,
|
|
149
|
+
fullSyncTrigger: 'scheduled',
|
|
149
150
|
};
|
|
150
151
|
const { products } = await doApiPost({
|
|
151
152
|
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
@@ -253,7 +254,10 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
253
254
|
await doApiPost({
|
|
254
255
|
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
255
256
|
token: userToken,
|
|
256
|
-
payload: {
|
|
257
|
+
payload: {
|
|
258
|
+
forceRefresh: true,
|
|
259
|
+
admissionOverrideReason: 'legacy manual refresh',
|
|
260
|
+
},
|
|
257
261
|
});
|
|
258
262
|
const cacheSavePayload = emitSpy.mock.calls
|
|
259
263
|
.filter(call => call[0] === 'bookingsProductSearch:cache:save')
|
|
@@ -265,6 +269,8 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
265
269
|
expect(cacheSavePayload.hint).toBe(testHint);
|
|
266
270
|
expect(cacheSavePayload.userIdHash).toBeTruthy();
|
|
267
271
|
expect(cacheSavePayload.hintHash).toBeTruthy();
|
|
272
|
+
expect(cacheSavePayload.fullSyncTrigger).toBe('manual');
|
|
273
|
+
expect(cacheSavePayload.admissionOverrideReason).toBe('legacy manual refresh');
|
|
268
274
|
emitSpy.mockRestore();
|
|
269
275
|
});
|
|
270
276
|
|
|
@@ -355,7 +361,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
355
361
|
await doApiPost({
|
|
356
362
|
url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
|
|
357
363
|
token: userToken,
|
|
358
|
-
payload: { forceRefresh: true },
|
|
364
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
359
365
|
}).then(({ products: p }) => {
|
|
360
366
|
products = p; // Assign to the outer scoped 'products'
|
|
361
367
|
});
|
|
@@ -415,7 +421,11 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
415
421
|
await doApiPost({
|
|
416
422
|
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
417
423
|
token: userToken,
|
|
418
|
-
payload: {
|
|
424
|
+
payload: {
|
|
425
|
+
forceRefresh: true,
|
|
426
|
+
fullSyncTrigger: 'scheduled',
|
|
427
|
+
omitServiceCodes: [],
|
|
428
|
+
},
|
|
419
429
|
});
|
|
420
430
|
|
|
421
431
|
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
@@ -642,7 +652,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
642
652
|
const result = await doApiPost({
|
|
643
653
|
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
644
654
|
token: userToken,
|
|
645
|
-
payload: { forceRefresh: true },
|
|
655
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
646
656
|
});
|
|
647
657
|
|
|
648
658
|
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
@@ -687,7 +697,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
687
697
|
await doApiPost({
|
|
688
698
|
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
689
699
|
token: userToken,
|
|
690
|
-
payload: { forceRefresh: true },
|
|
700
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
691
701
|
});
|
|
692
702
|
|
|
693
703
|
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
@@ -725,7 +735,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
725
735
|
const result = await doApiPost({
|
|
726
736
|
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
727
737
|
token: userToken,
|
|
728
|
-
payload: { forceRefresh: true },
|
|
738
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
729
739
|
});
|
|
730
740
|
|
|
731
741
|
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
@@ -764,15 +774,22 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
764
774
|
},
|
|
765
775
|
);
|
|
766
776
|
|
|
767
|
-
it(
|
|
777
|
+
it.each([
|
|
778
|
+
['an omitted trigger', { forceRefresh: true }],
|
|
779
|
+
['a blank reason', {
|
|
780
|
+
forceRefresh: true,
|
|
781
|
+
fullSyncTrigger: 'manual',
|
|
782
|
+
admissionOverrideReason: ' ',
|
|
783
|
+
}],
|
|
784
|
+
])('rejects a manual catalog refresh with %s', async (_description, payload) => {
|
|
768
785
|
const response = await doApiPost({
|
|
769
786
|
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
770
787
|
token: userToken,
|
|
771
|
-
payload
|
|
788
|
+
payload,
|
|
772
789
|
expectStatusCode: 400,
|
|
773
790
|
});
|
|
774
791
|
|
|
775
|
-
expect(response.message).toContain('
|
|
792
|
+
expect(response.message).toContain('requires a non-blank admissionOverrideReason');
|
|
776
793
|
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
777
794
|
});
|
|
778
795
|
|
|
@@ -847,7 +864,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
847
864
|
const result = await doApiPost({
|
|
848
865
|
url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
|
|
849
866
|
token: userToken,
|
|
850
|
-
payload: { forceRefresh: true },
|
|
867
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
851
868
|
});
|
|
852
869
|
|
|
853
870
|
expect(result).toEqual(expect.objectContaining({
|
|
@@ -1130,7 +1147,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
1130
1147
|
const makeForceRequest = () => doApiPost({
|
|
1131
1148
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
1132
1149
|
token: userToken,
|
|
1133
|
-
payload: { forceRefresh: true },
|
|
1150
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
1134
1151
|
});
|
|
1135
1152
|
|
|
1136
1153
|
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
@@ -1185,7 +1202,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
1185
1202
|
const makeForceRequest = () => doApiPost({
|
|
1186
1203
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
1187
1204
|
token: userToken,
|
|
1188
|
-
payload: { forceRefresh: true },
|
|
1205
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
1189
1206
|
});
|
|
1190
1207
|
|
|
1191
1208
|
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
@@ -1236,7 +1253,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
1236
1253
|
const result = await doApiPost({
|
|
1237
1254
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
1238
1255
|
token: userToken,
|
|
1239
|
-
payload: { forceRefresh: true },
|
|
1256
|
+
payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
|
|
1240
1257
|
});
|
|
1241
1258
|
|
|
1242
1259
|
expect(result).toEqual(expect.objectContaining({
|
|
@@ -97,11 +97,25 @@ describe('user', () => {
|
|
|
97
97
|
expect(template).toBeTruthy();
|
|
98
98
|
expect(R.path(['apiKey', 'regExp', 'source'], template)).toBeTruthy();
|
|
99
99
|
});
|
|
100
|
+
it('returns 404 when deleting a hint with no credential or lifecycle', async () => {
|
|
101
|
+
const unknownHint = chance.word({ length: 12 });
|
|
102
|
+
await doApiDelete({
|
|
103
|
+
url: `/${appName}/${userId}`,
|
|
104
|
+
token: userKey,
|
|
105
|
+
payload: { tokenHint: unknownHint },
|
|
106
|
+
expectStatusCode: 404,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(await db.models.IntegrationLifecycle.count({
|
|
110
|
+
where: { userId, integrationId: appName, hint: unknownHint },
|
|
111
|
+
})).toBe(0);
|
|
112
|
+
});
|
|
100
113
|
it('should be able to delete a user/app key', async () => {
|
|
114
|
+
const deletedHint = apiKey.split('-')[0];
|
|
101
115
|
await doApiDelete({
|
|
102
116
|
url: `/${appName}/${userId}`,
|
|
103
117
|
token: userKey,
|
|
104
|
-
payload: { tokenHint:
|
|
118
|
+
payload: { tokenHint: deletedHint },
|
|
105
119
|
});
|
|
106
120
|
const { userAppKeys } = await doApiGet({
|
|
107
121
|
url: `/user/${userId}/apps`,
|
|
@@ -116,6 +130,14 @@ describe('user', () => {
|
|
|
116
130
|
}),
|
|
117
131
|
]),
|
|
118
132
|
);
|
|
133
|
+
const lifecycle = await db.models.IntegrationLifecycle.findOne({
|
|
134
|
+
where: {
|
|
135
|
+
userId,
|
|
136
|
+
integrationId: appName,
|
|
137
|
+
hint: deletedHint,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
expect(lifecycle.requestedBy).toBe(`user:${userId}`);
|
|
119
141
|
});
|
|
120
142
|
it('should be able to create a user/app integration', async () => {
|
|
121
143
|
// set up new token
|
|
@@ -145,6 +167,70 @@ describe('user', () => {
|
|
|
145
167
|
]),
|
|
146
168
|
);
|
|
147
169
|
});
|
|
170
|
+
it('reports deferred deletion as pending until tokenHint finalization', async () => {
|
|
171
|
+
const deferredHint = chance.word({ length: 12 });
|
|
172
|
+
await doApiPost({
|
|
173
|
+
url: `/${appName}/${userId}`,
|
|
174
|
+
token: userKey,
|
|
175
|
+
payload: { tokenHint: deferredHint, token },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const deletion = await doApiDelete({
|
|
179
|
+
url: `/${appName}/${userId}`,
|
|
180
|
+
token: userKey,
|
|
181
|
+
payload: { tokenHint: deferredHint, deferCompletion: true },
|
|
182
|
+
});
|
|
183
|
+
expect(deletion).toEqual(expect.objectContaining({
|
|
184
|
+
message: 'Integration cleanup is awaiting external finalization.',
|
|
185
|
+
cleanup: expect.objectContaining({ status: 'external_cleanup' }),
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const pendingList = await doApiGet({
|
|
189
|
+
url: `/user/${userId}/apps`,
|
|
190
|
+
token: userKey,
|
|
191
|
+
});
|
|
192
|
+
expect(pendingList.userAppKeys).toEqual(expect.arrayContaining([
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
integrationId: appName,
|
|
195
|
+
userId,
|
|
196
|
+
hint: deferredHint,
|
|
197
|
+
cleanupStatus: 'external_cleanup',
|
|
198
|
+
}),
|
|
199
|
+
]));
|
|
200
|
+
|
|
201
|
+
await doApiPost({
|
|
202
|
+
url: `/${appName}/${userId}/cleanup/finalize`,
|
|
203
|
+
token: userKey,
|
|
204
|
+
payload: {
|
|
205
|
+
tokenHint: deferredHint,
|
|
206
|
+
generation: deletion.cleanup.generation,
|
|
207
|
+
},
|
|
208
|
+
expectStatusCode: 400,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const finalized = await doApiPost({
|
|
212
|
+
url: `/${appName}/${userId}/cleanup/finalize`,
|
|
213
|
+
token: userKey,
|
|
214
|
+
payload: {
|
|
215
|
+
tokenHint: deferredHint,
|
|
216
|
+
generation: deletion.cleanup.generation,
|
|
217
|
+
requestId: deletion.cleanup.requestId,
|
|
218
|
+
artifacts: { test: true },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
expect(finalized.cleanup.status).toBe('complete');
|
|
222
|
+
|
|
223
|
+
const completedList = await doApiGet({
|
|
224
|
+
url: `/user/${userId}/apps`,
|
|
225
|
+
token: userKey,
|
|
226
|
+
});
|
|
227
|
+
expect(completedList.userAppKeys).toEqual(expect.not.arrayContaining([
|
|
228
|
+
expect.objectContaining({
|
|
229
|
+
integrationId: appName,
|
|
230
|
+
hint: deferredHint,
|
|
231
|
+
}),
|
|
232
|
+
]));
|
|
233
|
+
});
|
|
148
234
|
it('reports post-save cron setup failure while preserving the committed credential', async () => {
|
|
149
235
|
const postCommitUserId = chance.guid();
|
|
150
236
|
const postCommitUserKey = (await doApiPost({
|