ti2 1.0.122 → 1.0.124

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,10 +128,25 @@ 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, fullSyncTrigger: 'scheduled' },
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 = {
118
148
  forceRefresh: true,
149
+ fullSyncTrigger: 'scheduled',
119
150
  };
120
151
  const { products } = await doApiPost({
121
152
  url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
@@ -143,6 +174,25 @@ describe('user: bookings controller - searchProducts', () => {
143
174
  expect(products[0].options.length).toBe(1);
144
175
  expect(products[1].options.length).toBe(2);
145
176
  });
177
+ it('cacheOnly should return the cache and never call the plugin', async () => {
178
+ await doApiPost({
179
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
180
+ token: userToken,
181
+ payload: {},
182
+ });
183
+ travelgatePlugin.searchProducts.mockClear();
184
+ const { products, cacheFound } = await doApiPost({
185
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
186
+ token: userToken,
187
+ payload: { cacheOnly: true, forceRefresh: true },
188
+ });
189
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
190
+ expect(cacheFound).toBe(true);
191
+ expect(Array.isArray(products)).toBeTruthy();
192
+ expect(products.length).toBe(2);
193
+ expect(products[0].options.length).toBe(1);
194
+ expect(products[1].options.length).toBe(2);
195
+ });
146
196
  it('should be able to get booking products with searchInput', async () => {
147
197
 
148
198
  const payload = {
@@ -204,7 +254,10 @@ describe('user: bookings controller - searchProducts', () => {
204
254
  await doApiPost({
205
255
  url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
206
256
  token: userToken,
207
- payload: { forceRefresh: true },
257
+ payload: {
258
+ forceRefresh: true,
259
+ admissionOverrideReason: 'legacy manual refresh',
260
+ },
208
261
  });
209
262
  const cacheSavePayload = emitSpy.mock.calls
210
263
  .filter(call => call[0] === 'bookingsProductSearch:cache:save')
@@ -216,6 +269,53 @@ describe('user: bookings controller - searchProducts', () => {
216
269
  expect(cacheSavePayload.hint).toBe(testHint);
217
270
  expect(cacheSavePayload.userIdHash).toBeTruthy();
218
271
  expect(cacheSavePayload.hintHash).toBeTruthy();
272
+ expect(cacheSavePayload.fullSyncTrigger).toBe('manual');
273
+ expect(cacheSavePayload.admissionOverrideReason).toBe('legacy manual refresh');
274
+ emitSpy.mockRestore();
275
+ });
276
+
277
+ it('carries a manual override reason on cache-save events', async () => {
278
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
279
+ const result = await doApiPost({
280
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
281
+ token: userToken,
282
+ payload: {
283
+ forceRefresh: true,
284
+ fullSyncTrigger: 'manual',
285
+ admissionOverrideReason: ' urgent catalog repair ',
286
+ fullSyncStartedAt: 1700000000,
287
+ fullSyncAdmissionToken: 'admission-token',
288
+ },
289
+ });
290
+ expect(result).toEqual(expect.objectContaining({
291
+ catalogRefreshOutcome: 'cache_updated',
292
+ cacheUpdated: true,
293
+ cachePreserved: false,
294
+ cachedProductCount: 2,
295
+ }));
296
+ const cacheSavePayload = emitSpy.mock.calls
297
+ .filter(call => call[0] === 'bookingsProductSearch:cache:save')
298
+ .map(call => call[1])
299
+ .find(payload => payload.action === 'cache_saved');
300
+
301
+ expect(cacheSavePayload).toEqual(expect.objectContaining({
302
+ fullSyncTrigger: 'manual',
303
+ admissionOverrideReason: 'urgent catalog repair',
304
+ fullSyncStartedAt: 1700000000,
305
+ fullSyncAdmissionToken: 'admission-token',
306
+ }));
307
+ const decisionPayload = emitSpy.mock.calls
308
+ .filter(call => call[0] === 'bookingsProductSearch:cache:decision')
309
+ .map(call => call[1])
310
+ .find(payload => payload.action === 'cache_saved');
311
+ expect(decisionPayload.fullSyncAdmissionToken).toBeUndefined();
312
+ expect(decisionPayload.fullSyncAdmissionTokenHash).toBeTruthy();
313
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
314
+ .not.toHaveProperty('admissionOverrideReason');
315
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
316
+ .not.toHaveProperty('fullSyncStartedAt');
317
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload)
318
+ .not.toHaveProperty('fullSyncAdmissionToken');
219
319
  emitSpy.mockRestore();
220
320
  });
221
321
  });
@@ -261,7 +361,7 @@ describe('user: bookings controller - searchProducts', () => {
261
361
  await doApiPost({
262
362
  url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
263
363
  token: userToken,
264
- payload: { forceRefresh: true },
364
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
265
365
  }).then(({ products: p }) => {
266
366
  products = p; // Assign to the outer scoped 'products'
267
367
  });
@@ -321,7 +421,11 @@ describe('user: bookings controller - searchProducts', () => {
321
421
  await doApiPost({
322
422
  url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
323
423
  token: userToken,
324
- payload: { forceRefresh: true, omitServiceCodes: [] },
424
+ payload: {
425
+ forceRefresh: true,
426
+ fullSyncTrigger: 'scheduled',
427
+ omitServiceCodes: [],
428
+ },
325
429
  });
326
430
 
327
431
  expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
@@ -343,7 +447,7 @@ describe('user: bookings controller - searchProducts', () => {
343
447
  ).toEqual(['AC', 'SM']);
344
448
  });
345
449
 
346
- it('preserves configured omissions in stale background refreshes', async () => {
450
+ it('does not queue a catalog refresh from a stale organic search', async () => {
347
451
  await cache.save({
348
452
  pluginName: testAppName,
349
453
  key: cacheKey,
@@ -365,16 +469,7 @@ describe('user: bookings controller - searchProducts', () => {
365
469
 
366
470
  expect(result.products).toEqual([{ productId: 'stale-product' }]);
367
471
  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
- });
472
+ expect(addJob).not.toHaveBeenCalled();
378
473
  });
379
474
  });
380
475
  describe('cache TTR and lock mechanism', () => {
@@ -421,9 +516,9 @@ describe('user: bookings controller - searchProducts', () => {
421
516
  setTimeout(resolve, 2100);
422
517
  });
423
518
  });
424
- it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
519
+ it('call outside of TTR should serve stale data and not queue a catalog refresh', async () => {
425
520
  travelgatePlugin.searchProducts.mockClear(); // Clear before action
426
- addJob.mockClear(); // Clear addJob mock before this action that should trigger it
521
+ addJob.mockClear();
427
522
  const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
428
523
 
429
524
  const { products } = await doApiPost({
@@ -431,39 +526,15 @@ describe('user: bookings controller - searchProducts', () => {
431
526
  token: userToken,
432
527
  payload: {},
433
528
  });
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
529
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
530
+ expect(addJob).not.toHaveBeenCalled();
437
531
  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 });
532
+ expect(products.length).toBe(2);
461
533
  expect(emitSpy).toHaveBeenCalledWith(
462
534
  'bookingsProductSearch:cache:decision',
463
535
  expect.objectContaining({
464
536
  action: 'stale_served',
465
- reason: 'backgroundRefreshQueued',
466
- jobId: 'mockJobId',
537
+ reason: 'awaitingScheduledRefresh',
467
538
  }),
468
539
  );
469
540
  emitSpy.mockRestore();
@@ -522,7 +593,6 @@ describe('user: bookings controller - searchProducts', () => {
522
593
 
523
594
  it('should return stale products and preserve the cache when refresh yields empty products', async () => {
524
595
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
525
- const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
526
596
  const cacheKeyForTest = hash({
527
597
  userId: testUserId,
528
598
  hint: staleCacheTestHint,
@@ -545,64 +615,61 @@ describe('user: bookings controller - searchProducts', () => {
545
615
  // 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
546
616
  await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
547
617
 
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
-
618
+ // 3. Second call: TTR has expired. Serve stale data and do not queue a catalog refresh.
553
619
  const { products: secondCallResult } = await doApiPost({
554
620
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
555
621
  token: userToken,
556
622
  payload: { searchInput: '' },
557
623
  });
558
624
 
559
- // Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
560
625
  expect(secondCallResult).toEqual(initialProductsInCache);
561
626
  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.
627
+ expect(addJob).not.toHaveBeenCalled();
567
628
  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
629
  });
572
630
 
573
- it('should keep existing non-empty cache when background refresh returns empty products', async () => {
631
+ it('should keep existing non-empty cache when forceRefresh returns empty products', async () => {
574
632
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
575
633
  const cacheKeyForTest = hash({
576
634
  userId: testUserId,
577
635
  hint: staleCacheTestHint,
578
636
  operationId: 'bookingsProductSearch',
579
637
  });
580
- await cache.save({
581
- pluginName: testAppName,
582
- key: cacheKeyForTest,
583
- value: { products: initialProductsInCache },
584
- ttl: 60,
638
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
639
+ await doApiPost({
640
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
641
+ token: userToken,
642
+ payload: { searchInput: '' },
585
643
  });
586
- await cache.save({
644
+ travelgatePlugin.searchProducts.mockReset();
645
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: [] });
646
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
647
+ const lastUpdatedBefore = await cache.get({
587
648
  pluginName: testAppName,
588
649
  key: `${cacheKeyForTest}:lastUpdated`,
589
- value: 1,
590
- ttl: 60,
591
650
  });
592
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
593
651
 
594
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
595
- appKey: testAppName,
596
- userId: testUserId,
597
- hint: staleCacheTestHint,
598
- pluginResult: { products: [] },
599
- requestId: 'empty-refresh-test',
652
+ const result = await doApiPost({
653
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
654
+ token: userToken,
655
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
600
656
  });
601
657
 
602
658
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
603
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
659
+ const lastUpdatedAfter = await cache.get({
660
+ pluginName: testAppName,
661
+ key: `${cacheKeyForTest}:lastUpdated`,
662
+ });
663
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
664
+ expect(result).toEqual(expect.objectContaining({
665
+ products: initialProductsInCache,
666
+ catalogRefreshOutcome: 'empty_result_preserved_cache',
667
+ cacheUpdated: false,
668
+ cachePreserved: true,
669
+ cachedProductCount: initialProductsInCache.length,
670
+ }));
604
671
  expect(cached.products).toEqual(initialProductsInCache);
605
- expect(lastUpdated).toBeGreaterThan(1);
672
+ expect(lastUpdatedAfter).toBe(lastUpdatedBefore);
606
673
  expect(emitSpy).toHaveBeenCalledWith(
607
674
  'bookingsProductSearch:cache:decision',
608
675
  expect.objectContaining({
@@ -612,42 +679,9 @@ describe('user: bookings controller - searchProducts', () => {
612
679
  existingProductCount: initialProductsInCache.length,
613
680
  }),
614
681
  );
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
682
  emitSpy.mockRestore();
624
683
  });
625
684
 
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
685
  it('should not update cache metadata when direct force refresh returns partial products', async () => {
652
686
  const pluginResult = {
653
687
  catalogPartial: true,
@@ -663,7 +697,7 @@ describe('user: bookings controller - searchProducts', () => {
663
697
  await doApiPost({
664
698
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
665
699
  token: userToken,
666
- payload: { forceRefresh: true },
700
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
667
701
  });
668
702
 
669
703
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
@@ -673,8 +707,8 @@ describe('user: bookings controller - searchProducts', () => {
673
707
  expect(lastUpdated).toBeFalsy();
674
708
  });
675
709
 
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' }];
710
+ it('keeps an existing cache and reports it when forceRefresh returns partial products', async () => {
711
+ const initialProductsInCache = [{ productId: 'completeProd1' }];
678
712
  const pluginResult = {
679
713
  catalogPartial: true,
680
714
  products: [{ productId: 'partialProd1' }],
@@ -696,93 +730,149 @@ describe('user: bookings controller - searchProducts', () => {
696
730
  value: 1,
697
731
  ttl: 60,
698
732
  });
699
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
733
+ travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
700
734
 
701
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
702
- appKey: testAppName,
703
- userId: testUserId,
704
- hint: staleCacheTestHint,
705
- pluginResult,
706
- requestId: 'partial-refresh-test',
735
+ const result = await doApiPost({
736
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
737
+ token: userToken,
738
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
707
739
  });
708
740
 
709
741
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
710
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
742
+ const lastUpdated = await cache.get({
743
+ pluginName: testAppName,
744
+ key: `${cacheKeyForTest}:lastUpdated`,
745
+ });
746
+ expect(result).toEqual(expect.objectContaining({
747
+ products: initialProductsInCache,
748
+ catalogRefreshOutcome: 'partial_result_preserved_cache',
749
+ cacheUpdated: false,
750
+ cachePreserved: true,
751
+ cachedProductCount: initialProductsInCache.length,
752
+ }));
711
753
  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();
754
+ expect(lastUpdated).toBe(1);
732
755
  });
733
756
 
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',
757
+ it.each(['searchInput', 'optionId', 'productId', 'productName', 'lastUpdatedFrom'])(
758
+ 'rejects a declared scheduled catalog refresh with the %s selector',
759
+ async selector => {
760
+ const selectorValue = selector === 'optionId' ? ['option-1'] : 'selected';
761
+ const response = await doApiPost({
762
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
763
+ token: userToken,
764
+ payload: {
765
+ forceRefresh: true,
766
+ fullSyncTrigger: 'scheduled',
767
+ [selector]: selectorValue,
768
+ },
769
+ expectStatusCode: 400,
770
+ });
771
+
772
+ expect(response.message).toContain('must not include product selectors');
773
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
774
+ },
775
+ );
776
+
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) => {
785
+ const response = await doApiPost({
786
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
787
+ token: userToken,
788
+ payload,
789
+ expectStatusCode: 400,
739
790
  });
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 () => {}),
791
+
792
+ expect(response.message).toContain('requires a non-blank admissionOverrideReason');
793
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
794
+ });
795
+
796
+ it('rejects a full catalog force refresh without the catalog capability', async () => {
797
+ const originalCapabilities = travelgatePlugin.capabilities;
798
+ travelgatePlugin.capabilities = {};
799
+
800
+ try {
801
+ const response = await doApiPost({
802
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
803
+ token: userToken,
804
+ payload: {
805
+ forceRefresh: true,
806
+ fullSyncTrigger: 'manual',
807
+ admissionOverrideReason: 'catalog repair',
808
+ },
809
+ expectStatusCode: 400,
810
+ });
811
+
812
+ expect(response.message).toContain(
813
+ 'does not support complete product catalog refreshes',
814
+ );
815
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
816
+ } finally {
817
+ travelgatePlugin.capabilities = originalCapabilities;
818
+ }
819
+ });
820
+
821
+ it('treats searchInput wildcard as an unscoped forced catalog refresh', async () => {
822
+ const products = [{ productId: 'catalog-product', options: [] }];
823
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products });
824
+
825
+ const result = await doApiPost({
826
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
827
+ token: userToken,
828
+ payload: {
829
+ forceRefresh: true,
830
+ fullSyncTrigger: 'scheduled',
831
+ searchInput: '*',
748
832
  },
749
- events: {
750
- emit: jest.fn(),
833
+ });
834
+
835
+ expect(result).toEqual(expect.objectContaining({
836
+ products,
837
+ catalogRefreshOutcome: 'cache_updated',
838
+ cachedProductCount: 1,
839
+ }));
840
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
841
+ });
842
+
843
+ it('does not let token configuration overwrite force-refresh outcome fields', async () => {
844
+ const configuredHint = 'refresh-outcome-configuration-hint';
845
+ await globalUtils.appSetup({
846
+ appName: testAppName,
847
+ userId: testUserId,
848
+ tokenHint: configuredHint,
849
+ token: {
850
+ endpoint: 'https://api.travelgatex.com/configuration-test',
851
+ apiKey: chance.guid(),
852
+ configuration: {
853
+ catalogRefreshOutcome: 'configured-value',
854
+ cacheUpdated: false,
855
+ cachePreserved: true,
856
+ cachedProductCount: 999,
857
+ },
751
858
  },
752
- };
859
+ });
860
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({
861
+ products: [{ productId: 'configured-product', options: [] }],
862
+ });
753
863
 
754
- await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
755
- appKey: fakePlugin.name,
756
- userId: 'race-user',
757
- hint: 'race-hint',
758
- pluginResult,
759
- requestId: 'race-refresh-test',
864
+ const result = await doApiPost({
865
+ url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
866
+ token: userToken,
867
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
760
868
  });
761
869
 
762
- expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
763
- key: `${cacheKeyForTest}:lastUpdated`,
764
- }));
765
- expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
766
- key: cacheKeyForTest,
767
- value: { products: [] },
870
+ expect(result).toEqual(expect.objectContaining({
871
+ catalogRefreshOutcome: 'cache_updated',
872
+ cacheUpdated: true,
873
+ cachePreserved: false,
874
+ cachedProductCount: 1,
768
875
  }));
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
876
  });
787
877
  });
788
878
  });
@@ -848,6 +938,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
848
938
  await cache.drop({ pluginName: testAppName, key: cacheKey });
849
939
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
850
940
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
941
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock:outcome` });
851
942
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
852
943
  };
853
944
 
@@ -867,7 +958,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
867
958
  if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
868
959
  });
869
960
 
870
- it('multiple concurrent requests to stale cache should serve stale data and queue only one new refresh job', async () => {
961
+ it('multiple concurrent requests to stale cache should serve stale data and not queue a catalog refresh', async () => {
871
962
  // 1. First call: Populate the cache using the specific lockTestPlugin
872
963
  lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
873
964
 
@@ -884,9 +975,6 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
884
975
  await new Promise(resolve => setTimeout(resolve, 1500));
885
976
 
886
977
  // 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
978
  const makeRequest = () => doApiPost({
891
979
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
892
980
  token: userToken,
@@ -894,52 +982,20 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
894
982
  });
895
983
 
896
984
  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
985
+ requestPromises.push(makeRequest());
900
986
  await global.sleep(50);
901
- requestPromises.push(makeRequest()); // Third request
987
+ requestPromises.push(makeRequest());
988
+ await global.sleep(50);
989
+ requestPromises.push(makeRequest());
902
990
 
903
991
  const results = await Promise.all(requestPromises);
904
992
 
905
- // Assertions:
906
- // a. All requests should serve stale data (the "Initial Product")
907
993
  results.forEach(result => {
908
994
  expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
909
995
  });
910
996
 
911
- // b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
912
997
  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
- }
998
+ expect(addJob).not.toHaveBeenCalled();
943
999
  });
944
1000
 
945
1001
  it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
@@ -1027,7 +1083,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1027
1083
  emitSpy.mockRestore();
1028
1084
  });
1029
1085
 
1030
- it('queues an unscoped refresh when an optionId request finds stale catalog data', async () => {
1086
+ it('serves a stale catalog for an optionId search without queueing a refresh', async () => {
1031
1087
  await clearProductSearchCache();
1032
1088
  const cachedProducts = [{
1033
1089
  productId: 'cached-product',
@@ -1053,13 +1109,23 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1053
1109
 
1054
1110
  expect(result.products).toEqual(cachedProducts);
1055
1111
  expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
1056
- expect(addJob).toHaveBeenCalledTimes(1);
1057
- expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
1112
+ expect(addJob).not.toHaveBeenCalled();
1058
1113
  });
1059
1114
 
1060
1115
  it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
1061
1116
  await clearProductSearchCache();
1062
1117
 
1118
+ const cacheKey = hash({
1119
+ userId: testUserId,
1120
+ hint: ttrTestHint,
1121
+ operationId: 'bookingsProductSearch',
1122
+ });
1123
+ await cache.save({
1124
+ pluginName: testAppName,
1125
+ key: cacheKey,
1126
+ value: { products: [{ productId: 'stale-force-product', options: [] }] },
1127
+ ttl: 60,
1128
+ });
1063
1129
  const refreshedProducts = [{
1064
1130
  productId: 'force-product',
1065
1131
  productName: 'Force Product',
@@ -1081,7 +1147,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1081
1147
  const makeForceRequest = () => doApiPost({
1082
1148
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1083
1149
  token: userToken,
1084
- payload: { forceRefresh: true },
1150
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1085
1151
  });
1086
1152
 
1087
1153
  const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
@@ -1093,11 +1159,121 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
1093
1159
 
1094
1160
  results.forEach(result => {
1095
1161
  expect(result.products).toEqual(refreshedProducts);
1162
+ expect(result).toEqual(expect.objectContaining({
1163
+ catalogRefreshOutcome: 'cache_updated',
1164
+ cacheUpdated: true,
1165
+ cachePreserved: false,
1166
+ cachedProductCount: refreshedProducts.length,
1167
+ }));
1096
1168
  });
1097
1169
  expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
1098
1170
  expect(addJob).not.toHaveBeenCalled();
1099
1171
  });
1100
1172
 
1173
+ it('concurrent forceRefresh waiters receive a preserved-cache outcome', async () => {
1174
+ await clearProductSearchCache();
1175
+
1176
+ const cachedProducts = [{
1177
+ productId: 'preserved-product',
1178
+ productName: 'Preserved Product',
1179
+ options: [],
1180
+ }];
1181
+ const cacheKey = hash({
1182
+ userId: testUserId,
1183
+ hint: ttrTestHint,
1184
+ operationId: 'bookingsProductSearch',
1185
+ });
1186
+ await cache.save({
1187
+ pluginName: testAppName,
1188
+ key: cacheKey,
1189
+ value: { products: cachedProducts },
1190
+ ttl: 60,
1191
+ });
1192
+ lockTestPlugin.searchProducts.mockReset();
1193
+ let releasePluginFetch;
1194
+ const pluginFetchStarted = new Promise(resolve => {
1195
+ lockTestPlugin.searchProducts.mockImplementation(() => {
1196
+ resolve();
1197
+ return new Promise(pluginResolve => {
1198
+ releasePluginFetch = () => pluginResolve({ products: [] });
1199
+ });
1200
+ });
1201
+ });
1202
+ const makeForceRequest = () => doApiPost({
1203
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1204
+ token: userToken,
1205
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1206
+ });
1207
+
1208
+ const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
1209
+ await pluginFetchStarted;
1210
+ await new Promise(resolve => setTimeout(resolve, 50));
1211
+ releasePluginFetch();
1212
+ const results = await Promise.all(requestPromises);
1213
+
1214
+ results.forEach(result => {
1215
+ expect(result).toEqual(expect.objectContaining({
1216
+ products: cachedProducts,
1217
+ catalogRefreshOutcome: 'empty_result_preserved_cache',
1218
+ cacheUpdated: false,
1219
+ cachePreserved: true,
1220
+ cachedProductCount: cachedProducts.length,
1221
+ }));
1222
+ });
1223
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
1224
+ expect(addJob).not.toHaveBeenCalled();
1225
+ });
1226
+
1227
+ it('serves cache with an in-progress outcome when a forceRefresh wait times out', async () => {
1228
+ await clearProductSearchCache();
1229
+
1230
+ const cachedProducts = [{ productId: 'in-progress-product', options: [] }];
1231
+ const cacheKey = hash({
1232
+ userId: testUserId,
1233
+ hint: ttrTestHint,
1234
+ operationId: 'bookingsProductSearch',
1235
+ });
1236
+ const lockKey = `${cacheKey}:lock`;
1237
+ await cache.save({
1238
+ pluginName: testAppName,
1239
+ key: cacheKey,
1240
+ value: { products: cachedProducts },
1241
+ ttl: 60,
1242
+ });
1243
+ await cache.save({
1244
+ pluginName: testAppName,
1245
+ key: lockKey,
1246
+ value: 'active-refresh-owner',
1247
+ ttl: 60,
1248
+ });
1249
+ const originalLockWaitMs = process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
1250
+ process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = '10';
1251
+
1252
+ try {
1253
+ const result = await doApiPost({
1254
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
1255
+ token: userToken,
1256
+ payload: { forceRefresh: true, fullSyncTrigger: 'scheduled' },
1257
+ });
1258
+
1259
+ expect(result).toEqual(expect.objectContaining({
1260
+ products: cachedProducts,
1261
+ catalogRefreshOutcome: 'refresh_in_progress_cache_served',
1262
+ cacheUpdated: false,
1263
+ cachePreserved: true,
1264
+ cachedProductCount: cachedProducts.length,
1265
+ }));
1266
+ expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
1267
+ } finally {
1268
+ await cache.drop({ pluginName: testAppName, key: lockKey });
1269
+ if (originalLockWaitMs === undefined) {
1270
+ delete process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
1271
+ } else {
1272
+ process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = originalLockWaitMs;
1273
+ }
1274
+ }
1275
+ });
1276
+
1101
1277
  it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
1102
1278
  await clearProductSearchCache();
1103
1279
  const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;