ti2 1.0.122 → 1.0.123

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.
@@ -16,7 +16,6 @@ jest.mock('../../worker/queue', () => ({
16
16
  const { addJob, jobStatus: mockJobStatus } = require('../../worker/queue'); // Now addJob is the mock
17
17
 
18
18
  const testUtils = require('../../test/utils'); // Require the module itself
19
- const bookingsControllerFactory = require('../bookings');
20
19
 
21
20
  // Global setup for the entire test file
22
21
  let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
@@ -24,7 +23,13 @@ let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
24
23
  beforeAll(async () => {
25
24
  // Initialize utils once for the entire test file
26
25
  // Ensure all plugins needed across different describe blocks are listed here.
27
- globalUtils = await testUtils({ plugins: ['lockTestPlugin', 'travelgate'] });
26
+ globalUtils = await testUtils({
27
+ plugins: ['lockTestPlugin', 'travelgate'],
28
+ pluginCapabilities: {
29
+ lockTestPlugin: { weeklyProductCatalogSync: true },
30
+ travelgate: { weeklyProductCatalogSync: true },
31
+ },
32
+ });
28
33
  globalDoApiPost = globalUtils.doApiPost;
29
34
  globalPlugins = globalUtils.plugins; // Array of instantiated plugins from the app instance
30
35
  globalSqldb = globalUtils.sqldb;
@@ -99,6 +104,17 @@ describe('user: bookings controller - searchProducts', () => {
99
104
  });
100
105
 
101
106
  describe('searchProducts', () => {
107
+ it('distinguishes a cache-only miss from a cached empty catalog', async () => {
108
+ const response = await doApiPost({
109
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
110
+ token: userToken,
111
+ payload: { cacheOnly: true },
112
+ });
113
+ expect(response.cacheFound).toBe(false);
114
+ expect(response.products).toEqual([]);
115
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
116
+ });
117
+
102
118
  it('should be able to get bookings products for most users without special setup: no cache', async () => {
103
119
  const payload = {};
104
120
  const { products } = await doApiPost({
@@ -112,6 +128,20 @@ describe('user: bookings controller - searchProducts', () => {
112
128
  expect(products[0].options.length).toBe(1);
113
129
  expect(products[1].options.length).toBe(2);
114
130
  });
131
+ it('rejects plugin results that do not expose the canonical products array', async () => {
132
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ accommodation: [] });
133
+
134
+ const response = await doApiPost({
135
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
136
+ token: userToken,
137
+ payload: { forceRefresh: true },
138
+ expectStatusCode: 500,
139
+ });
140
+
141
+ expect(response.message).toContain(
142
+ 'travelgate product search must return an object with a products array',
143
+ );
144
+ });
115
145
  it('should be able to get booking products: no cache, forceRefresh', async () => {
116
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
117
147
  const payload = {
@@ -143,6 +173,25 @@ describe('user: bookings controller - searchProducts', () => {
143
173
  expect(products[0].options.length).toBe(1);
144
174
  expect(products[1].options.length).toBe(2);
145
175
  });
176
+ it('cacheOnly should return the cache and never call the plugin', async () => {
177
+ await doApiPost({
178
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
179
+ token: userToken,
180
+ payload: {},
181
+ });
182
+ travelgatePlugin.searchProducts.mockClear();
183
+ const { products, cacheFound } = await doApiPost({
184
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
185
+ token: userToken,
186
+ payload: { cacheOnly: true, forceRefresh: true },
187
+ });
188
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
189
+ expect(cacheFound).toBe(true);
190
+ expect(Array.isArray(products)).toBeTruthy();
191
+ expect(products.length).toBe(2);
192
+ expect(products[0].options.length).toBe(1);
193
+ expect(products[1].options.length).toBe(2);
194
+ });
146
195
  it('should be able to get booking products with searchInput', async () => {
147
196
 
148
197
  const payload = {
@@ -218,6 +267,51 @@ describe('user: bookings controller - searchProducts', () => {
218
267
  expect(cacheSavePayload.hintHash).toBeTruthy();
219
268
  emitSpy.mockRestore();
220
269
  });
270
+
271
+ it('carries a manual override reason on cache-save events', async () => {
272
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
273
+ const result = await doApiPost({
274
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
275
+ token: userToken,
276
+ payload: {
277
+ forceRefresh: true,
278
+ fullSyncTrigger: 'manual',
279
+ admissionOverrideReason: ' urgent catalog repair ',
280
+ fullSyncStartedAt: 1700000000,
281
+ fullSyncAdmissionToken: 'admission-token',
282
+ },
283
+ });
284
+ expect(result).toEqual(expect.objectContaining({
285
+ catalogRefreshOutcome: 'cache_updated',
286
+ cacheUpdated: true,
287
+ cachePreserved: false,
288
+ cachedProductCount: 2,
289
+ }));
290
+ const cacheSavePayload = emitSpy.mock.calls
291
+ .filter(call => call[0] === 'bookingsProductSearch:cache:save')
292
+ .map(call => call[1])
293
+ .find(payload => payload.action === 'cache_saved');
294
+
295
+ expect(cacheSavePayload).toEqual(expect.objectContaining({
296
+ fullSyncTrigger: 'manual',
297
+ admissionOverrideReason: 'urgent catalog repair',
298
+ fullSyncStartedAt: 1700000000,
299
+ fullSyncAdmissionToken: 'admission-token',
300
+ }));
301
+ const decisionPayload = emitSpy.mock.calls
302
+ .filter(call => call[0] === 'bookingsProductSearch:cache:decision')
303
+ .map(call => call[1])
304
+ .find(payload => payload.action === 'cache_saved');
305
+ expect(decisionPayload.fullSyncAdmissionToken).toBeUndefined();
306
+ expect(decisionPayload.fullSyncAdmissionTokenHash).toBeTruthy();
307
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
308
+ .not.toHaveProperty('admissionOverrideReason');
309
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
310
+ .not.toHaveProperty('fullSyncStartedAt');
311
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
312
+ .not.toHaveProperty('fullSyncAdmissionToken');
313
+ emitSpy.mockRestore();
314
+ });
221
315
  });
222
316
  describe('doNotCallPluginForProducts flag', () => {
223
317
  const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
@@ -343,7 +437,7 @@ describe('user: bookings controller - searchProducts', () => {
343
437
  ).toEqual(['AC', 'SM']);
344
438
  });
345
439
 
346
- it('preserves configured omissions in stale background refreshes', async () => {
440
+ it('does not queue a catalog refresh from a stale organic search', async () => {
347
441
  await cache.save({
348
442
  pluginName: testAppName,
349
443
  key: cacheKey,
@@ -365,16 +459,7 @@ describe('user: bookings controller - searchProducts', () => {
365
459
 
366
460
  expect(result.products).toEqual([{ productId: 'stale-product' }]);
367
461
  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
- });
462
+ expect(addJob).not.toHaveBeenCalled();
378
463
  });
379
464
  });
380
465
  describe('cache TTR and lock mechanism', () => {
@@ -421,9 +506,9 @@ describe('user: bookings controller - searchProducts', () => {
421
506
  setTimeout(resolve, 2100);
422
507
  });
423
508
  });
424
- it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
509
+ it('call outside of TTR should serve stale data and not queue a catalog refresh', async () => {
425
510
  travelgatePlugin.searchProducts.mockClear(); // Clear before action
426
- addJob.mockClear(); // Clear addJob mock before this action that should trigger it
511
+ addJob.mockClear();
427
512
  const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
428
513
 
429
514
  const { products } = await doApiPost({
@@ -431,39 +516,15 @@ describe('user: bookings controller - searchProducts', () => {
431
516
  token: userToken,
432
517
  payload: {},
433
518
  });
434
- // This synchronous call should serve stale data.
435
- // The plugin should NOT be called by *this* request directly as a background job is queued.
436
519
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
520
+ expect(addJob).not.toHaveBeenCalled();
437
521
  expect(Array.isArray(products)).toBeTruthy();
438
- expect(products.length).toBe(2); // Assuming stale data (from initial cache population) is available and has 2 products
439
-
440
- // Check that addJob was called and verify its parameters
441
- expect(addJob).toHaveBeenCalledTimes(1);
442
-
443
- const jobCall = addJob.mock.calls[0]; // Get the first (and only expected) call to addJob
444
- const jobData = jobCall[0]; // First argument to addJob is the job payload
445
- const jobParams = jobCall[1]; // Second argument is params like removeOnComplete
446
-
447
- const expectedPluginMethod = travelgatePlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
448
-
449
- expect(jobData.type).toBe('plugin');
450
- expect(jobData.pluginName).toBe(testAppName); // 'travelgate'
451
- expect(jobData.method).toBe(expectedPluginMethod);
452
- expect(jobData.token).toBeDefined(); // Token passed to the job
453
- expect(jobData.payload.userId).toBe(testUserId);
454
- expect(jobData.payload.payload).toEqual({ forceRefresh: false }); // Original payload for the plugin method
455
- expect(jobData.postProcess.controller).toBe('bookings');
456
- expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
457
- expect(jobData.postProcess.args.appKey).toBe(testAppName);
458
- expect(jobData.postProcess.args.userId).toBe(testUserId);
459
- expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
460
- expect(jobParams).toEqual({ removeOnComplete: true });
522
+ expect(products.length).toBe(2);
461
523
  expect(emitSpy).toHaveBeenCalledWith(
462
524
  'bookingsProductSearch:cache:decision',
463
525
  expect.objectContaining({
464
526
  action: 'stale_served',
465
- reason: 'backgroundRefreshQueued',
466
- jobId: 'mockJobId',
527
+ reason: 'awaitingScheduledRefresh',
467
528
  }),
468
529
  );
469
530
  emitSpy.mockRestore();
@@ -522,7 +583,6 @@ describe('user: bookings controller - searchProducts', () => {
522
583
 
523
584
  it('should return stale products and preserve the cache when refresh yields empty products', async () => {
524
585
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
525
- const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
526
586
  const cacheKeyForTest = hash({
527
587
  userId: testUserId,
528
588
  hint: staleCacheTestHint,
@@ -545,64 +605,61 @@ describe('user: bookings controller - searchProducts', () => {
545
605
  // 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
546
606
  await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
547
607
 
548
- // 3. Second call: TTR has expired. A background job will be queued.
549
- // The plugin method for the *background job* will return empty results.
550
- // This mock is for the plugin call that the *worker* would make.
551
- travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: productsFromPluginRefresh });
552
-
608
+ // 3. Second call: TTR has expired. Serve stale data and do not queue a catalog refresh.
553
609
  const { products: secondCallResult } = await doApiPost({
554
610
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
555
611
  token: userToken,
556
612
  payload: { searchInput: '' },
557
613
  });
558
614
 
559
- // Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
560
615
  expect(secondCallResult).toEqual(initialProductsInCache);
561
616
  expect(secondCallResult.length).toBeGreaterThan(0);
562
-
563
- // Verify that addJob was called to queue the background refresh.
564
- expect(addJob).toHaveBeenCalledTimes(1);
565
- // The travelgatePlugin.searchProducts mock was for the *job*, so it shouldn't be called by the API directly here.
566
- // The controller serves stale and queues job; it doesn't call plugin directly in this path.
617
+ expect(addJob).not.toHaveBeenCalled();
567
618
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
568
-
569
- // Note: This test verifies the immediate response serves stale data and a job is queued.
570
- // Cache preservation for empty background refreshes is covered below.
571
619
  });
572
620
 
573
- it('should keep existing non-empty cache when background refresh returns empty products', async () => {
621
+ it('should keep existing non-empty cache when forceRefresh returns empty products', async () => {
574
622
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
575
623
  const cacheKeyForTest = hash({
576
624
  userId: testUserId,
577
625
  hint: staleCacheTestHint,
578
626
  operationId: 'bookingsProductSearch',
579
627
  });
580
- await cache.save({
581
- pluginName: testAppName,
582
- key: cacheKeyForTest,
583
- value: { products: initialProductsInCache },
584
- ttl: 60,
628
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
629
+ await doApiPost({
630
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
631
+ token: userToken,
632
+ payload: { searchInput: '' },
585
633
  });
586
- await cache.save({
634
+ travelgatePlugin.searchProducts.mockReset();
635
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: [] });
636
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
637
+ const lastUpdatedBefore = await cache.get({
587
638
  pluginName: testAppName,
588
639
  key: `${cacheKeyForTest}:lastUpdated`,
589
- value: 1,
590
- ttl: 60,
591
640
  });
592
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
593
641
 
594
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
595
- appKey: testAppName,
596
- userId: testUserId,
597
- hint: staleCacheTestHint,
598
- pluginResult: { products: [] },
599
- requestId: 'empty-refresh-test',
642
+ const result = await doApiPost({
643
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
644
+ token: userToken,
645
+ payload: { forceRefresh: true },
600
646
  });
601
647
 
602
648
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
603
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
649
+ const lastUpdatedAfter = await cache.get({
650
+ pluginName: testAppName,
651
+ key: `${cacheKeyForTest}:lastUpdated`,
652
+ });
653
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
654
+ expect(result).toEqual(expect.objectContaining({
655
+ products: initialProductsInCache,
656
+ catalogRefreshOutcome: 'empty_result_preserved_cache',
657
+ cacheUpdated: false,
658
+ cachePreserved: true,
659
+ cachedProductCount: initialProductsInCache.length,
660
+ }));
604
661
  expect(cached.products).toEqual(initialProductsInCache);
605
- expect(lastUpdated).toBeGreaterThan(1);
662
+ expect(lastUpdatedAfter).toBe(lastUpdatedBefore);
606
663
  expect(emitSpy).toHaveBeenCalledWith(
607
664
  'bookingsProductSearch:cache:decision',
608
665
  expect.objectContaining({
@@ -612,42 +669,9 @@ describe('user: bookings controller - searchProducts', () => {
612
669
  existingProductCount: initialProductsInCache.length,
613
670
  }),
614
671
  );
615
- expect(emitSpy).toHaveBeenCalledWith(
616
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
617
- expect.objectContaining({
618
- action: 'empty_refresh_skipped',
619
- reason: 'emptyResultPreservedExistingCache',
620
- cachePreserved: true,
621
- }),
622
- );
623
672
  emitSpy.mockRestore();
624
673
  });
625
674
 
626
- it('should not cache partial product refreshes as complete results', async () => {
627
- const cacheKeyForTest = hash({
628
- userId: testUserId,
629
- hint: staleCacheTestHint,
630
- operationId: 'bookingsProductSearch',
631
- });
632
- await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
633
-
634
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
635
- appKey: testAppName,
636
- userId: testUserId,
637
- hint: staleCacheTestHint,
638
- pluginResult: {
639
- catalogPartial: true,
640
- products: [{ productId: 'partialProd1' }],
641
- },
642
- requestId: 'partial-refresh-test',
643
- });
644
-
645
- const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
646
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
647
- expect(cached).toBeFalsy();
648
- expect(lastUpdated).toBeTruthy();
649
- });
650
-
651
675
  it('should not update cache metadata when direct force refresh returns partial products', async () => {
652
676
  const pluginResult = {
653
677
  catalogPartial: true,
@@ -673,8 +697,8 @@ describe('user: bookings controller - searchProducts', () => {
673
697
  expect(lastUpdated).toBeFalsy();
674
698
  });
675
699
 
676
- it('should keep existing non-empty cache when background refresh returns partial products', async () => {
677
- const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
700
+ it('keeps an existing cache and reports it when forceRefresh returns partial products', async () => {
701
+ const initialProductsInCache = [{ productId: 'completeProd1' }];
678
702
  const pluginResult = {
679
703
  catalogPartial: true,
680
704
  products: [{ productId: 'partialProd1' }],
@@ -696,93 +720,142 @@ describe('user: bookings controller - searchProducts', () => {
696
720
  value: 1,
697
721
  ttl: 60,
698
722
  });
699
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
723
+ travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
700
724
 
701
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
702
- appKey: testAppName,
703
- userId: testUserId,
704
- hint: staleCacheTestHint,
705
- pluginResult,
706
- requestId: 'partial-refresh-test',
725
+ const result = await doApiPost({
726
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
727
+ token: userToken,
728
+ payload: { forceRefresh: true },
707
729
  });
708
730
 
709
731
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
710
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
732
+ const lastUpdated = await cache.get({
733
+ pluginName: testAppName,
734
+ key: `${cacheKeyForTest}:lastUpdated`,
735
+ });
736
+ expect(result).toEqual(expect.objectContaining({
737
+ products: initialProductsInCache,
738
+ catalogRefreshOutcome: 'partial_result_preserved_cache',
739
+ cacheUpdated: false,
740
+ cachePreserved: true,
741
+ cachedProductCount: initialProductsInCache.length,
742
+ }));
711
743
  expect(cached.products).toEqual(initialProductsInCache);
712
- expect(lastUpdated).toBeGreaterThan(1);
713
- expect(emitSpy).toHaveBeenCalledWith(
714
- 'bookingsProductSearch:cache:decision',
715
- expect.objectContaining({
716
- action: 'partial_refresh_skipped',
717
- reason: 'partialResultPreservedExistingCache',
718
- cachePreserved: true,
719
- existingProductCount: initialProductsInCache.length,
720
- returnedProductCount: pluginResult.products.length,
721
- }),
722
- );
723
- expect(emitSpy).toHaveBeenCalledWith(
724
- 'bookingsProductSearch:cache:partialRefreshSkipped',
725
- expect.objectContaining({
726
- action: 'partial_refresh_skipped',
727
- reason: 'partialResultPreservedExistingCache',
728
- cachePreserved: true,
729
- }),
730
- );
731
- emitSpy.mockRestore();
744
+ expect(lastUpdated).toBe(1);
732
745
  });
733
746
 
734
- it('should not cache empty products if non-empty cache appears before the empty save', async () => {
735
- const cacheKeyForTest = hash({
736
- userId: 'race-user',
737
- hint: 'race-hint',
738
- operationId: 'bookingsProductSearch',
747
+ it.each(['searchInput', 'optionId', 'productId', 'productName', 'lastUpdatedFrom'])(
748
+ 'rejects a declared scheduled catalog refresh with the %s selector',
749
+ async selector => {
750
+ const selectorValue = selector === 'optionId' ? ['option-1'] : 'selected';
751
+ const response = await doApiPost({
752
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
753
+ token: userToken,
754
+ payload: {
755
+ forceRefresh: true,
756
+ fullSyncTrigger: 'scheduled',
757
+ [selector]: selectorValue,
758
+ },
759
+ expectStatusCode: 400,
760
+ });
761
+
762
+ expect(response.message).toContain('must not include product selectors');
763
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
764
+ },
765
+ );
766
+
767
+ it('rejects a forced selector when fullSyncTrigger is omitted', async () => {
768
+ const response = await doApiPost({
769
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
770
+ token: userToken,
771
+ payload: { forceRefresh: true, optionId: 'option-1' },
772
+ expectStatusCode: 400,
739
773
  });
740
- const pluginResult = { products: [] };
741
- const fakePlugin = {
742
- name: 'racePlugin',
743
- cache: {
744
- get: jest.fn()
745
- .mockResolvedValueOnce(null)
746
- .mockResolvedValueOnce({ products: [{ productId: 'raceProd1' }] }),
747
- save: jest.fn(async () => {}),
774
+
775
+ expect(response.message).toContain('must not include product selectors');
776
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
777
+ });
778
+
779
+ it('rejects a full catalog force refresh without the catalog capability', async () => {
780
+ const originalCapabilities = travelgatePlugin.capabilities;
781
+ travelgatePlugin.capabilities = {};
782
+
783
+ try {
784
+ const response = await doApiPost({
785
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
786
+ token: userToken,
787
+ payload: {
788
+ forceRefresh: true,
789
+ fullSyncTrigger: 'manual',
790
+ admissionOverrideReason: 'catalog repair',
791
+ },
792
+ expectStatusCode: 400,
793
+ });
794
+
795
+ expect(response.message).toContain(
796
+ 'does not support complete product catalog refreshes',
797
+ );
798
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
799
+ } finally {
800
+ travelgatePlugin.capabilities = originalCapabilities;
801
+ }
802
+ });
803
+
804
+ it('treats searchInput wildcard as an unscoped forced catalog refresh', async () => {
805
+ const products = [{ productId: 'catalog-product', options: [] }];
806
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products });
807
+
808
+ const result = await doApiPost({
809
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
810
+ token: userToken,
811
+ payload: {
812
+ forceRefresh: true,
813
+ fullSyncTrigger: 'scheduled',
814
+ searchInput: '*',
748
815
  },
749
- events: {
750
- emit: jest.fn(),
816
+ });
817
+
818
+ expect(result).toEqual(expect.objectContaining({
819
+ products,
820
+ catalogRefreshOutcome: 'cache_updated',
821
+ cachedProductCount: 1,
822
+ }));
823
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
824
+ });
825
+
826
+ it('does not let token configuration overwrite force-refresh outcome fields', async () => {
827
+ const configuredHint = 'refresh-outcome-configuration-hint';
828
+ await globalUtils.appSetup({
829
+ appName: testAppName,
830
+ userId: testUserId,
831
+ tokenHint: configuredHint,
832
+ token: {
833
+ endpoint: 'https://api.travelgatex.com/configuration-test',
834
+ apiKey: chance.guid(),
835
+ configuration: {
836
+ catalogRefreshOutcome: 'configured-value',
837
+ cacheUpdated: false,
838
+ cachePreserved: true,
839
+ cachedProductCount: 999,
840
+ },
751
841
  },
752
- };
842
+ });
843
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({
844
+ products: [{ productId: 'configured-product', options: [] }],
845
+ });
753
846
 
754
- await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
755
- appKey: fakePlugin.name,
756
- userId: 'race-user',
757
- hint: 'race-hint',
758
- pluginResult,
759
- requestId: 'race-refresh-test',
847
+ const result = await doApiPost({
848
+ url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
849
+ token: userToken,
850
+ payload: { forceRefresh: true },
760
851
  });
761
852
 
762
- expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
763
- key: `${cacheKeyForTest}:lastUpdated`,
853
+ expect(result).toEqual(expect.objectContaining({
854
+ catalogRefreshOutcome: 'cache_updated',
855
+ cacheUpdated: true,
856
+ cachePreserved: false,
857
+ cachedProductCount: 1,
764
858
  }));
765
- expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
766
- key: cacheKeyForTest,
767
- value: { products: [] },
768
- }));
769
- expect(fakePlugin.events.emit).toHaveBeenCalledWith(
770
- 'bookingsProductSearch:cache:decision',
771
- expect.objectContaining({
772
- action: 'empty_refresh_skipped',
773
- reason: 'emptyResultPreservedConcurrentCache',
774
- cachePreserved: true,
775
- existingProductCount: 1,
776
- }),
777
- );
778
- expect(fakePlugin.events.emit).toHaveBeenCalledWith(
779
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
780
- expect.objectContaining({
781
- action: 'empty_refresh_skipped',
782
- reason: 'emptyResultPreservedConcurrentCache',
783
- cachePreserved: true,
784
- }),
785
- );
786
859
  });
787
860
  });
788
861
  });
@@ -848,6 +921,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
848
921
  await cache.drop({ pluginName: testAppName, key: cacheKey });
849
922
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
850
923
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
924
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock:outcome` });
851
925
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
852
926
  };
853
927
 
@@ -867,7 +941,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
867
941
  if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
868
942
  });
869
943
 
870
- it('multiple concurrent requests to stale cache should serve stale data and queue only one new refresh job', async () => {
944
+ it('multiple concurrent requests to stale cache should serve stale data and not queue a catalog refresh', async () => {
871
945
  // 1. First call: Populate the cache using the specific lockTestPlugin
872
946
  lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
873
947
 
@@ -884,9 +958,6 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
884
958
  await new Promise(resolve => setTimeout(resolve, 1500));
885
959
 
886
960
  // 3. Make multiple concurrent requests to the now stale cache
887
- // Mock plugin response for the background refresh. This mock is for the job that addJob is supposed to queue.
888
- lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod2', name: 'Refreshed Product' }] });
889
-
890
961
  const makeRequest = () => doApiPost({
891
962
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
892
963
  token: userToken,
@@ -894,52 +965,20 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
894
965
  });
895
966
 
896
967
  const requestPromises = [];
897
- requestPromises.push(makeRequest()); // First request hits stale cache, should queue job
898
- await global.sleep(50); // Small delay to simulate near concurrency
899
- requestPromises.push(makeRequest()); // Second request should also hit stale cache, but not queue another job
968
+ requestPromises.push(makeRequest());
969
+ await global.sleep(50);
970
+ requestPromises.push(makeRequest());
900
971
  await global.sleep(50);
901
- requestPromises.push(makeRequest()); // Third request
972
+ requestPromises.push(makeRequest());
902
973
 
903
974
  const results = await Promise.all(requestPromises);
904
975
 
905
- // Assertions:
906
- // a. All requests should serve stale data (the "Initial Product")
907
976
  results.forEach(result => {
908
977
  expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
909
978
  });
910
979
 
911
- // b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
912
980
  expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
913
-
914
- // c. addJob should have been called exactly once
915
- expect(addJob).toHaveBeenCalledTimes(1);
916
-
917
- // d. Verify arguments of addJob
918
- if (addJob.mock.calls.length > 0) {
919
- const expectedPluginMethodName = lockTestPlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
920
- expect(addJob).toHaveBeenCalledWith(
921
- expect.objectContaining({
922
- type: 'plugin',
923
- pluginName: testAppName, // This should be 'lockTestPlugin'
924
- method: expectedPluginMethodName,
925
- token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
926
- payload: expect.objectContaining({
927
- payload: { forceRefresh: false }, // originalRequestBody was empty for the product search
928
- userId: testUserId,
929
- }),
930
- postProcess: expect.objectContaining({
931
- controller: 'bookings',
932
- action: '$updateProductSearchCache',
933
- args: expect.objectContaining({
934
- appKey: testAppName, // 'lockTestPlugin'
935
- userId: testUserId,
936
- hint: ttrTestHint,
937
- }),
938
- }),
939
- }),
940
- { removeOnComplete: true }
941
- );
942
- }
981
+ expect(addJob).not.toHaveBeenCalled();
943
982
  });
944
983
 
945
984
  it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
@@ -1027,7 +1066,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1027
1066
  emitSpy.mockRestore();
1028
1067
  });
1029
1068
 
1030
- it('queues an unscoped refresh when an optionId request finds stale catalog data', async () => {
1069
+ it('serves a stale catalog for an optionId search without queueing a refresh', async () => {
1031
1070
  await clearProductSearchCache();
1032
1071
  const cachedProducts = [{
1033
1072
  productId: 'cached-product',
@@ -1053,13 +1092,23 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1053
1092
 
1054
1093
  expect(result.products).toEqual(cachedProducts);
1055
1094
  expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
1056
- expect(addJob).toHaveBeenCalledTimes(1);
1057
- expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
1095
+ expect(addJob).not.toHaveBeenCalled();
1058
1096
  });
1059
1097
 
1060
1098
  it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
1061
1099
  await clearProductSearchCache();
1062
1100
 
1101
+ const cacheKey = hash({
1102
+ userId: testUserId,
1103
+ hint: ttrTestHint,
1104
+ operationId: 'bookingsProductSearch',
1105
+ });
1106
+ await cache.save({
1107
+ pluginName: testAppName,
1108
+ key: cacheKey,
1109
+ value: { products: [{ productId: 'stale-force-product', options: [] }] },
1110
+ ttl: 60,
1111
+ });
1063
1112
  const refreshedProducts = [{
1064
1113
  productId: 'force-product',
1065
1114
  productName: 'Force Product',
@@ -1093,11 +1142,121 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1093
1142
 
1094
1143
  results.forEach(result => {
1095
1144
  expect(result.products).toEqual(refreshedProducts);
1145
+ expect(result).toEqual(expect.objectContaining({
1146
+ catalogRefreshOutcome: 'cache_updated',
1147
+ cacheUpdated: true,
1148
+ cachePreserved: false,
1149
+ cachedProductCount: refreshedProducts.length,
1150
+ }));
1151
+ });
1152
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
1153
+ expect(addJob).not.toHaveBeenCalled();
1154
+ });
1155
+
1156
+ it('concurrent forceRefresh waiters receive a preserved-cache outcome', async () => {
1157
+ await clearProductSearchCache();
1158
+
1159
+ const cachedProducts = [{
1160
+ productId: 'preserved-product',
1161
+ productName: 'Preserved Product',
1162
+ options: [],
1163
+ }];
1164
+ const cacheKey = hash({
1165
+ userId: testUserId,
1166
+ hint: ttrTestHint,
1167
+ operationId: 'bookingsProductSearch',
1168
+ });
1169
+ await cache.save({
1170
+ pluginName: testAppName,
1171
+ key: cacheKey,
1172
+ value: { products: cachedProducts },
1173
+ ttl: 60,
1174
+ });
1175
+ lockTestPlugin.searchProducts.mockReset();
1176
+ let releasePluginFetch;
1177
+ const pluginFetchStarted = new Promise(resolve => {
1178
+ lockTestPlugin.searchProducts.mockImplementation(() => {
1179
+ resolve();
1180
+ return new Promise(pluginResolve => {
1181
+ releasePluginFetch = () => pluginResolve({ products: [] });
1182
+ });
1183
+ });
1184
+ });
1185
+ const makeForceRequest = () => doApiPost({
1186
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1187
+ token: userToken,
1188
+ payload: { forceRefresh: true },
1189
+ });
1190
+
1191
+ const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
1192
+ await pluginFetchStarted;
1193
+ await new Promise(resolve => setTimeout(resolve, 50));
1194
+ releasePluginFetch();
1195
+ const results = await Promise.all(requestPromises);
1196
+
1197
+ results.forEach(result => {
1198
+ expect(result).toEqual(expect.objectContaining({
1199
+ products: cachedProducts,
1200
+ catalogRefreshOutcome: 'empty_result_preserved_cache',
1201
+ cacheUpdated: false,
1202
+ cachePreserved: true,
1203
+ cachedProductCount: cachedProducts.length,
1204
+ }));
1096
1205
  });
1097
1206
  expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
1098
1207
  expect(addJob).not.toHaveBeenCalled();
1099
1208
  });
1100
1209
 
1210
+ it('serves cache with an in-progress outcome when a forceRefresh wait times out', async () => {
1211
+ await clearProductSearchCache();
1212
+
1213
+ const cachedProducts = [{ productId: 'in-progress-product', options: [] }];
1214
+ const cacheKey = hash({
1215
+ userId: testUserId,
1216
+ hint: ttrTestHint,
1217
+ operationId: 'bookingsProductSearch',
1218
+ });
1219
+ const lockKey = `${cacheKey}:lock`;
1220
+ await cache.save({
1221
+ pluginName: testAppName,
1222
+ key: cacheKey,
1223
+ value: { products: cachedProducts },
1224
+ ttl: 60,
1225
+ });
1226
+ await cache.save({
1227
+ pluginName: testAppName,
1228
+ key: lockKey,
1229
+ value: 'active-refresh-owner',
1230
+ ttl: 60,
1231
+ });
1232
+ const originalLockWaitMs = process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
1233
+ process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = '10';
1234
+
1235
+ try {
1236
+ const result = await doApiPost({
1237
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1238
+ token: userToken,
1239
+ payload: { forceRefresh: true },
1240
+ });
1241
+
1242
+ expect(result).toEqual(expect.objectContaining({
1243
+ products: cachedProducts,
1244
+ catalogRefreshOutcome: 'refresh_in_progress_cache_served',
1245
+ cacheUpdated: false,
1246
+ cachePreserved: true,
1247
+ cachedProductCount: cachedProducts.length,
1248
+ }));
1249
+ expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
1250
+ } finally {
1251
+ await cache.drop({ pluginName: testAppName, key: lockKey });
1252
+ if (originalLockWaitMs === undefined) {
1253
+ delete process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
1254
+ } else {
1255
+ process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = originalLockWaitMs;
1256
+ }
1257
+ }
1258
+ });
1259
+
1101
1260
  it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
1102
1261
  await clearProductSearchCache();
1103
1262
  const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;