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 +8 -1
- package/__tests__/cache.js +24 -0
- package/cache.js +39 -30
- package/controllers/__tests__/bookings-searchProducts.js +315 -7
- package/controllers/__tests__/bookings.js +3 -3
- package/controllers/__tests__/user.js +13 -0
- package/controllers/app.js +2 -0
- package/controllers/bookings.js +142 -28
- package/index.js +1 -0
- package/package.json +1 -1
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.
|
package/__tests__/cache.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
318
|
-
travelgatePlugin.searchProducts.
|
|
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.
|
|
345
|
+
if (travelgatePlugin.searchProducts.mockReset) travelgatePlugin.searchProducts.mockReset();
|
|
345
346
|
if (addJob.mockClear) addJob.mockClear();
|
|
346
347
|
});
|
|
347
348
|
|
|
348
|
-
it('should return stale
|
|
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
|
-
//
|
|
391
|
-
|
|
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].
|
|
109
|
+
expect(plugins[0].searchItineraries).toHaveBeenCalled();
|
|
110
110
|
expect(Array.isArray(bookings)).toBeTruthy();
|
|
111
|
-
expect(plugins[0].
|
|
112
|
-
expect(plugins[0].
|
|
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({
|
package/controllers/app.js
CHANGED
package/controllers/bookings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 -
|
|
237
|
-
const
|
|
238
|
-
|
|
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
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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,
|