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
 
@@ -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 payloadForBackgroundJob = R.omit(['credentials'], payloadForPlugin);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.119",
3
+ "version": "1.0.120",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {