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