ti2 1.0.121 → 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';
@@ -271,6 +365,103 @@ describe('user: bookings controller - searchProducts', () => {
271
365
 
272
366
  });
273
367
  });
368
+ describe('configured productSearchOmitServiceCodes', () => {
369
+ const configuredOmitHint = 'configured-product-omit-service-codes';
370
+ let cacheKey;
371
+
372
+ beforeAll(async () => {
373
+ await globalUtils.appSetup({
374
+ appName: testAppName,
375
+ userId: testUserId,
376
+ tokenHint: configuredOmitHint,
377
+ token: {
378
+ endpoint: 'https://api.travelgatex.com/configured-omit',
379
+ apiKey: chance.guid(),
380
+ client: 'tourconnect-configured-omit',
381
+ productSearchOmitServiceCodes: ' sm, AC, ac ',
382
+ },
383
+ });
384
+ cacheKey = hash({
385
+ userId: testUserId,
386
+ hint: configuredOmitHint,
387
+ operationId: 'bookingsProductSearch',
388
+ });
389
+ });
390
+
391
+ beforeEach(async () => {
392
+ await Promise.all([
393
+ cache.drop({ pluginName: testAppName, key: cacheKey }),
394
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` }),
395
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }),
396
+ cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }),
397
+ ]);
398
+ });
399
+
400
+ it('sends configured omissions when the caller omits the field', async () => {
401
+ await doApiPost({
402
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
403
+ token: userToken,
404
+ payload: {},
405
+ });
406
+
407
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
408
+ expect(
409
+ travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
410
+ ).toEqual(['AC', 'SM']);
411
+ expect(await cache.get({ pluginName: testAppName, key: cacheKey })).toBeTruthy();
412
+ });
413
+
414
+ it('keeps configured omissions during an empty-scope force refresh', async () => {
415
+ await doApiPost({
416
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
417
+ token: userToken,
418
+ payload: { forceRefresh: true, omitServiceCodes: [] },
419
+ });
420
+
421
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
422
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload).toEqual({
423
+ forceRefresh: true,
424
+ omitServiceCodes: ['AC', 'SM'],
425
+ });
426
+ });
427
+
428
+ it('ignores extra request omit codes once the integration is configured', async () => {
429
+ await doApiPost({
430
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
431
+ token: userToken,
432
+ payload: { omitServiceCodes: ['TR'] },
433
+ });
434
+
435
+ expect(
436
+ travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
437
+ ).toEqual(['AC', 'SM']);
438
+ });
439
+
440
+ it('does not queue a catalog refresh from a stale organic search', async () => {
441
+ await cache.save({
442
+ pluginName: testAppName,
443
+ key: cacheKey,
444
+ value: { products: [{ productId: 'stale-product' }] },
445
+ ttl: 60,
446
+ });
447
+ await cache.save({
448
+ pluginName: testAppName,
449
+ key: `${cacheKey}:lastUpdated`,
450
+ value: 1,
451
+ ttl: 60,
452
+ });
453
+
454
+ const result = await doApiPost({
455
+ url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
456
+ token: userToken,
457
+ payload: {},
458
+ });
459
+
460
+ expect(result.products).toEqual([{ productId: 'stale-product' }]);
461
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
462
+ expect(addJob).not.toHaveBeenCalled();
463
+ });
464
+ });
274
465
  describe('cache TTR and lock mechanism', () => {
275
466
  const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
276
467
  const shortTTRToken = {
@@ -315,9 +506,9 @@ describe('user: bookings controller - searchProducts', () => {
315
506
  setTimeout(resolve, 2100);
316
507
  });
317
508
  });
318
- 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 () => {
319
510
  travelgatePlugin.searchProducts.mockClear(); // Clear before action
320
- addJob.mockClear(); // Clear addJob mock before this action that should trigger it
511
+ addJob.mockClear();
321
512
  const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
322
513
 
323
514
  const { products } = await doApiPost({
@@ -325,39 +516,15 @@ describe('user: bookings controller - searchProducts', () => {
325
516
  token: userToken,
326
517
  payload: {},
327
518
  });
328
- // This synchronous call should serve stale data.
329
- // The plugin should NOT be called by *this* request directly as a background job is queued.
330
519
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
520
+ expect(addJob).not.toHaveBeenCalled();
331
521
  expect(Array.isArray(products)).toBeTruthy();
332
- expect(products.length).toBe(2); // Assuming stale data (from initial cache population) is available and has 2 products
333
-
334
- // Check that addJob was called and verify its parameters
335
- expect(addJob).toHaveBeenCalledTimes(1);
336
-
337
- const jobCall = addJob.mock.calls[0]; // Get the first (and only expected) call to addJob
338
- const jobData = jobCall[0]; // First argument to addJob is the job payload
339
- const jobParams = jobCall[1]; // Second argument is params like removeOnComplete
340
-
341
- const expectedPluginMethod = travelgatePlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
342
-
343
- expect(jobData.type).toBe('plugin');
344
- expect(jobData.pluginName).toBe(testAppName); // 'travelgate'
345
- expect(jobData.method).toBe(expectedPluginMethod);
346
- expect(jobData.token).toBeDefined(); // Token passed to the job
347
- expect(jobData.payload.userId).toBe(testUserId);
348
- expect(jobData.payload.payload).toEqual({ forceRefresh: false }); // Original payload for the plugin method
349
- expect(jobData.postProcess.controller).toBe('bookings');
350
- expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
351
- expect(jobData.postProcess.args.appKey).toBe(testAppName);
352
- expect(jobData.postProcess.args.userId).toBe(testUserId);
353
- expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
354
- expect(jobParams).toEqual({ removeOnComplete: true });
522
+ expect(products.length).toBe(2);
355
523
  expect(emitSpy).toHaveBeenCalledWith(
356
524
  'bookingsProductSearch:cache:decision',
357
525
  expect.objectContaining({
358
526
  action: 'stale_served',
359
- reason: 'backgroundRefreshQueued',
360
- jobId: 'mockJobId',
527
+ reason: 'awaitingScheduledRefresh',
361
528
  }),
362
529
  );
363
530
  emitSpy.mockRestore();
@@ -416,7 +583,6 @@ describe('user: bookings controller - searchProducts', () => {
416
583
 
417
584
  it('should return stale products and preserve the cache when refresh yields empty products', async () => {
418
585
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
419
- const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
420
586
  const cacheKeyForTest = hash({
421
587
  userId: testUserId,
422
588
  hint: staleCacheTestHint,
@@ -439,64 +605,61 @@ describe('user: bookings controller - searchProducts', () => {
439
605
  // 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
440
606
  await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
441
607
 
442
- // 3. Second call: TTR has expired. A background job will be queued.
443
- // The plugin method for the *background job* will return empty results.
444
- // This mock is for the plugin call that the *worker* would make.
445
- travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: productsFromPluginRefresh });
446
-
608
+ // 3. Second call: TTR has expired. Serve stale data and do not queue a catalog refresh.
447
609
  const { products: secondCallResult } = await doApiPost({
448
610
  url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
449
611
  token: userToken,
450
612
  payload: { searchInput: '' },
451
613
  });
452
614
 
453
- // Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
454
615
  expect(secondCallResult).toEqual(initialProductsInCache);
455
616
  expect(secondCallResult.length).toBeGreaterThan(0);
456
-
457
- // Verify that addJob was called to queue the background refresh.
458
- expect(addJob).toHaveBeenCalledTimes(1);
459
- // The travelgatePlugin.searchProducts mock was for the *job*, so it shouldn't be called by the API directly here.
460
- // The controller serves stale and queues job; it doesn't call plugin directly in this path.
617
+ expect(addJob).not.toHaveBeenCalled();
461
618
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
462
-
463
- // Note: This test verifies the immediate response serves stale data and a job is queued.
464
- // Cache preservation for empty background refreshes is covered below.
465
619
  });
466
620
 
467
- 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 () => {
468
622
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
469
623
  const cacheKeyForTest = hash({
470
624
  userId: testUserId,
471
625
  hint: staleCacheTestHint,
472
626
  operationId: 'bookingsProductSearch',
473
627
  });
474
- await cache.save({
475
- pluginName: testAppName,
476
- key: cacheKeyForTest,
477
- value: { products: initialProductsInCache },
478
- ttl: 60,
628
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
629
+ await doApiPost({
630
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
631
+ token: userToken,
632
+ payload: { searchInput: '' },
479
633
  });
480
- 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({
481
638
  pluginName: testAppName,
482
639
  key: `${cacheKeyForTest}:lastUpdated`,
483
- value: 1,
484
- ttl: 60,
485
640
  });
486
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
487
641
 
488
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
489
- appKey: testAppName,
490
- userId: testUserId,
491
- hint: staleCacheTestHint,
492
- pluginResult: { products: [] },
493
- requestId: 'empty-refresh-test',
642
+ const result = await doApiPost({
643
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
644
+ token: userToken,
645
+ payload: { forceRefresh: true },
494
646
  });
495
647
 
496
648
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
497
- 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
+ }));
498
661
  expect(cached.products).toEqual(initialProductsInCache);
499
- expect(lastUpdated).toBeGreaterThan(1);
662
+ expect(lastUpdatedAfter).toBe(lastUpdatedBefore);
500
663
  expect(emitSpy).toHaveBeenCalledWith(
501
664
  'bookingsProductSearch:cache:decision',
502
665
  expect.objectContaining({
@@ -506,42 +669,9 @@ describe('user: bookings controller - searchProducts', () => {
506
669
  existingProductCount: initialProductsInCache.length,
507
670
  }),
508
671
  );
509
- expect(emitSpy).toHaveBeenCalledWith(
510
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
511
- expect.objectContaining({
512
- action: 'empty_refresh_skipped',
513
- reason: 'emptyResultPreservedExistingCache',
514
- cachePreserved: true,
515
- }),
516
- );
517
672
  emitSpy.mockRestore();
518
673
  });
519
674
 
520
- it('should not cache partial product refreshes as complete results', async () => {
521
- const cacheKeyForTest = hash({
522
- userId: testUserId,
523
- hint: staleCacheTestHint,
524
- operationId: 'bookingsProductSearch',
525
- });
526
- await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
527
-
528
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
529
- appKey: testAppName,
530
- userId: testUserId,
531
- hint: staleCacheTestHint,
532
- pluginResult: {
533
- catalogPartial: true,
534
- products: [{ productId: 'partialProd1' }],
535
- },
536
- requestId: 'partial-refresh-test',
537
- });
538
-
539
- const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
540
- const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
541
- expect(cached).toBeFalsy();
542
- expect(lastUpdated).toBeTruthy();
543
- });
544
-
545
675
  it('should not update cache metadata when direct force refresh returns partial products', async () => {
546
676
  const pluginResult = {
547
677
  catalogPartial: true,
@@ -567,8 +697,8 @@ describe('user: bookings controller - searchProducts', () => {
567
697
  expect(lastUpdated).toBeFalsy();
568
698
  });
569
699
 
570
- it('should keep existing non-empty cache when background refresh returns partial products', async () => {
571
- 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' }];
572
702
  const pluginResult = {
573
703
  catalogPartial: true,
574
704
  products: [{ productId: 'partialProd1' }],
@@ -590,93 +720,142 @@ describe('user: bookings controller - searchProducts', () => {
590
720
  value: 1,
591
721
  ttl: 60,
592
722
  });
593
- const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
723
+ travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
594
724
 
595
- await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
596
- appKey: testAppName,
597
- userId: testUserId,
598
- hint: staleCacheTestHint,
599
- pluginResult,
600
- requestId: 'partial-refresh-test',
725
+ const result = await doApiPost({
726
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
727
+ token: userToken,
728
+ payload: { forceRefresh: true },
601
729
  });
602
730
 
603
731
  const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
604
- 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
+ }));
605
743
  expect(cached.products).toEqual(initialProductsInCache);
606
- expect(lastUpdated).toBeGreaterThan(1);
607
- expect(emitSpy).toHaveBeenCalledWith(
608
- 'bookingsProductSearch:cache:decision',
609
- expect.objectContaining({
610
- action: 'partial_refresh_skipped',
611
- reason: 'partialResultPreservedExistingCache',
612
- cachePreserved: true,
613
- existingProductCount: initialProductsInCache.length,
614
- returnedProductCount: pluginResult.products.length,
615
- }),
616
- );
617
- expect(emitSpy).toHaveBeenCalledWith(
618
- 'bookingsProductSearch:cache:partialRefreshSkipped',
619
- expect.objectContaining({
620
- action: 'partial_refresh_skipped',
621
- reason: 'partialResultPreservedExistingCache',
622
- cachePreserved: true,
623
- }),
624
- );
625
- emitSpy.mockRestore();
744
+ expect(lastUpdated).toBe(1);
626
745
  });
627
746
 
628
- it('should not cache empty products if non-empty cache appears before the empty save', async () => {
629
- const cacheKeyForTest = hash({
630
- userId: 'race-user',
631
- hint: 'race-hint',
632
- 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,
633
773
  });
634
- const pluginResult = { products: [] };
635
- const fakePlugin = {
636
- name: 'racePlugin',
637
- cache: {
638
- get: jest.fn()
639
- .mockResolvedValueOnce(null)
640
- .mockResolvedValueOnce({ products: [{ productId: 'raceProd1' }] }),
641
- 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: '*',
642
815
  },
643
- events: {
644
- 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
+ },
645
841
  },
646
- };
842
+ });
843
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({
844
+ products: [{ productId: 'configured-product', options: [] }],
845
+ });
647
846
 
648
- await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
649
- appKey: fakePlugin.name,
650
- userId: 'race-user',
651
- hint: 'race-hint',
652
- pluginResult,
653
- requestId: 'race-refresh-test',
847
+ const result = await doApiPost({
848
+ url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
849
+ token: userToken,
850
+ payload: { forceRefresh: true },
654
851
  });
655
852
 
656
- expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
657
- key: `${cacheKeyForTest}:lastUpdated`,
658
- }));
659
- expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
660
- key: cacheKeyForTest,
661
- value: { products: [] },
853
+ expect(result).toEqual(expect.objectContaining({
854
+ catalogRefreshOutcome: 'cache_updated',
855
+ cacheUpdated: true,
856
+ cachePreserved: false,
857
+ cachedProductCount: 1,
662
858
  }));
663
- expect(fakePlugin.events.emit).toHaveBeenCalledWith(
664
- 'bookingsProductSearch:cache:decision',
665
- expect.objectContaining({
666
- action: 'empty_refresh_skipped',
667
- reason: 'emptyResultPreservedConcurrentCache',
668
- cachePreserved: true,
669
- existingProductCount: 1,
670
- }),
671
- );
672
- expect(fakePlugin.events.emit).toHaveBeenCalledWith(
673
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
674
- expect.objectContaining({
675
- action: 'empty_refresh_skipped',
676
- reason: 'emptyResultPreservedConcurrentCache',
677
- cachePreserved: true,
678
- }),
679
- );
680
859
  });
681
860
  });
682
861
  });
@@ -742,6 +921,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
742
921
  await cache.drop({ pluginName: testAppName, key: cacheKey });
743
922
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
744
923
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
924
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock:outcome` });
745
925
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
746
926
  };
747
927
 
@@ -761,7 +941,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
761
941
  if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
762
942
  });
763
943
 
764
- 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 () => {
765
945
  // 1. First call: Populate the cache using the specific lockTestPlugin
766
946
  lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
767
947
 
@@ -778,9 +958,6 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
778
958
  await new Promise(resolve => setTimeout(resolve, 1500));
779
959
 
780
960
  // 3. Make multiple concurrent requests to the now stale cache
781
- // Mock plugin response for the background refresh. This mock is for the job that addJob is supposed to queue.
782
- lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod2', name: 'Refreshed Product' }] });
783
-
784
961
  const makeRequest = () => doApiPost({
785
962
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
786
963
  token: userToken,
@@ -788,52 +965,20 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
788
965
  });
789
966
 
790
967
  const requestPromises = [];
791
- requestPromises.push(makeRequest()); // First request hits stale cache, should queue job
792
- await global.sleep(50); // Small delay to simulate near concurrency
793
- 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());
794
971
  await global.sleep(50);
795
- requestPromises.push(makeRequest()); // Third request
972
+ requestPromises.push(makeRequest());
796
973
 
797
974
  const results = await Promise.all(requestPromises);
798
975
 
799
- // Assertions:
800
- // a. All requests should serve stale data (the "Initial Product")
801
976
  results.forEach(result => {
802
977
  expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
803
978
  });
804
979
 
805
- // b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
806
980
  expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
807
-
808
- // c. addJob should have been called exactly once
809
- expect(addJob).toHaveBeenCalledTimes(1);
810
-
811
- // d. Verify arguments of addJob
812
- if (addJob.mock.calls.length > 0) {
813
- const expectedPluginMethodName = lockTestPlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
814
- expect(addJob).toHaveBeenCalledWith(
815
- expect.objectContaining({
816
- type: 'plugin',
817
- pluginName: testAppName, // This should be 'lockTestPlugin'
818
- method: expectedPluginMethodName,
819
- token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
820
- payload: expect.objectContaining({
821
- payload: { forceRefresh: false }, // originalRequestBody was empty for the product search
822
- userId: testUserId,
823
- }),
824
- postProcess: expect.objectContaining({
825
- controller: 'bookings',
826
- action: '$updateProductSearchCache',
827
- args: expect.objectContaining({
828
- appKey: testAppName, // 'lockTestPlugin'
829
- userId: testUserId,
830
- hint: ttrTestHint,
831
- }),
832
- }),
833
- }),
834
- { removeOnComplete: true }
835
- );
836
- }
981
+ expect(addJob).not.toHaveBeenCalled();
837
982
  });
838
983
 
839
984
  it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
@@ -921,7 +1066,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
921
1066
  emitSpy.mockRestore();
922
1067
  });
923
1068
 
924
- 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 () => {
925
1070
  await clearProductSearchCache();
926
1071
  const cachedProducts = [{
927
1072
  productId: 'cached-product',
@@ -947,13 +1092,23 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
947
1092
 
948
1093
  expect(result.products).toEqual(cachedProducts);
949
1094
  expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
950
- expect(addJob).toHaveBeenCalledTimes(1);
951
- expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
1095
+ expect(addJob).not.toHaveBeenCalled();
952
1096
  });
953
1097
 
954
1098
  it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
955
1099
  await clearProductSearchCache();
956
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
+ });
957
1112
  const refreshedProducts = [{
958
1113
  productId: 'force-product',
959
1114
  productName: 'Force Product',
@@ -987,11 +1142,121 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
987
1142
 
988
1143
  results.forEach(result => {
989
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
+ }));
990
1151
  });
991
1152
  expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
992
1153
  expect(addJob).not.toHaveBeenCalled();
993
1154
  });
994
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
+ }));
1205
+ });
1206
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
1207
+ expect(addJob).not.toHaveBeenCalled();
1208
+ });
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
+
995
1260
  it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
996
1261
  await clearProductSearchCache();
997
1262
  const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;