ti2 1.0.114 → 1.0.116

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.
package/CLAUDE.md CHANGED
@@ -28,6 +28,13 @@ Ti2 (Tourism Information Interchange) is an open-source integration framework fo
28
28
 
29
29
  4. **Code Confidence**: Do not make changes until 95% confident. Ask follow-up questions if uncertain.
30
30
 
31
+ ## Public Repo Privacy
32
+
33
+ - `ti2` is open source. Before opening or updating public PRs, review summaries, inline comments, release notes, or commit messages, scrub internal TourConnect service names and private implementation details.
34
+ - Do not mention internal services such as `pyfilematch`, `filematch`, `tcoutlook`, private frontend names, private trace URLs, private customer identifiers, or internal deployment architecture in public GitHub text.
35
+ - Use generic wording such as `upstream caller`, `internal catalog caller`, `private frontend`, `caller-side timeout`, or `internal workflow` when context is needed.
36
+ - After editing public GitHub text, search the PR body, issue comments, review comments, and review summaries for internal service names before handoff.
37
+
31
38
  ## Common Development Commands
32
39
 
33
40
  ```bash
@@ -156,4 +163,4 @@ export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
156
163
  - SSL configuration uses a per-request interceptor approach for better isolation
157
164
  - The configuration checks domains dynamically on each request
158
165
 
159
- **Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
166
+ **Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.
@@ -15,6 +15,30 @@ describe('cache', () => {
15
15
  });
16
16
 
17
17
 
18
+ describe('saveIfNotExists', () => {
19
+ it('should only save the first value for a key', async () => {
20
+ const key = 'lock:test';
21
+ testKeys.push(key);
22
+
23
+ const firstSaved = await cache.saveIfNotExists({
24
+ pluginName: testPluginName,
25
+ key,
26
+ value: { owner: 'first' },
27
+ ttl: 60,
28
+ });
29
+ const secondSaved = await cache.saveIfNotExists({
30
+ pluginName: testPluginName,
31
+ key,
32
+ value: { owner: 'second' },
33
+ ttl: 60,
34
+ });
35
+
36
+ expect(firstSaved).toBe(true);
37
+ expect(secondSaved).toBe(false);
38
+ expect(await cache.get({ pluginName: testPluginName, key })).toEqual({ owner: 'first' });
39
+ });
40
+ });
41
+
18
42
  describe('scan', () => {
19
43
  it('should return keys matching the pattern without plugin prefix', async () => {
20
44
  // Save some test keys
package/cache.js CHANGED
@@ -5,6 +5,23 @@ const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
5
5
  const cache = new Redis(`${REDIS_URL}/2`);
6
6
  const defaultTTL = 60 * 60; // 1 hour
7
7
 
8
+ const normalizeKey = ({
9
+ key: keyParam,
10
+ fn,
11
+ fnParams,
12
+ useFunctionFallback = false,
13
+ }) => {
14
+ if (useFunctionFallback && !keyParam) {
15
+ return hash({ fn, fnParams });
16
+ }
17
+ if (typeof keyParam !== 'string') {
18
+ return hash(keyParam);
19
+ }
20
+ return keyParam;
21
+ };
22
+
23
+ const buildKey = ({ pluginName, ...args }) => `${pluginName}:${normalizeKey(args)}`;
24
+
8
25
  const save = async ({
9
26
  pluginName,
10
27
  key: keyParam,
@@ -12,12 +29,7 @@ const save = async ({
12
29
  skipTTL,
13
30
  ttl = defaultTTL,
14
31
  }) => {
15
- const key = `${pluginName}:${(() => {
16
- if (typeof keyParam !== 'string') {
17
- return hash(keyParam);
18
- }
19
- return keyParam;
20
- })()}`;
32
+ const key = buildKey({ pluginName, key: keyParam });
21
33
  // console.log('saving key', key);
22
34
  await cache.set(key, JSON.stringify(value));
23
35
  if (!skipTTL) {
@@ -25,16 +37,22 @@ const save = async ({
25
37
  }
26
38
  };
27
39
 
40
+ const saveIfNotExists = async ({
41
+ pluginName,
42
+ key: keyParam,
43
+ value,
44
+ ttl = defaultTTL,
45
+ }) => {
46
+ const key = buildKey({ pluginName, key: keyParam });
47
+ const result = await cache.set(key, JSON.stringify(value), 'EX', ttl, 'NX');
48
+ return result === 'OK';
49
+ };
50
+
28
51
  const get = async ({
29
52
  pluginName,
30
53
  key: keyParam,
31
54
  }) => {
32
- const key = `${pluginName}:${(() => {
33
- if (typeof keyParam !== 'string') {
34
- return hash(keyParam);
35
- }
36
- return keyParam;
37
- })()}`;
55
+ const key = buildKey({ pluginName, key: keyParam });
38
56
  const storeVal = await cache.get(key);
39
57
  if (!storeVal) return storeVal;
40
58
  return JSON.parse(storeVal);
@@ -48,15 +66,7 @@ const getOrExec = async ({
48
66
  fnParams,
49
67
  forceRefresh,
50
68
  }) => {
51
- const key = (() => {
52
- if (!keyParam) {
53
- return hash({ fn, fnParams });
54
- }
55
- if (typeof keyParam !== 'string') {
56
- return hash(keyParam);
57
- }
58
- return keyParam;
59
- })();
69
+ const key = normalizeKey({ key: keyParam, fn, fnParams, useFunctionFallback: true });
60
70
  let value;
61
71
  if (!forceRefresh) {
62
72
  value = await get({ pluginName, key });
@@ -78,15 +88,13 @@ const drop = async ({
78
88
  fn,
79
89
  fnParams,
80
90
  }) => {
81
- const key = `${pluginName}:${(() => {
82
- if (!keyParam) {
83
- return hash({ fn, fnParams });
84
- }
85
- if (typeof keyParam !== 'string') {
86
- return hash(keyParam);
87
- }
88
- return keyParam;
89
- })()}`;
91
+ const key = buildKey({
92
+ pluginName,
93
+ key: keyParam,
94
+ fn,
95
+ fnParams,
96
+ useFunctionFallback: true,
97
+ });
90
98
  // console.log('dropping key', key);
91
99
  await cache.del(key);
92
100
  };
@@ -116,6 +124,7 @@ const scan = ({
116
124
  module.exports = {
117
125
  cache,
118
126
  save,
127
+ saveIfNotExists,
119
128
  getOrExec,
120
129
  drop,
121
130
  get,
@@ -16,6 +16,7 @@ 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');
19
20
 
20
21
  // Global setup for the entire test file
21
22
  let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
@@ -314,8 +315,8 @@ describe('user: bookings controller - searchProducts', () => {
314
315
 
315
316
  beforeEach(async () => {
316
317
  // Clear mocks
317
- if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockClear) {
318
- travelgatePlugin.searchProducts.mockClear();
318
+ if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockReset) {
319
+ travelgatePlugin.searchProducts.mockReset();
319
320
  }
320
321
  if (addJob && addJob.mockClear) {
321
322
  addJob.mockClear();
@@ -341,13 +342,18 @@ describe('user: bookings controller - searchProducts', () => {
341
342
  token: staleCacheTokenConfig,
342
343
  });
343
344
  // Clear mocks that might have been called during appSetup
344
- if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
345
+ if (travelgatePlugin.searchProducts.mockReset) travelgatePlugin.searchProducts.mockReset();
345
346
  if (addJob.mockClear) addJob.mockClear();
346
347
  });
347
348
 
348
- it('should return stale (non-empty) products when TTR expires and refresh yields empty products', async () => {
349
+ it('should return stale products and preserve the cache when refresh yields empty products', async () => {
349
350
  const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
350
351
  const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
352
+ const cacheKeyForTest = hash({
353
+ userId: testUserId,
354
+ hint: staleCacheTestHint,
355
+ operationId: 'bookingsProductSearch',
356
+ });
351
357
 
352
358
  // 1. First call: Populate the cache with initialProductsInCache
353
359
  travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
@@ -387,8 +393,195 @@ describe('user: bookings controller - searchProducts', () => {
387
393
  expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
388
394
 
389
395
  // Note: This test verifies the immediate response serves stale data and a job is queued.
390
- // It does not verify the cache state *after* the job (which would use the empty refresh)
391
- // because $updateProductSearchCache currently caches empty results.
396
+ // Cache preservation for empty background refreshes is covered below.
397
+ });
398
+
399
+ it('should keep existing non-empty cache when background refresh returns empty products', async () => {
400
+ const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
401
+ const cacheKeyForTest = hash({
402
+ userId: testUserId,
403
+ hint: staleCacheTestHint,
404
+ operationId: 'bookingsProductSearch',
405
+ });
406
+ await cache.save({
407
+ pluginName: testAppName,
408
+ key: cacheKeyForTest,
409
+ value: { products: initialProductsInCache },
410
+ ttl: 60,
411
+ });
412
+ await cache.save({
413
+ pluginName: testAppName,
414
+ key: `${cacheKeyForTest}:lastUpdated`,
415
+ value: 1,
416
+ ttl: 60,
417
+ });
418
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
419
+
420
+ await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
421
+ appKey: testAppName,
422
+ userId: testUserId,
423
+ hint: staleCacheTestHint,
424
+ pluginResult: { products: [] },
425
+ requestId: 'empty-refresh-test',
426
+ });
427
+
428
+ const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
429
+ const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
430
+ expect(cached.products).toEqual(initialProductsInCache);
431
+ expect(lastUpdated).toBeGreaterThan(1);
432
+ expect(emitSpy).toHaveBeenCalledWith(
433
+ 'bookingsProductSearch:cache:emptyRefreshSkipped',
434
+ expect.objectContaining({
435
+ reason: 'emptyResultPreservedExistingCache',
436
+ cachePreserved: true,
437
+ existingProductCount: initialProductsInCache.length,
438
+ }),
439
+ );
440
+ emitSpy.mockRestore();
441
+ });
442
+
443
+ it('should not cache partial product refreshes as complete results', async () => {
444
+ const cacheKeyForTest = hash({
445
+ userId: testUserId,
446
+ hint: staleCacheTestHint,
447
+ operationId: 'bookingsProductSearch',
448
+ });
449
+ await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
450
+
451
+ await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
452
+ appKey: testAppName,
453
+ userId: testUserId,
454
+ hint: staleCacheTestHint,
455
+ pluginResult: {
456
+ catalogPartial: true,
457
+ products: [{ productId: 'partialProd1' }],
458
+ },
459
+ requestId: 'partial-refresh-test',
460
+ });
461
+
462
+ const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
463
+ const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
464
+ expect(cached).toBeFalsy();
465
+ expect(lastUpdated).toBeTruthy();
466
+ });
467
+
468
+ it('should not update cache metadata when direct force refresh returns partial products', async () => {
469
+ const pluginResult = {
470
+ catalogPartial: true,
471
+ products: [{ productId: 'partialProd1' }],
472
+ };
473
+ const cacheKeyForTest = hash({
474
+ userId: testUserId,
475
+ hint: staleCacheTestHint,
476
+ operationId: 'bookingsProductSearch',
477
+ });
478
+ travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
479
+
480
+ await doApiPost({
481
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
482
+ token: userToken,
483
+ payload: { forceRefresh: true },
484
+ });
485
+
486
+ const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
487
+ const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
488
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
489
+ expect(cached).toBeFalsy();
490
+ expect(lastUpdated).toBeFalsy();
491
+ });
492
+
493
+ it('should keep existing non-empty cache when background refresh returns partial products', async () => {
494
+ const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
495
+ const pluginResult = {
496
+ catalogPartial: true,
497
+ products: [{ productId: 'partialProd1' }],
498
+ };
499
+ const cacheKeyForTest = hash({
500
+ userId: testUserId,
501
+ hint: staleCacheTestHint,
502
+ operationId: 'bookingsProductSearch',
503
+ });
504
+ await cache.save({
505
+ pluginName: testAppName,
506
+ key: cacheKeyForTest,
507
+ value: { products: initialProductsInCache },
508
+ ttl: 60,
509
+ });
510
+ await cache.save({
511
+ pluginName: testAppName,
512
+ key: `${cacheKeyForTest}:lastUpdated`,
513
+ value: 1,
514
+ ttl: 60,
515
+ });
516
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
517
+
518
+ await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
519
+ appKey: testAppName,
520
+ userId: testUserId,
521
+ hint: staleCacheTestHint,
522
+ pluginResult,
523
+ requestId: 'partial-refresh-test',
524
+ });
525
+
526
+ const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
527
+ const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
528
+ expect(cached.products).toEqual(initialProductsInCache);
529
+ expect(lastUpdated).toBeGreaterThan(1);
530
+ expect(emitSpy).toHaveBeenCalledWith(
531
+ 'bookingsProductSearch:cache:partialRefreshSkipped',
532
+ expect.objectContaining({
533
+ reason: 'partialResultPreservedExistingCache',
534
+ cachePreserved: true,
535
+ existingProductCount: initialProductsInCache.length,
536
+ pluginResult,
537
+ }),
538
+ );
539
+ emitSpy.mockRestore();
540
+ });
541
+
542
+ it('should not cache empty products if non-empty cache appears before the empty save', async () => {
543
+ const cacheKeyForTest = hash({
544
+ userId: 'race-user',
545
+ hint: 'race-hint',
546
+ operationId: 'bookingsProductSearch',
547
+ });
548
+ const pluginResult = { products: [] };
549
+ const fakePlugin = {
550
+ name: 'racePlugin',
551
+ cache: {
552
+ get: jest.fn()
553
+ .mockResolvedValueOnce(null)
554
+ .mockResolvedValueOnce({ products: [{ productId: 'raceProd1' }] }),
555
+ save: jest.fn(async () => {}),
556
+ },
557
+ events: {
558
+ emit: jest.fn(),
559
+ },
560
+ };
561
+
562
+ await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
563
+ appKey: fakePlugin.name,
564
+ userId: 'race-user',
565
+ hint: 'race-hint',
566
+ pluginResult,
567
+ requestId: 'race-refresh-test',
568
+ });
569
+
570
+ expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
571
+ key: `${cacheKeyForTest}:lastUpdated`,
572
+ }));
573
+ expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
574
+ key: cacheKeyForTest,
575
+ value: { products: [] },
576
+ }));
577
+ expect(fakePlugin.events.emit).toHaveBeenCalledWith(
578
+ 'bookingsProductSearch:cache:emptyRefreshSkipped',
579
+ expect.objectContaining({
580
+ reason: 'emptyResultPreservedConcurrentCache',
581
+ cachePreserved: true,
582
+ existingProductCount: 1,
583
+ }),
584
+ );
392
585
  });
393
586
  });
394
587
  });
@@ -445,6 +638,24 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
445
638
  await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }); // jobQueueLock
446
639
  });
447
640
 
641
+ const clearProductSearchCache = async () => {
642
+ const cacheKey = hash({
643
+ userId: testUserId,
644
+ hint: ttrTestHint,
645
+ operationId: 'bookingsProductSearch',
646
+ });
647
+ await cache.drop({ pluginName: testAppName, key: cacheKey });
648
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
649
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
650
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
651
+ };
652
+
653
+ const makeProductSearchRequest = () => doApiPost({
654
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
655
+ token: userToken,
656
+ payload: {},
657
+ });
658
+
448
659
  beforeEach(async () => {
449
660
  // Clear mocks before each test in this suite
450
661
  addJob.mockClear(); // Reset addJob mock calls
@@ -529,5 +740,102 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
529
740
  );
530
741
  }
531
742
  });
532
- });
533
743
 
744
+ it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
745
+ await clearProductSearchCache();
746
+
747
+ const coldProducts = [{
748
+ productId: 'cold-product',
749
+ productName: 'Cold Product',
750
+ options: [{
751
+ optionId: 'cold-option',
752
+ optionName: 'Cold Option',
753
+ }],
754
+ }];
755
+ lockTestPlugin.searchProducts.mockReset();
756
+ const releasePluginFetches = [];
757
+ const pluginFetchStarted = new Promise(resolve => {
758
+ lockTestPlugin.searchProducts.mockImplementation(() => {
759
+ resolve();
760
+ return new Promise(pluginResolve => {
761
+ releasePluginFetches.push(() => pluginResolve({ products: coldProducts }));
762
+ });
763
+ });
764
+ });
765
+
766
+ const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
767
+ await pluginFetchStarted;
768
+ await new Promise(resolve => setTimeout(resolve, 50));
769
+
770
+ releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
771
+ const results = await Promise.all(requestPromises);
772
+
773
+ results.forEach(result => {
774
+ expect(result.products).toEqual(coldProducts);
775
+ });
776
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
777
+ expect(addJob).not.toHaveBeenCalled();
778
+ });
779
+
780
+ it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
781
+ await clearProductSearchCache();
782
+
783
+ lockTestPlugin.searchProducts.mockReset();
784
+ const releasePluginFetches = [];
785
+ const pluginFetchStarted = new Promise(resolve => {
786
+ lockTestPlugin.searchProducts.mockImplementation(() => {
787
+ resolve();
788
+ return new Promise(pluginResolve => {
789
+ releasePluginFetches.push(() => pluginResolve({ products: [] }));
790
+ });
791
+ });
792
+ });
793
+
794
+ const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
795
+ await pluginFetchStarted;
796
+ await new Promise(resolve => setTimeout(resolve, 50));
797
+
798
+ releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
799
+ const results = await Promise.all(requestPromises);
800
+
801
+ results.forEach(result => {
802
+ expect(result.products).toEqual([]);
803
+ });
804
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
805
+ expect(addJob).not.toHaveBeenCalled();
806
+ });
807
+
808
+ it('multiple concurrent requests with no cache should fail when the leader throws', async () => {
809
+ await clearProductSearchCache();
810
+
811
+ lockTestPlugin.searchProducts.mockReset();
812
+ let rejectPluginFetch;
813
+ const pluginFetchStarted = new Promise(resolve => {
814
+ lockTestPlugin.searchProducts.mockImplementation(() => {
815
+ resolve();
816
+ return new Promise((_, reject) => {
817
+ rejectPluginFetch = () => reject(new Error('boom'));
818
+ });
819
+ });
820
+ });
821
+
822
+ const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()]
823
+ .map(requestPromise => requestPromise
824
+ .then(result => ({ status: 'fulfilled', result }))
825
+ .catch(error => ({ status: 'rejected', error })));
826
+ await pluginFetchStarted;
827
+ await new Promise(resolve => setTimeout(resolve, 50));
828
+
829
+ rejectPluginFetch();
830
+ const results = await Promise.all(requestPromises);
831
+
832
+ results.forEach(result => {
833
+ expect(result.status).toBe('rejected');
834
+ });
835
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
836
+ expect(addJob).not.toHaveBeenCalled();
837
+
838
+ const statuses = results.map(result => result.error.response.status).sort();
839
+ expect(statuses).toEqual([500, 503, 503]);
840
+ });
841
+ });
@@ -106,10 +106,10 @@ describe('user: bookings controller', () => {
106
106
  token: userToken,
107
107
  payload,
108
108
  });
109
- expect(plugins[0].searchHotelBooking).toHaveBeenCalled();
109
+ expect(plugins[0].searchItineraries).toHaveBeenCalled();
110
110
  expect(Array.isArray(bookings)).toBeTruthy();
111
- expect(plugins[0].searchHotelBooking.mock.calls[0][0].payload).toEqual(payload);
112
- expect(plugins[0].searchHotelBooking.mock.calls[0][0].token).toEqual(token);
111
+ expect(plugins[0].searchItineraries.mock.calls[0][0].payload).toEqual(payload);
112
+ expect(plugins[0].searchItineraries.mock.calls[0][0].token).toEqual(token);
113
113
  });
114
114
 
115
115
  });
@@ -222,6 +222,19 @@ describe('user', () => {
222
222
  expect(plugins[0].validateToken.mock.calls[0][0].payload).toEqual({
223
223
  tokenHint: apiKey.split('-')[0],
224
224
  });
225
+ expect(plugins[0].validateToken.mock.calls[0][0].userId).toBe(userId);
226
+ expect(plugins[0].validateToken.mock.calls[0][0].hint).toBe(apiKey.split('-')[0]);
227
+ });
228
+ it('testing the user token should not infer a hint when no hint is provided', async () => {
229
+ const { valid } = await doApiPost({
230
+ url: `/${appName}/${userId}/validate`,
231
+ token: userKey,
232
+ payload: {},
233
+ });
234
+ expect(plugins[0].validateToken).toHaveBeenCalled();
235
+ expect(valid).toBe(true);
236
+ expect(plugins[0].validateToken.mock.calls[0][0].userId).toBe(userId);
237
+ expect(plugins[0].validateToken.mock.calls[0][0].hint).toBeUndefined();
225
238
  });
226
239
  it('should be able to delete an app settings', async () => {
227
240
  const returnValue = await doApiDelete({
@@ -261,6 +261,8 @@ const validateAppToken = plugins => async (req, res, next) => {
261
261
  axios,
262
262
  token,
263
263
  payload: req.body,
264
+ userId,
265
+ hint,
264
266
  requestId: req.requestId,
265
267
  });
266
268
  return res.json({ valid });
@@ -28,6 +28,24 @@ const typeDefsAndQueries = {
28
28
  itineraryBookingQuery,
29
29
  };
30
30
 
31
+ const getPositiveIntegerEnv = (name, defaultValue) => {
32
+ const value = Number(process.env[name]);
33
+ const integerValue = Math.floor(value);
34
+ return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
35
+ };
36
+
37
+ const productSearchLockTtlSeconds = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
38
+ const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
39
+ const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
40
+ const emptyProductSearchCacheTtlSeconds = 60;
41
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
42
+
43
+ const createProductSearchUnavailableError = () => {
44
+ const err = new Error('Product search cache refresh did not produce cached results');
45
+ err.status = 503;
46
+ return err;
47
+ };
48
+
31
49
  const getAppAndToken = async ({ plugins, appKey, userId, hint }) => {
32
50
  const app = plugins.find(({ name }) => name === appKey);
33
51
  assert(app, 'could not find the app ' + appKey);
@@ -52,7 +70,8 @@ const bookingsSearch = plugins => async (req, res, next) => {
52
70
  try {
53
71
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
54
72
  assert(app.searchItineraries || app.searchHotelBooking || app.searchBooking, `searchItineraries or searchHotelBooking or searchBooking is not available for ${appKey}`);
55
- const search = (app.searchHotelBooking || app.searchBooking || app.searchItineraries).bind(app);
73
+ // Prefer the dedicated itinerary endpoint so date/filter payloads used by mining are preserved.
74
+ const search = (app.searchItineraries || app.searchHotelBooking || app.searchBooking).bind(app);
56
75
  const results = await search({
57
76
  axios,
58
77
  token,
@@ -122,6 +141,22 @@ const $searchProductList = (products, searchInput = '', optionId = '') => {
122
141
  return filteredProducts;
123
142
  };
124
143
 
144
+ // Plugins may flag incomplete catalog responses with `catalogPartial` or `partial`;
145
+ // both must suppress cache writes so a partial result does not replace a complete cache.
146
+ const hasCacheableProductResults = pluginResults => Boolean(
147
+ pluginResults
148
+ && pluginResults.products
149
+ && pluginResults.products.length > 0
150
+ && !pluginResults.catalogPartial
151
+ && !pluginResults.partial
152
+ );
153
+
154
+ const hasNonEmptyProductCache = cacheContent => Boolean(
155
+ cacheContent
156
+ && cacheContent.products
157
+ && cacheContent.products.length > 0
158
+ );
159
+
125
160
  const $bookingsProductSearch = plugins => async ({
126
161
  axios,
127
162
  appKey,
@@ -159,10 +194,44 @@ const $bookingsProductSearch = plugins => async ({
159
194
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
160
195
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
161
196
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
197
+ assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
198
+
199
+ const getCachedProductSearchResults = async () => {
200
+ const cacheContent = await app.cache.get({ key: cacheKey });
201
+ if (cacheContent && cacheContent.products) return cacheContent;
202
+ return null;
203
+ };
204
+
205
+ const waitForProductSearchCache = async () => {
206
+ const timeoutAt = Date.now() + productSearchLockWaitMs;
207
+ while (Date.now() < timeoutAt) {
208
+ const cacheContent = await getCachedProductSearchResults();
209
+ if (cacheContent) return cacheContent;
210
+
211
+ const lockStillActive = await app.cache.get({ key: pluginExecutionLockKey });
212
+ if (!lockStillActive) return null;
213
+
214
+ await sleep(productSearchLockPollMs);
215
+ }
216
+
217
+ return getCachedProductSearchResults();
218
+ };
219
+
220
+ const acquirePluginExecutionLock = async () => app.cache.saveIfNotExists({
221
+ key: pluginExecutionLockKey,
222
+ value: true,
223
+ ttl: productSearchLockTtlSeconds,
224
+ });
162
225
 
163
226
  // Helper function to call the plugin, save cache, and return results
164
227
  const fetchFromPluginAndCache = async () => {
165
- await app.cache.save({ key: pluginExecutionLockKey, value: true, ttl: 120 });
228
+ const lockAcquired = await acquirePluginExecutionLock();
229
+ if (!lockAcquired) {
230
+ const cacheContent = await waitForProductSearchCache();
231
+ if (cacheContent) return cacheContent;
232
+ throw createProductSearchUnavailableError();
233
+ }
234
+
166
235
  let pluginResults;
167
236
  try {
168
237
  pluginResults = await func({
@@ -175,20 +244,25 @@ const $bookingsProductSearch = plugins => async ({
175
244
  hint,
176
245
  });
177
246
 
178
- // This function is responsible for caching if it fetched good results.
247
+ // This function is responsible for caching if it fetched usable results.
179
248
  // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
180
249
  // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
181
- if (pluginResults && pluginResults.products && pluginResults.products.length > 0) {
250
+ if (hasCacheableProductResults(pluginResults)) {
182
251
  const monthInSeconds = 30 * 24 * 60 * 60;
183
252
  await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
184
253
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: monthInSeconds });
185
254
  app.events.emit('bookingsProductSearch:cache:save', {
186
255
  cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
187
256
  });
257
+ } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
258
+ app.events.emit('bookingsProductSearch:cache:partialRefreshSkipped', {
259
+ cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
260
+ reason: 'directPartialResultNotCached', pluginResult: pluginResults,
261
+ });
262
+ } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
263
+ // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
264
+ await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
188
265
  }
189
- // If pluginResults are empty, cache is NOT updated here by fetchFromPluginAndCache.
190
- // This maintains existing behavior for forceRefresh/initial load paths.
191
- // $updateProductSearchCache has its own logic for handling empty results from background refresh.
192
266
  } finally {
193
267
  await app.cache.drop({ key: pluginExecutionLockKey });
194
268
  }
@@ -233,14 +307,16 @@ const $bookingsProductSearch = plugins => async ({
233
307
  return returnCachedResults();
234
308
  }
235
309
 
236
- // Cache is stale - check if background job already queued
237
- const hasJobQueueLock = await app.cache.get({ key: jobQueueLockKey });
238
- if (hasJobQueueLock) {
310
+ // Cache is stale - queue at most one background refresh job and serve stale data.
311
+ const jobQueueLockAcquired = await app.cache.saveIfNotExists({
312
+ key: jobQueueLockKey,
313
+ value: true,
314
+ ttl: 60,
315
+ });
316
+ if (!jobQueueLockAcquired) {
239
317
  return returnCachedResults();
240
318
  }
241
319
 
242
- // Queue background refresh job and serve stale data
243
- await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 });
244
320
  await addJob({
245
321
  type: 'plugin',
246
322
  pluginName: appKey,
@@ -551,26 +627,64 @@ const $updateProductSearchCache = plugins => async ({
551
627
 
552
628
  const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
553
629
  const monthInSeconds = 30 * 24 * 60 * 60;
630
+ const markRefreshAttempted = () => app.cache.save({
631
+ key: `${cacheKey}:lastUpdated`,
632
+ value: Date.now(),
633
+ ttl: monthInSeconds,
634
+ });
635
+ const emitCacheEvent = (eventName, extra = {}) => app.events.emit(eventName, {
636
+ cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
637
+ ...extra,
638
+ });
554
639
 
555
- // Always update lastUpdated to prevent immediate re-trigger of background jobs
556
- // even if the plugin returned empty results.
557
- await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
558
-
559
- if (pluginResult && pluginResult.products && pluginResult.products.length > 0) {
640
+ if (hasCacheableProductResults(pluginResult)) {
641
+ await markRefreshAttempted();
560
642
  await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
561
- app.events.emit('bookingsProductSearch:cache:save', {
562
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
563
- });
564
- console.log(`[$updateProductSearchCache][requestId: ${requestId}] Saved products to cache for ${appKey}, user ${userId}, hint ${hint}.`);
643
+ emitCacheEvent('bookingsProductSearch:cache:save');
565
644
  } else {
566
- // If plugin returned no products, update the cache to reflect this.
567
- // This prevents serving stale data indefinitely if the source truly has no products anymore.
645
+ const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
646
+ const existingCacheContent = await app.cache.get({ key: cacheKey });
647
+ if (hasNonEmptyProductCache(existingCacheContent)) {
648
+ await markRefreshAttempted();
649
+ const eventExtra = {
650
+ reason: isPartialRefresh ? 'partialResultPreservedExistingCache' : 'emptyResultPreservedExistingCache',
651
+ cachePreserved: true,
652
+ existingProductCount: existingCacheContent.products.length,
653
+ };
654
+ if (isPartialRefresh) eventExtra.pluginResult = pluginResult;
655
+ emitCacheEvent(
656
+ isPartialRefresh ? 'bookingsProductSearch:cache:partialRefreshSkipped' : 'bookingsProductSearch:cache:emptyRefreshSkipped',
657
+ eventExtra,
658
+ );
659
+ return;
660
+ }
661
+
662
+ if (isPartialRefresh) {
663
+ await markRefreshAttempted();
664
+ emitCacheEvent('bookingsProductSearch:cache:partialRefreshSkipped', {
665
+ reason: 'partialResultNotCached',
666
+ cachePreserved: false,
667
+ pluginResult,
668
+ });
669
+ return;
670
+ }
671
+
672
+ // Best-effort race guard: avoid overwriting a non-empty cache written after the first read.
673
+ const latestCacheContent = await app.cache.get({ key: cacheKey });
674
+ if (hasNonEmptyProductCache(latestCacheContent)) {
675
+ await markRefreshAttempted();
676
+ emitCacheEvent('bookingsProductSearch:cache:emptyRefreshSkipped', {
677
+ reason: 'emptyResultPreservedConcurrentCache',
678
+ cachePreserved: true,
679
+ existingProductCount: latestCacheContent.products.length,
680
+ });
681
+ return;
682
+ }
683
+
684
+ // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
685
+ await markRefreshAttempted();
568
686
  await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
569
- app.events.emit('bookingsProductSearch:cache:emptyRefresh', {
570
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
571
- pluginResult, // Log what the plugin returned
572
- });
573
- console.log(`[$updateProductSearchCache][requestId: ${requestId}] Plugin returned empty/no products. Cached empty for ${appKey}, user ${userId}, hint ${hint}.`);
687
+ emitCacheEvent('bookingsProductSearch:cache:emptyRefresh');
574
688
  }
575
689
  };
576
690
 
package/index.js CHANGED
@@ -90,6 +90,7 @@ module.exports = async ({
90
90
  get: args => cache.get({ ...args, pluginName }),
91
91
  getOrExec: args => cache.getOrExec({ ...args, pluginName }),
92
92
  save: args => cache.save({ ...args, pluginName }),
93
+ saveIfNotExists: args => cache.saveIfNotExists({ ...args, pluginName }),
93
94
  scan: args => cache.scan({ ...args, pluginName }),
94
95
  },
95
96
  axios: pluginAxios,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.114",
3
+ "version": "1.0.116",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {