ti2 1.0.119 → 1.0.120
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.
|
@@ -872,6 +872,85 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
872
872
|
expect(addJob).not.toHaveBeenCalled();
|
|
873
873
|
});
|
|
874
874
|
|
|
875
|
+
it('does not save a cold optionId hydration as the full product catalog', async () => {
|
|
876
|
+
await clearProductSearchCache();
|
|
877
|
+
|
|
878
|
+
const scopedProducts = [{
|
|
879
|
+
productId: 'scoped-product',
|
|
880
|
+
productName: 'Scoped Product',
|
|
881
|
+
options: [{
|
|
882
|
+
optionId: 'scoped-option',
|
|
883
|
+
optionName: 'Scoped Option',
|
|
884
|
+
}],
|
|
885
|
+
}];
|
|
886
|
+
const fullCatalog = [{
|
|
887
|
+
productId: 'catalog-product',
|
|
888
|
+
productName: 'Catalog Product',
|
|
889
|
+
options: [{
|
|
890
|
+
optionId: 'catalog-option',
|
|
891
|
+
optionName: 'Catalog Option',
|
|
892
|
+
}],
|
|
893
|
+
}];
|
|
894
|
+
const emitSpy = jest.spyOn(lockTestPlugin.events, 'emit');
|
|
895
|
+
lockTestPlugin.searchProducts
|
|
896
|
+
.mockResolvedValueOnce({ products: scopedProducts })
|
|
897
|
+
.mockResolvedValueOnce({ products: fullCatalog });
|
|
898
|
+
|
|
899
|
+
const scopedResult = await doApiPost({
|
|
900
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
901
|
+
token: userToken,
|
|
902
|
+
payload: { optionId: ['scoped-option'] },
|
|
903
|
+
});
|
|
904
|
+
const catalogResult = await makeProductSearchRequest();
|
|
905
|
+
|
|
906
|
+
expect(scopedResult.products).toEqual(scopedProducts);
|
|
907
|
+
expect(catalogResult.products).toEqual(fullCatalog);
|
|
908
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(2);
|
|
909
|
+
expect(lockTestPlugin.searchProducts.mock.calls[0][0].payload.optionId).toEqual([
|
|
910
|
+
'scoped-option',
|
|
911
|
+
]);
|
|
912
|
+
expect(lockTestPlugin.searchProducts.mock.calls[1][0].payload.optionId).toBeUndefined();
|
|
913
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
914
|
+
'bookingsProductSearch:cache:decision',
|
|
915
|
+
expect.objectContaining({
|
|
916
|
+
action: 'scoped_result_not_cached',
|
|
917
|
+
returnedProductCount: scopedProducts.length,
|
|
918
|
+
returnedOptionCount: 1,
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
emitSpy.mockRestore();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('queues an unscoped refresh when an optionId request finds stale catalog data', async () => {
|
|
925
|
+
await clearProductSearchCache();
|
|
926
|
+
const cachedProducts = [{
|
|
927
|
+
productId: 'cached-product',
|
|
928
|
+
productName: 'Cached Product',
|
|
929
|
+
options: [{
|
|
930
|
+
optionId: 'cached-option',
|
|
931
|
+
optionName: 'Cached Option',
|
|
932
|
+
}],
|
|
933
|
+
}];
|
|
934
|
+
lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: cachedProducts });
|
|
935
|
+
await makeProductSearchRequest();
|
|
936
|
+
lockTestPlugin.searchProducts.mockClear();
|
|
937
|
+
addJob.mockClear();
|
|
938
|
+
await new Promise(resolve => {
|
|
939
|
+
setTimeout(resolve, 1500);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const result = await doApiPost({
|
|
943
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
944
|
+
token: userToken,
|
|
945
|
+
payload: { optionId: ['cached-option'] },
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
expect(result.products).toEqual(cachedProducts);
|
|
949
|
+
expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
|
|
950
|
+
expect(addJob).toHaveBeenCalledTimes(1);
|
|
951
|
+
expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
|
|
952
|
+
});
|
|
953
|
+
|
|
875
954
|
it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
|
|
876
955
|
await clearProductSearchCache();
|
|
877
956
|
|
package/controllers/bookings.js
CHANGED
|
@@ -52,8 +52,29 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
52
52
|
|
|
53
53
|
const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
|
|
54
54
|
|
|
55
|
+
const productSearchSelectorFields = [
|
|
56
|
+
'searchInput',
|
|
57
|
+
'optionId',
|
|
58
|
+
'productId',
|
|
59
|
+
'productName',
|
|
60
|
+
'lastUpdatedFrom',
|
|
61
|
+
'skipAccommodation',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const hasProductSearchSelector = payload => productSearchSelectorFields.some(field => {
|
|
65
|
+
const value = R.path([field], payload);
|
|
66
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
67
|
+
if (typeof value === 'string') return Boolean(value.trim());
|
|
68
|
+
return value !== undefined && value !== null && value !== false;
|
|
69
|
+
});
|
|
70
|
+
|
|
55
71
|
const productCount = value => R.pathOr([], ['products'], value).length;
|
|
56
72
|
|
|
73
|
+
const optionCount = value => R.pathOr([], ['products'], value).reduce(
|
|
74
|
+
(count, product) => count + R.pathOr([], ['options'], product).length,
|
|
75
|
+
0,
|
|
76
|
+
);
|
|
77
|
+
|
|
57
78
|
const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
|
|
58
79
|
|
|
59
80
|
const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
|
|
@@ -291,7 +312,11 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
291
312
|
|
|
292
313
|
// Payload for the plugin function (func) - pass forceRefresh so plugins can trigger background cache rebuilds
|
|
293
314
|
const payloadForPlugin = { ...R.omit(['forceRefresh'], originalRequestBody), forceRefresh };
|
|
294
|
-
const
|
|
315
|
+
const isScopedSearch = hasProductSearchSelector(originalRequestBody);
|
|
316
|
+
const payloadForBackgroundJob = R.omit(
|
|
317
|
+
['credentials', ...productSearchSelectorFields],
|
|
318
|
+
payloadForPlugin,
|
|
319
|
+
);
|
|
295
320
|
|
|
296
321
|
const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
|
|
297
322
|
assert(userId, 'userId is required');
|
|
@@ -359,6 +384,26 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
359
384
|
|
|
360
385
|
// Helper function to call the plugin, save cache, and return results
|
|
361
386
|
const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
|
|
387
|
+
if (isScopedSearch) {
|
|
388
|
+
const pluginStartedAt = Date.now();
|
|
389
|
+
const pluginResults = await func({
|
|
390
|
+
axios,
|
|
391
|
+
token,
|
|
392
|
+
payload: payloadForPlugin,
|
|
393
|
+
typeDefsAndQueries,
|
|
394
|
+
requestId,
|
|
395
|
+
userId,
|
|
396
|
+
hint,
|
|
397
|
+
});
|
|
398
|
+
emitDecision('scoped_result_not_cached', {
|
|
399
|
+
reason,
|
|
400
|
+
pluginElapsedMs: Date.now() - pluginStartedAt,
|
|
401
|
+
returnedProductCount: productCount(pluginResults),
|
|
402
|
+
returnedOptionCount: optionCount(pluginResults),
|
|
403
|
+
});
|
|
404
|
+
return pluginResults || { products: [] };
|
|
405
|
+
}
|
|
406
|
+
|
|
362
407
|
const lockOwnerToken = crypto.randomBytes(16).toString('hex');
|
|
363
408
|
const lockStartedAt = Date.now();
|
|
364
409
|
const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
|