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