ti2 1.0.121 → 1.0.122
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/api.yml +9 -0
- package/controllers/README.md +2 -1
- package/controllers/__tests__/bookings-searchProducts.js +106 -0
- package/controllers/app.js +20 -17
- package/controllers/bookings.js +63 -15
- package/package.json +1 -1
package/api.yml
CHANGED
|
@@ -433,6 +433,15 @@ components:
|
|
|
433
433
|
required: false
|
|
434
434
|
description: Product Name wildcard match
|
|
435
435
|
example: '*bus*'
|
|
436
|
+
omitServiceCodes:
|
|
437
|
+
required: false
|
|
438
|
+
description: Service codes to omit from the product catalog. When the integration has productSearchOmitServiceCodes, that list is used instead.
|
|
439
|
+
example: ['AC', 'SM']
|
|
440
|
+
oneOf:
|
|
441
|
+
- type: string
|
|
442
|
+
- type: array
|
|
443
|
+
items:
|
|
444
|
+
type: string
|
|
436
445
|
credentials:
|
|
437
446
|
required: false
|
|
438
447
|
$ref: '#/components/schemas/integrationCredentials'
|
package/controllers/README.md
CHANGED
|
@@ -7,7 +7,8 @@ The `$bookingsProductSearch` function in `controllers/bookings.js` implements a
|
|
|
7
7
|
1. **Initialization**:
|
|
8
8
|
* Retrieves user, application (`appKey`), and token details (including `hint`).
|
|
9
9
|
* Determines the specific plugin function to call for product search (e.g., `searchProducts` or `searchProductsForItinerary`).
|
|
10
|
-
*
|
|
10
|
+
* Injects the integration's configured `productSearchOmitServiceCodes` into the plugin payload so every caller for that token omits the same service codes. When the setting is empty, the request body is unchanged.
|
|
11
|
+
* Calculates a `cacheKey` based on `userId`, `hint`, and a static `operationId` (`bookingsProductSearch`). Changing the omit setting does not change the key; clear that integration's product-search cache and resync.
|
|
11
12
|
* Defines two distinct lock keys derived from this `cacheKey`:
|
|
12
13
|
* `pluginExecutionLockKey` (resolves to `${cacheKey}:lock`): Used to serialize direct calls to the plugin.
|
|
13
14
|
* `jobQueueLockKey` (resolves to `${cacheKey}:jobLock`): Used to prevent multiple submissions of background refresh jobs.
|
|
@@ -271,6 +271,112 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
271
271
|
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
|
+
describe('configured productSearchOmitServiceCodes', () => {
|
|
275
|
+
const configuredOmitHint = 'configured-product-omit-service-codes';
|
|
276
|
+
let cacheKey;
|
|
277
|
+
|
|
278
|
+
beforeAll(async () => {
|
|
279
|
+
await globalUtils.appSetup({
|
|
280
|
+
appName: testAppName,
|
|
281
|
+
userId: testUserId,
|
|
282
|
+
tokenHint: configuredOmitHint,
|
|
283
|
+
token: {
|
|
284
|
+
endpoint: 'https://api.travelgatex.com/configured-omit',
|
|
285
|
+
apiKey: chance.guid(),
|
|
286
|
+
client: 'tourconnect-configured-omit',
|
|
287
|
+
productSearchOmitServiceCodes: ' sm, AC, ac ',
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
cacheKey = hash({
|
|
291
|
+
userId: testUserId,
|
|
292
|
+
hint: configuredOmitHint,
|
|
293
|
+
operationId: 'bookingsProductSearch',
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
beforeEach(async () => {
|
|
298
|
+
await Promise.all([
|
|
299
|
+
cache.drop({ pluginName: testAppName, key: cacheKey }),
|
|
300
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` }),
|
|
301
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }),
|
|
302
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }),
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('sends configured omissions when the caller omits the field', async () => {
|
|
307
|
+
await doApiPost({
|
|
308
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
309
|
+
token: userToken,
|
|
310
|
+
payload: {},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
314
|
+
expect(
|
|
315
|
+
travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
|
|
316
|
+
).toEqual(['AC', 'SM']);
|
|
317
|
+
expect(await cache.get({ pluginName: testAppName, key: cacheKey })).toBeTruthy();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('keeps configured omissions during an empty-scope force refresh', async () => {
|
|
321
|
+
await doApiPost({
|
|
322
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
323
|
+
token: userToken,
|
|
324
|
+
payload: { forceRefresh: true, omitServiceCodes: [] },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
328
|
+
expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload).toEqual({
|
|
329
|
+
forceRefresh: true,
|
|
330
|
+
omitServiceCodes: ['AC', 'SM'],
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('ignores extra request omit codes once the integration is configured', async () => {
|
|
335
|
+
await doApiPost({
|
|
336
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
337
|
+
token: userToken,
|
|
338
|
+
payload: { omitServiceCodes: ['TR'] },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(
|
|
342
|
+
travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
|
|
343
|
+
).toEqual(['AC', 'SM']);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('preserves configured omissions in stale background refreshes', async () => {
|
|
347
|
+
await cache.save({
|
|
348
|
+
pluginName: testAppName,
|
|
349
|
+
key: cacheKey,
|
|
350
|
+
value: { products: [{ productId: 'stale-product' }] },
|
|
351
|
+
ttl: 60,
|
|
352
|
+
});
|
|
353
|
+
await cache.save({
|
|
354
|
+
pluginName: testAppName,
|
|
355
|
+
key: `${cacheKey}:lastUpdated`,
|
|
356
|
+
value: 1,
|
|
357
|
+
ttl: 60,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const result = await doApiPost({
|
|
361
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
362
|
+
token: userToken,
|
|
363
|
+
payload: {},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(result.products).toEqual([{ productId: 'stale-product' }]);
|
|
367
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
368
|
+
expect(addJob).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(addJob.mock.calls[0][0].payload.payload).toEqual({
|
|
370
|
+
forceRefresh: false,
|
|
371
|
+
omitServiceCodes: ['AC', 'SM'],
|
|
372
|
+
});
|
|
373
|
+
expect(addJob.mock.calls[0][0].postProcess.args).toEqual({
|
|
374
|
+
appKey: testAppName,
|
|
375
|
+
userId: testUserId,
|
|
376
|
+
hint: configuredOmitHint,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
274
380
|
describe('cache TTR and lock mechanism', () => {
|
|
275
381
|
const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
|
|
276
382
|
const shortTTRToken = {
|
package/controllers/app.js
CHANGED
|
@@ -50,7 +50,6 @@ const tokenTemplate = async (req, res, next) => {
|
|
|
50
50
|
try {
|
|
51
51
|
const thePlugin = req.app.plugins.find(({ name }) => name === pluginName);
|
|
52
52
|
assert(thePlugin);
|
|
53
|
-
let template = thePlugin.tokenTemplate();
|
|
54
53
|
const safeRegExp = el => {
|
|
55
54
|
if (!el || !(el.regExp instanceof RegExp)) {
|
|
56
55
|
return el;
|
|
@@ -64,24 +63,28 @@ const tokenTemplate = async (req, res, next) => {
|
|
|
64
63
|
},
|
|
65
64
|
};
|
|
66
65
|
};
|
|
67
|
-
template = R.map(safeRegExp,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
66
|
+
const template = R.map(safeRegExp, {
|
|
67
|
+
...thePlugin.tokenTemplate(),
|
|
68
|
+
// ttlProducts and doNotCallPluginForProducts are being used at ti2 level
|
|
69
|
+
// hence we should just by default allow them for all ti2 plugins
|
|
70
|
+
ttlForProducts: {
|
|
71
|
+
type: 'number',
|
|
72
|
+
regExp: /.+/,
|
|
73
|
+
default: 60 * 60 * 24, // 1 day
|
|
74
|
+
},
|
|
75
|
+
doNotCallPluginForProducts: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
regExp: /.+/,
|
|
78
|
+
default: false,
|
|
79
|
+
},
|
|
80
|
+
productSearchOmitServiceCodes: {
|
|
81
|
+
type: 'text',
|
|
82
|
+
regExp: /^[\sA-Za-z0-9,]*$/,
|
|
83
|
+
default: '',
|
|
84
|
+
description: 'Service codes that every product search for this integration must omit',
|
|
83
85
|
},
|
|
84
86
|
});
|
|
87
|
+
return res.json({ template });
|
|
85
88
|
} catch (err) {
|
|
86
89
|
return next(err);
|
|
87
90
|
}
|
package/controllers/bookings.js
CHANGED
|
@@ -50,7 +50,19 @@ const legacyProductSearchCacheEvents = {
|
|
|
50
50
|
};
|
|
51
51
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
52
|
|
|
53
|
-
const
|
|
53
|
+
const normalizeOmitServiceCodes = (...values) => [...new Set(
|
|
54
|
+
values
|
|
55
|
+
.reduce((items, value) => items.concat(Array.isArray(value) ? value : [value]), [])
|
|
56
|
+
.reduce((items, value) => items.concat(String(value == null ? '' : value).split(',')), [])
|
|
57
|
+
.map(value => value.trim().toUpperCase())
|
|
58
|
+
.filter(Boolean),
|
|
59
|
+
)].sort();
|
|
60
|
+
|
|
61
|
+
const productSearchCacheKey = ({ userId, hint }) => hash({
|
|
62
|
+
userId,
|
|
63
|
+
hint,
|
|
64
|
+
operationId: productSearchOperationId,
|
|
65
|
+
});
|
|
54
66
|
|
|
55
67
|
const productSearchSelectorFields = [
|
|
56
68
|
'searchInput',
|
|
@@ -58,7 +70,6 @@ const productSearchSelectorFields = [
|
|
|
58
70
|
'productId',
|
|
59
71
|
'productName',
|
|
60
72
|
'lastUpdatedFrom',
|
|
61
|
-
'skipAccommodation',
|
|
62
73
|
];
|
|
63
74
|
|
|
64
75
|
const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
|
|
@@ -79,7 +90,17 @@ const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.pro
|
|
|
79
90
|
|
|
80
91
|
const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
|
|
81
92
|
|
|
82
|
-
const emitProductSearchCacheDecision = ({
|
|
93
|
+
const emitProductSearchCacheDecision = ({
|
|
94
|
+
app,
|
|
95
|
+
cacheKey,
|
|
96
|
+
userId,
|
|
97
|
+
hint,
|
|
98
|
+
requestId,
|
|
99
|
+
searchInput,
|
|
100
|
+
optionId,
|
|
101
|
+
forceRefresh,
|
|
102
|
+
startedAt,
|
|
103
|
+
}, action, extra = {}) => {
|
|
83
104
|
if (!app.events || !app.events.emit) return;
|
|
84
105
|
const payload = {
|
|
85
106
|
action,
|
|
@@ -303,27 +324,40 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
303
324
|
requestId,
|
|
304
325
|
headers, // To pass original request headers for background job
|
|
305
326
|
}) => {
|
|
327
|
+
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
328
|
+
assert(userId, 'userId is required');
|
|
329
|
+
assert(appKey, 'appKey is required');
|
|
330
|
+
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
331
|
+
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
332
|
+
|
|
333
|
+
const configuredOmitServiceCodes = normalizeOmitServiceCodes(
|
|
334
|
+
R.path(['productSearchOmitServiceCodes'], token),
|
|
335
|
+
);
|
|
336
|
+
const normalizedRequestBody = {
|
|
337
|
+
...originalRequestBody,
|
|
338
|
+
...(configuredOmitServiceCodes.length ? {
|
|
339
|
+
omitServiceCodes: configuredOmitServiceCodes,
|
|
340
|
+
} : {}),
|
|
341
|
+
};
|
|
342
|
+
|
|
306
343
|
// Extract controller-specific flags from originalRequestBody
|
|
307
344
|
const {
|
|
308
345
|
searchInput = '', // Provide defaults
|
|
309
346
|
optionId = '',
|
|
310
347
|
forceRefresh = false,
|
|
311
|
-
} =
|
|
348
|
+
} = normalizedRequestBody;
|
|
312
349
|
|
|
313
|
-
//
|
|
314
|
-
const payloadForPlugin = {
|
|
315
|
-
|
|
350
|
+
// Keep forceRefresh in the plugin payload so plugins can trigger their own rebuilds.
|
|
351
|
+
const payloadForPlugin = {
|
|
352
|
+
...R.omit(['forceRefresh'], normalizedRequestBody),
|
|
353
|
+
forceRefresh,
|
|
354
|
+
};
|
|
355
|
+
const isScopedSearch = hasProductSearchSelector(normalizedRequestBody);
|
|
316
356
|
const payloadForBackgroundJob = R.omit(
|
|
317
357
|
['credentials', ...productSearchSelectorFields],
|
|
318
358
|
payloadForPlugin,
|
|
319
359
|
);
|
|
320
360
|
|
|
321
|
-
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
322
|
-
assert(userId, 'userId is required');
|
|
323
|
-
assert(appKey, 'appKey is required');
|
|
324
|
-
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
325
|
-
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
326
|
-
|
|
327
361
|
const cacheKey = productSearchCacheKey({ userId, hint });
|
|
328
362
|
const telemetryContext = {
|
|
329
363
|
app,
|
|
@@ -336,7 +370,11 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
336
370
|
forceRefresh,
|
|
337
371
|
startedAt: Date.now(),
|
|
338
372
|
};
|
|
339
|
-
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
|
|
373
|
+
const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(
|
|
374
|
+
telemetryContext,
|
|
375
|
+
action,
|
|
376
|
+
extra,
|
|
377
|
+
);
|
|
340
378
|
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
341
379
|
const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
|
|
342
380
|
|
|
@@ -349,6 +387,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
349
387
|
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
350
388
|
emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
|
|
351
389
|
cacheProductCount: productCount(initialActualCacheContent),
|
|
390
|
+
cacheOptionCount: optionCount(initialActualCacheContent),
|
|
352
391
|
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
353
392
|
ttrMs: ttr * 1000,
|
|
354
393
|
reason: hasPluginExecutionLock ? 'lockActive' : undefined,
|
|
@@ -416,6 +455,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
416
455
|
reason: 'waitedForLeader',
|
|
417
456
|
lockWaitMs,
|
|
418
457
|
cacheProductCount: productCount(cacheContent),
|
|
458
|
+
cacheOptionCount: optionCount(cacheContent),
|
|
419
459
|
});
|
|
420
460
|
return cacheContent;
|
|
421
461
|
}
|
|
@@ -457,6 +497,7 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
457
497
|
reason,
|
|
458
498
|
pluginElapsedMs,
|
|
459
499
|
cacheProductCount: productCount(pluginResults),
|
|
500
|
+
cacheOptionCount: optionCount(pluginResults),
|
|
460
501
|
});
|
|
461
502
|
} else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
|
|
462
503
|
emitDecision('partial_refresh_skipped', {
|
|
@@ -492,7 +533,9 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
492
533
|
emitDecision('cache_hit', {
|
|
493
534
|
reason: 'doNotCallPluginForProducts',
|
|
494
535
|
cacheProductCount: initialActualCacheContent.products.length,
|
|
536
|
+
cacheOptionCount: optionCount(initialActualCacheContent),
|
|
495
537
|
returnedProductCount: searchResults.length,
|
|
538
|
+
returnedOptionCount: optionCount({ products: searchResults }),
|
|
496
539
|
});
|
|
497
540
|
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
498
541
|
}
|
|
@@ -522,7 +565,9 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
522
565
|
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
523
566
|
emitDecision(action, {
|
|
524
567
|
cacheProductCount: initialActualCacheContent.products.length,
|
|
568
|
+
cacheOptionCount: optionCount(initialActualCacheContent),
|
|
525
569
|
returnedProductCount: searchResults.length,
|
|
570
|
+
returnedOptionCount: optionCount({ products: searchResults }),
|
|
526
571
|
cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
|
|
527
572
|
ttrMs: ttr * 1000,
|
|
528
573
|
...extra,
|
|
@@ -879,7 +924,10 @@ const $updateProductSearchCache = plugins => async ({
|
|
|
879
924
|
if (hasCacheableProductResults(pluginResult)) {
|
|
880
925
|
await markRefreshAttempted();
|
|
881
926
|
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
|
|
882
|
-
emitCacheEvent('cache_saved', {
|
|
927
|
+
emitCacheEvent('cache_saved', {
|
|
928
|
+
cacheProductCount: productCount(pluginResult),
|
|
929
|
+
cacheOptionCount: optionCount(pluginResult),
|
|
930
|
+
});
|
|
883
931
|
} else {
|
|
884
932
|
const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
|
|
885
933
|
const existingCacheContent = await app.cache.get({ key: cacheKey });
|