ti2 1.0.121 → 1.0.123
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/__tests__/plugin-capabilities.js +42 -0
- package/api.yml +84 -12
- package/controllers/README.md +25 -35
- package/controllers/__tests__/bookings-searchProducts.js +481 -216
- package/controllers/__tests__/product-search-ttr.js +39 -0
- package/controllers/__tests__/user.js +5 -1
- package/controllers/app.js +20 -17
- package/controllers/bookings.js +337 -163
- 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';
|
|
@@ -271,6 +365,103 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
271
365
|
|
|
272
366
|
});
|
|
273
367
|
});
|
|
368
|
+
describe('configured productSearchOmitServiceCodes', () => {
|
|
369
|
+
const configuredOmitHint = 'configured-product-omit-service-codes';
|
|
370
|
+
let cacheKey;
|
|
371
|
+
|
|
372
|
+
beforeAll(async () => {
|
|
373
|
+
await globalUtils.appSetup({
|
|
374
|
+
appName: testAppName,
|
|
375
|
+
userId: testUserId,
|
|
376
|
+
tokenHint: configuredOmitHint,
|
|
377
|
+
token: {
|
|
378
|
+
endpoint: 'https://api.travelgatex.com/configured-omit',
|
|
379
|
+
apiKey: chance.guid(),
|
|
380
|
+
client: 'tourconnect-configured-omit',
|
|
381
|
+
productSearchOmitServiceCodes: ' sm, AC, ac ',
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
cacheKey = hash({
|
|
385
|
+
userId: testUserId,
|
|
386
|
+
hint: configuredOmitHint,
|
|
387
|
+
operationId: 'bookingsProductSearch',
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
beforeEach(async () => {
|
|
392
|
+
await Promise.all([
|
|
393
|
+
cache.drop({ pluginName: testAppName, key: cacheKey }),
|
|
394
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` }),
|
|
395
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }),
|
|
396
|
+
cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }),
|
|
397
|
+
]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('sends configured omissions when the caller omits the field', async () => {
|
|
401
|
+
await doApiPost({
|
|
402
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
403
|
+
token: userToken,
|
|
404
|
+
payload: {},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
408
|
+
expect(
|
|
409
|
+
travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
|
|
410
|
+
).toEqual(['AC', 'SM']);
|
|
411
|
+
expect(await cache.get({ pluginName: testAppName, key: cacheKey })).toBeTruthy();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('keeps configured omissions during an empty-scope force refresh', async () => {
|
|
415
|
+
await doApiPost({
|
|
416
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
417
|
+
token: userToken,
|
|
418
|
+
payload: { forceRefresh: true, omitServiceCodes: [] },
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(travelgatePlugin.searchProducts.mock.calls[0][0].payload).toEqual({
|
|
423
|
+
forceRefresh: true,
|
|
424
|
+
omitServiceCodes: ['AC', 'SM'],
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('ignores extra request omit codes once the integration is configured', async () => {
|
|
429
|
+
await doApiPost({
|
|
430
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
431
|
+
token: userToken,
|
|
432
|
+
payload: { omitServiceCodes: ['TR'] },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(
|
|
436
|
+
travelgatePlugin.searchProducts.mock.calls[0][0].payload.omitServiceCodes,
|
|
437
|
+
).toEqual(['AC', 'SM']);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('does not queue a catalog refresh from a stale organic search', async () => {
|
|
441
|
+
await cache.save({
|
|
442
|
+
pluginName: testAppName,
|
|
443
|
+
key: cacheKey,
|
|
444
|
+
value: { products: [{ productId: 'stale-product' }] },
|
|
445
|
+
ttl: 60,
|
|
446
|
+
});
|
|
447
|
+
await cache.save({
|
|
448
|
+
pluginName: testAppName,
|
|
449
|
+
key: `${cacheKey}:lastUpdated`,
|
|
450
|
+
value: 1,
|
|
451
|
+
ttl: 60,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const result = await doApiPost({
|
|
455
|
+
url: `/products/${testAppName}/${testUserId}/${configuredOmitHint}/search`,
|
|
456
|
+
token: userToken,
|
|
457
|
+
payload: {},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(result.products).toEqual([{ productId: 'stale-product' }]);
|
|
461
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
462
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
274
465
|
describe('cache TTR and lock mechanism', () => {
|
|
275
466
|
const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
|
|
276
467
|
const shortTTRToken = {
|
|
@@ -315,9 +506,9 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
315
506
|
setTimeout(resolve, 2100);
|
|
316
507
|
});
|
|
317
508
|
});
|
|
318
|
-
it('call outside of TTR should serve stale data and queue
|
|
509
|
+
it('call outside of TTR should serve stale data and not queue a catalog refresh', async () => {
|
|
319
510
|
travelgatePlugin.searchProducts.mockClear(); // Clear before action
|
|
320
|
-
addJob.mockClear();
|
|
511
|
+
addJob.mockClear();
|
|
321
512
|
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
322
513
|
|
|
323
514
|
const { products } = await doApiPost({
|
|
@@ -325,39 +516,15 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
325
516
|
token: userToken,
|
|
326
517
|
payload: {},
|
|
327
518
|
});
|
|
328
|
-
// This synchronous call should serve stale data.
|
|
329
|
-
// The plugin should NOT be called by *this* request directly as a background job is queued.
|
|
330
519
|
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
520
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
331
521
|
expect(Array.isArray(products)).toBeTruthy();
|
|
332
|
-
expect(products.length).toBe(2);
|
|
333
|
-
|
|
334
|
-
// Check that addJob was called and verify its parameters
|
|
335
|
-
expect(addJob).toHaveBeenCalledTimes(1);
|
|
336
|
-
|
|
337
|
-
const jobCall = addJob.mock.calls[0]; // Get the first (and only expected) call to addJob
|
|
338
|
-
const jobData = jobCall[0]; // First argument to addJob is the job payload
|
|
339
|
-
const jobParams = jobCall[1]; // Second argument is params like removeOnComplete
|
|
340
|
-
|
|
341
|
-
const expectedPluginMethod = travelgatePlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
|
|
342
|
-
|
|
343
|
-
expect(jobData.type).toBe('plugin');
|
|
344
|
-
expect(jobData.pluginName).toBe(testAppName); // 'travelgate'
|
|
345
|
-
expect(jobData.method).toBe(expectedPluginMethod);
|
|
346
|
-
expect(jobData.token).toBeDefined(); // Token passed to the job
|
|
347
|
-
expect(jobData.payload.userId).toBe(testUserId);
|
|
348
|
-
expect(jobData.payload.payload).toEqual({ forceRefresh: false }); // Original payload for the plugin method
|
|
349
|
-
expect(jobData.postProcess.controller).toBe('bookings');
|
|
350
|
-
expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
|
|
351
|
-
expect(jobData.postProcess.args.appKey).toBe(testAppName);
|
|
352
|
-
expect(jobData.postProcess.args.userId).toBe(testUserId);
|
|
353
|
-
expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
|
|
354
|
-
expect(jobParams).toEqual({ removeOnComplete: true });
|
|
522
|
+
expect(products.length).toBe(2);
|
|
355
523
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
356
524
|
'bookingsProductSearch:cache:decision',
|
|
357
525
|
expect.objectContaining({
|
|
358
526
|
action: 'stale_served',
|
|
359
|
-
reason: '
|
|
360
|
-
jobId: 'mockJobId',
|
|
527
|
+
reason: 'awaitingScheduledRefresh',
|
|
361
528
|
}),
|
|
362
529
|
);
|
|
363
530
|
emitSpy.mockRestore();
|
|
@@ -416,7 +583,6 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
416
583
|
|
|
417
584
|
it('should return stale products and preserve the cache when refresh yields empty products', async () => {
|
|
418
585
|
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
419
|
-
const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
|
|
420
586
|
const cacheKeyForTest = hash({
|
|
421
587
|
userId: testUserId,
|
|
422
588
|
hint: staleCacheTestHint,
|
|
@@ -439,64 +605,61 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
439
605
|
// 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
|
|
440
606
|
await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
|
|
441
607
|
|
|
442
|
-
// 3. Second call: TTR has expired.
|
|
443
|
-
// The plugin method for the *background job* will return empty results.
|
|
444
|
-
// This mock is for the plugin call that the *worker* would make.
|
|
445
|
-
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: productsFromPluginRefresh });
|
|
446
|
-
|
|
608
|
+
// 3. Second call: TTR has expired. Serve stale data and do not queue a catalog refresh.
|
|
447
609
|
const { products: secondCallResult } = await doApiPost({
|
|
448
610
|
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
449
611
|
token: userToken,
|
|
450
612
|
payload: { searchInput: '' },
|
|
451
613
|
});
|
|
452
614
|
|
|
453
|
-
// Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
|
|
454
615
|
expect(secondCallResult).toEqual(initialProductsInCache);
|
|
455
616
|
expect(secondCallResult.length).toBeGreaterThan(0);
|
|
456
|
-
|
|
457
|
-
// Verify that addJob was called to queue the background refresh.
|
|
458
|
-
expect(addJob).toHaveBeenCalledTimes(1);
|
|
459
|
-
// The travelgatePlugin.searchProducts mock was for the *job*, so it shouldn't be called by the API directly here.
|
|
460
|
-
// The controller serves stale and queues job; it doesn't call plugin directly in this path.
|
|
617
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
461
618
|
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
462
|
-
|
|
463
|
-
// Note: This test verifies the immediate response serves stale data and a job is queued.
|
|
464
|
-
// Cache preservation for empty background refreshes is covered below.
|
|
465
619
|
});
|
|
466
620
|
|
|
467
|
-
it('should keep existing non-empty cache when
|
|
621
|
+
it('should keep existing non-empty cache when forceRefresh returns empty products', async () => {
|
|
468
622
|
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
469
623
|
const cacheKeyForTest = hash({
|
|
470
624
|
userId: testUserId,
|
|
471
625
|
hint: staleCacheTestHint,
|
|
472
626
|
operationId: 'bookingsProductSearch',
|
|
473
627
|
});
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
628
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
|
|
629
|
+
await doApiPost({
|
|
630
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
631
|
+
token: userToken,
|
|
632
|
+
payload: { searchInput: '' },
|
|
479
633
|
});
|
|
480
|
-
|
|
634
|
+
travelgatePlugin.searchProducts.mockReset();
|
|
635
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: [] });
|
|
636
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
637
|
+
const lastUpdatedBefore = await cache.get({
|
|
481
638
|
pluginName: testAppName,
|
|
482
639
|
key: `${cacheKeyForTest}:lastUpdated`,
|
|
483
|
-
value: 1,
|
|
484
|
-
ttl: 60,
|
|
485
640
|
});
|
|
486
|
-
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
487
641
|
|
|
488
|
-
await
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
pluginResult: { products: [] },
|
|
493
|
-
requestId: 'empty-refresh-test',
|
|
642
|
+
const result = await doApiPost({
|
|
643
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
644
|
+
token: userToken,
|
|
645
|
+
payload: { forceRefresh: true },
|
|
494
646
|
});
|
|
495
647
|
|
|
496
648
|
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
497
|
-
const
|
|
649
|
+
const lastUpdatedAfter = await cache.get({
|
|
650
|
+
pluginName: testAppName,
|
|
651
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
652
|
+
});
|
|
653
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
654
|
+
expect(result).toEqual(expect.objectContaining({
|
|
655
|
+
products: initialProductsInCache,
|
|
656
|
+
catalogRefreshOutcome: 'empty_result_preserved_cache',
|
|
657
|
+
cacheUpdated: false,
|
|
658
|
+
cachePreserved: true,
|
|
659
|
+
cachedProductCount: initialProductsInCache.length,
|
|
660
|
+
}));
|
|
498
661
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
499
|
-
expect(
|
|
662
|
+
expect(lastUpdatedAfter).toBe(lastUpdatedBefore);
|
|
500
663
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
501
664
|
'bookingsProductSearch:cache:decision',
|
|
502
665
|
expect.objectContaining({
|
|
@@ -506,42 +669,9 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
506
669
|
existingProductCount: initialProductsInCache.length,
|
|
507
670
|
}),
|
|
508
671
|
);
|
|
509
|
-
expect(emitSpy).toHaveBeenCalledWith(
|
|
510
|
-
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
511
|
-
expect.objectContaining({
|
|
512
|
-
action: 'empty_refresh_skipped',
|
|
513
|
-
reason: 'emptyResultPreservedExistingCache',
|
|
514
|
-
cachePreserved: true,
|
|
515
|
-
}),
|
|
516
|
-
);
|
|
517
672
|
emitSpy.mockRestore();
|
|
518
673
|
});
|
|
519
674
|
|
|
520
|
-
it('should not cache partial product refreshes as complete results', async () => {
|
|
521
|
-
const cacheKeyForTest = hash({
|
|
522
|
-
userId: testUserId,
|
|
523
|
-
hint: staleCacheTestHint,
|
|
524
|
-
operationId: 'bookingsProductSearch',
|
|
525
|
-
});
|
|
526
|
-
await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
|
|
527
|
-
|
|
528
|
-
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
529
|
-
appKey: testAppName,
|
|
530
|
-
userId: testUserId,
|
|
531
|
-
hint: staleCacheTestHint,
|
|
532
|
-
pluginResult: {
|
|
533
|
-
catalogPartial: true,
|
|
534
|
-
products: [{ productId: 'partialProd1' }],
|
|
535
|
-
},
|
|
536
|
-
requestId: 'partial-refresh-test',
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
540
|
-
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
541
|
-
expect(cached).toBeFalsy();
|
|
542
|
-
expect(lastUpdated).toBeTruthy();
|
|
543
|
-
});
|
|
544
|
-
|
|
545
675
|
it('should not update cache metadata when direct force refresh returns partial products', async () => {
|
|
546
676
|
const pluginResult = {
|
|
547
677
|
catalogPartial: true,
|
|
@@ -567,8 +697,8 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
567
697
|
expect(lastUpdated).toBeFalsy();
|
|
568
698
|
});
|
|
569
699
|
|
|
570
|
-
it('
|
|
571
|
-
const initialProductsInCache = [{ productId: '
|
|
700
|
+
it('keeps an existing cache and reports it when forceRefresh returns partial products', async () => {
|
|
701
|
+
const initialProductsInCache = [{ productId: 'completeProd1' }];
|
|
572
702
|
const pluginResult = {
|
|
573
703
|
catalogPartial: true,
|
|
574
704
|
products: [{ productId: 'partialProd1' }],
|
|
@@ -590,93 +720,142 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
590
720
|
value: 1,
|
|
591
721
|
ttl: 60,
|
|
592
722
|
});
|
|
593
|
-
|
|
723
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
|
|
594
724
|
|
|
595
|
-
await
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
pluginResult,
|
|
600
|
-
requestId: 'partial-refresh-test',
|
|
725
|
+
const result = await doApiPost({
|
|
726
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
727
|
+
token: userToken,
|
|
728
|
+
payload: { forceRefresh: true },
|
|
601
729
|
});
|
|
602
730
|
|
|
603
731
|
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
604
|
-
const lastUpdated = await cache.get({
|
|
732
|
+
const lastUpdated = await cache.get({
|
|
733
|
+
pluginName: testAppName,
|
|
734
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
735
|
+
});
|
|
736
|
+
expect(result).toEqual(expect.objectContaining({
|
|
737
|
+
products: initialProductsInCache,
|
|
738
|
+
catalogRefreshOutcome: 'partial_result_preserved_cache',
|
|
739
|
+
cacheUpdated: false,
|
|
740
|
+
cachePreserved: true,
|
|
741
|
+
cachedProductCount: initialProductsInCache.length,
|
|
742
|
+
}));
|
|
605
743
|
expect(cached.products).toEqual(initialProductsInCache);
|
|
606
|
-
expect(lastUpdated).
|
|
607
|
-
expect(emitSpy).toHaveBeenCalledWith(
|
|
608
|
-
'bookingsProductSearch:cache:decision',
|
|
609
|
-
expect.objectContaining({
|
|
610
|
-
action: 'partial_refresh_skipped',
|
|
611
|
-
reason: 'partialResultPreservedExistingCache',
|
|
612
|
-
cachePreserved: true,
|
|
613
|
-
existingProductCount: initialProductsInCache.length,
|
|
614
|
-
returnedProductCount: pluginResult.products.length,
|
|
615
|
-
}),
|
|
616
|
-
);
|
|
617
|
-
expect(emitSpy).toHaveBeenCalledWith(
|
|
618
|
-
'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
619
|
-
expect.objectContaining({
|
|
620
|
-
action: 'partial_refresh_skipped',
|
|
621
|
-
reason: 'partialResultPreservedExistingCache',
|
|
622
|
-
cachePreserved: true,
|
|
623
|
-
}),
|
|
624
|
-
);
|
|
625
|
-
emitSpy.mockRestore();
|
|
744
|
+
expect(lastUpdated).toBe(1);
|
|
626
745
|
});
|
|
627
746
|
|
|
628
|
-
it('
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
747
|
+
it.each(['searchInput', 'optionId', 'productId', 'productName', 'lastUpdatedFrom'])(
|
|
748
|
+
'rejects a declared scheduled catalog refresh with the %s selector',
|
|
749
|
+
async selector => {
|
|
750
|
+
const selectorValue = selector === 'optionId' ? ['option-1'] : 'selected';
|
|
751
|
+
const response = await doApiPost({
|
|
752
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
753
|
+
token: userToken,
|
|
754
|
+
payload: {
|
|
755
|
+
forceRefresh: true,
|
|
756
|
+
fullSyncTrigger: 'scheduled',
|
|
757
|
+
[selector]: selectorValue,
|
|
758
|
+
},
|
|
759
|
+
expectStatusCode: 400,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
expect(response.message).toContain('must not include product selectors');
|
|
763
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
764
|
+
},
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
it('rejects a forced selector when fullSyncTrigger is omitted', async () => {
|
|
768
|
+
const response = await doApiPost({
|
|
769
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
770
|
+
token: userToken,
|
|
771
|
+
payload: { forceRefresh: true, optionId: 'option-1' },
|
|
772
|
+
expectStatusCode: 400,
|
|
633
773
|
});
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
774
|
+
|
|
775
|
+
expect(response.message).toContain('must not include product selectors');
|
|
776
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('rejects a full catalog force refresh without the catalog capability', async () => {
|
|
780
|
+
const originalCapabilities = travelgatePlugin.capabilities;
|
|
781
|
+
travelgatePlugin.capabilities = {};
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
const response = await doApiPost({
|
|
785
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
786
|
+
token: userToken,
|
|
787
|
+
payload: {
|
|
788
|
+
forceRefresh: true,
|
|
789
|
+
fullSyncTrigger: 'manual',
|
|
790
|
+
admissionOverrideReason: 'catalog repair',
|
|
791
|
+
},
|
|
792
|
+
expectStatusCode: 400,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
expect(response.message).toContain(
|
|
796
|
+
'does not support complete product catalog refreshes',
|
|
797
|
+
);
|
|
798
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
799
|
+
} finally {
|
|
800
|
+
travelgatePlugin.capabilities = originalCapabilities;
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('treats searchInput wildcard as an unscoped forced catalog refresh', async () => {
|
|
805
|
+
const products = [{ productId: 'catalog-product', options: [] }];
|
|
806
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products });
|
|
807
|
+
|
|
808
|
+
const result = await doApiPost({
|
|
809
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
810
|
+
token: userToken,
|
|
811
|
+
payload: {
|
|
812
|
+
forceRefresh: true,
|
|
813
|
+
fullSyncTrigger: 'scheduled',
|
|
814
|
+
searchInput: '*',
|
|
642
815
|
},
|
|
643
|
-
|
|
644
|
-
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(result).toEqual(expect.objectContaining({
|
|
819
|
+
products,
|
|
820
|
+
catalogRefreshOutcome: 'cache_updated',
|
|
821
|
+
cachedProductCount: 1,
|
|
822
|
+
}));
|
|
823
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('does not let token configuration overwrite force-refresh outcome fields', async () => {
|
|
827
|
+
const configuredHint = 'refresh-outcome-configuration-hint';
|
|
828
|
+
await globalUtils.appSetup({
|
|
829
|
+
appName: testAppName,
|
|
830
|
+
userId: testUserId,
|
|
831
|
+
tokenHint: configuredHint,
|
|
832
|
+
token: {
|
|
833
|
+
endpoint: 'https://api.travelgatex.com/configuration-test',
|
|
834
|
+
apiKey: chance.guid(),
|
|
835
|
+
configuration: {
|
|
836
|
+
catalogRefreshOutcome: 'configured-value',
|
|
837
|
+
cacheUpdated: false,
|
|
838
|
+
cachePreserved: true,
|
|
839
|
+
cachedProductCount: 999,
|
|
840
|
+
},
|
|
645
841
|
},
|
|
646
|
-
};
|
|
842
|
+
});
|
|
843
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce({
|
|
844
|
+
products: [{ productId: 'configured-product', options: [] }],
|
|
845
|
+
});
|
|
647
846
|
|
|
648
|
-
await
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
pluginResult,
|
|
653
|
-
requestId: 'race-refresh-test',
|
|
847
|
+
const result = await doApiPost({
|
|
848
|
+
url: `/products/${testAppName}/${testUserId}/${configuredHint}/search`,
|
|
849
|
+
token: userToken,
|
|
850
|
+
payload: { forceRefresh: true },
|
|
654
851
|
});
|
|
655
852
|
|
|
656
|
-
expect(
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
value: { products: [] },
|
|
853
|
+
expect(result).toEqual(expect.objectContaining({
|
|
854
|
+
catalogRefreshOutcome: 'cache_updated',
|
|
855
|
+
cacheUpdated: true,
|
|
856
|
+
cachePreserved: false,
|
|
857
|
+
cachedProductCount: 1,
|
|
662
858
|
}));
|
|
663
|
-
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
664
|
-
'bookingsProductSearch:cache:decision',
|
|
665
|
-
expect.objectContaining({
|
|
666
|
-
action: 'empty_refresh_skipped',
|
|
667
|
-
reason: 'emptyResultPreservedConcurrentCache',
|
|
668
|
-
cachePreserved: true,
|
|
669
|
-
existingProductCount: 1,
|
|
670
|
-
}),
|
|
671
|
-
);
|
|
672
|
-
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
673
|
-
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
674
|
-
expect.objectContaining({
|
|
675
|
-
action: 'empty_refresh_skipped',
|
|
676
|
-
reason: 'emptyResultPreservedConcurrentCache',
|
|
677
|
-
cachePreserved: true,
|
|
678
|
-
}),
|
|
679
|
-
);
|
|
680
859
|
});
|
|
681
860
|
});
|
|
682
861
|
});
|
|
@@ -742,6 +921,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
742
921
|
await cache.drop({ pluginName: testAppName, key: cacheKey });
|
|
743
922
|
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
|
|
744
923
|
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
|
|
924
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock:outcome` });
|
|
745
925
|
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
|
|
746
926
|
};
|
|
747
927
|
|
|
@@ -761,7 +941,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
761
941
|
if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
|
|
762
942
|
});
|
|
763
943
|
|
|
764
|
-
it('multiple concurrent requests to stale cache should serve stale data and queue
|
|
944
|
+
it('multiple concurrent requests to stale cache should serve stale data and not queue a catalog refresh', async () => {
|
|
765
945
|
// 1. First call: Populate the cache using the specific lockTestPlugin
|
|
766
946
|
lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
|
|
767
947
|
|
|
@@ -778,9 +958,6 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
778
958
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
779
959
|
|
|
780
960
|
// 3. Make multiple concurrent requests to the now stale cache
|
|
781
|
-
// Mock plugin response for the background refresh. This mock is for the job that addJob is supposed to queue.
|
|
782
|
-
lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod2', name: 'Refreshed Product' }] });
|
|
783
|
-
|
|
784
961
|
const makeRequest = () => doApiPost({
|
|
785
962
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
786
963
|
token: userToken,
|
|
@@ -788,52 +965,20 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
788
965
|
});
|
|
789
966
|
|
|
790
967
|
const requestPromises = [];
|
|
791
|
-
requestPromises.push(makeRequest());
|
|
792
|
-
await global.sleep(50);
|
|
793
|
-
requestPromises.push(makeRequest());
|
|
968
|
+
requestPromises.push(makeRequest());
|
|
969
|
+
await global.sleep(50);
|
|
970
|
+
requestPromises.push(makeRequest());
|
|
794
971
|
await global.sleep(50);
|
|
795
|
-
requestPromises.push(makeRequest());
|
|
972
|
+
requestPromises.push(makeRequest());
|
|
796
973
|
|
|
797
974
|
const results = await Promise.all(requestPromises);
|
|
798
975
|
|
|
799
|
-
// Assertions:
|
|
800
|
-
// a. All requests should serve stale data (the "Initial Product")
|
|
801
976
|
results.forEach(result => {
|
|
802
977
|
expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
|
|
803
978
|
});
|
|
804
979
|
|
|
805
|
-
// b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
|
|
806
980
|
expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
|
|
807
|
-
|
|
808
|
-
// c. addJob should have been called exactly once
|
|
809
|
-
expect(addJob).toHaveBeenCalledTimes(1);
|
|
810
|
-
|
|
811
|
-
// d. Verify arguments of addJob
|
|
812
|
-
if (addJob.mock.calls.length > 0) {
|
|
813
|
-
const expectedPluginMethodName = lockTestPlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
|
|
814
|
-
expect(addJob).toHaveBeenCalledWith(
|
|
815
|
-
expect.objectContaining({
|
|
816
|
-
type: 'plugin',
|
|
817
|
-
pluginName: testAppName, // This should be 'lockTestPlugin'
|
|
818
|
-
method: expectedPluginMethodName,
|
|
819
|
-
token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
|
|
820
|
-
payload: expect.objectContaining({
|
|
821
|
-
payload: { forceRefresh: false }, // originalRequestBody was empty for the product search
|
|
822
|
-
userId: testUserId,
|
|
823
|
-
}),
|
|
824
|
-
postProcess: expect.objectContaining({
|
|
825
|
-
controller: 'bookings',
|
|
826
|
-
action: '$updateProductSearchCache',
|
|
827
|
-
args: expect.objectContaining({
|
|
828
|
-
appKey: testAppName, // 'lockTestPlugin'
|
|
829
|
-
userId: testUserId,
|
|
830
|
-
hint: ttrTestHint,
|
|
831
|
-
}),
|
|
832
|
-
}),
|
|
833
|
-
}),
|
|
834
|
-
{ removeOnComplete: true }
|
|
835
|
-
);
|
|
836
|
-
}
|
|
981
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
837
982
|
});
|
|
838
983
|
|
|
839
984
|
it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
|
|
@@ -921,7 +1066,7 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
921
1066
|
emitSpy.mockRestore();
|
|
922
1067
|
});
|
|
923
1068
|
|
|
924
|
-
it('
|
|
1069
|
+
it('serves a stale catalog for an optionId search without queueing a refresh', async () => {
|
|
925
1070
|
await clearProductSearchCache();
|
|
926
1071
|
const cachedProducts = [{
|
|
927
1072
|
productId: 'cached-product',
|
|
@@ -947,13 +1092,23 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
947
1092
|
|
|
948
1093
|
expect(result.products).toEqual(cachedProducts);
|
|
949
1094
|
expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
|
|
950
|
-
expect(addJob).
|
|
951
|
-
expect(addJob.mock.calls[0][0].payload.payload).toEqual({ forceRefresh: false });
|
|
1095
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
952
1096
|
});
|
|
953
1097
|
|
|
954
1098
|
it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
|
|
955
1099
|
await clearProductSearchCache();
|
|
956
1100
|
|
|
1101
|
+
const cacheKey = hash({
|
|
1102
|
+
userId: testUserId,
|
|
1103
|
+
hint: ttrTestHint,
|
|
1104
|
+
operationId: 'bookingsProductSearch',
|
|
1105
|
+
});
|
|
1106
|
+
await cache.save({
|
|
1107
|
+
pluginName: testAppName,
|
|
1108
|
+
key: cacheKey,
|
|
1109
|
+
value: { products: [{ productId: 'stale-force-product', options: [] }] },
|
|
1110
|
+
ttl: 60,
|
|
1111
|
+
});
|
|
957
1112
|
const refreshedProducts = [{
|
|
958
1113
|
productId: 'force-product',
|
|
959
1114
|
productName: 'Force Product',
|
|
@@ -987,11 +1142,121 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
987
1142
|
|
|
988
1143
|
results.forEach(result => {
|
|
989
1144
|
expect(result.products).toEqual(refreshedProducts);
|
|
1145
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1146
|
+
catalogRefreshOutcome: 'cache_updated',
|
|
1147
|
+
cacheUpdated: true,
|
|
1148
|
+
cachePreserved: false,
|
|
1149
|
+
cachedProductCount: refreshedProducts.length,
|
|
1150
|
+
}));
|
|
990
1151
|
});
|
|
991
1152
|
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
992
1153
|
expect(addJob).not.toHaveBeenCalled();
|
|
993
1154
|
});
|
|
994
1155
|
|
|
1156
|
+
it('concurrent forceRefresh waiters receive a preserved-cache outcome', async () => {
|
|
1157
|
+
await clearProductSearchCache();
|
|
1158
|
+
|
|
1159
|
+
const cachedProducts = [{
|
|
1160
|
+
productId: 'preserved-product',
|
|
1161
|
+
productName: 'Preserved Product',
|
|
1162
|
+
options: [],
|
|
1163
|
+
}];
|
|
1164
|
+
const cacheKey = hash({
|
|
1165
|
+
userId: testUserId,
|
|
1166
|
+
hint: ttrTestHint,
|
|
1167
|
+
operationId: 'bookingsProductSearch',
|
|
1168
|
+
});
|
|
1169
|
+
await cache.save({
|
|
1170
|
+
pluginName: testAppName,
|
|
1171
|
+
key: cacheKey,
|
|
1172
|
+
value: { products: cachedProducts },
|
|
1173
|
+
ttl: 60,
|
|
1174
|
+
});
|
|
1175
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
1176
|
+
let releasePluginFetch;
|
|
1177
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
1178
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
1179
|
+
resolve();
|
|
1180
|
+
return new Promise(pluginResolve => {
|
|
1181
|
+
releasePluginFetch = () => pluginResolve({ products: [] });
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
const makeForceRequest = () => doApiPost({
|
|
1186
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
1187
|
+
token: userToken,
|
|
1188
|
+
payload: { forceRefresh: true },
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
1192
|
+
await pluginFetchStarted;
|
|
1193
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1194
|
+
releasePluginFetch();
|
|
1195
|
+
const results = await Promise.all(requestPromises);
|
|
1196
|
+
|
|
1197
|
+
results.forEach(result => {
|
|
1198
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1199
|
+
products: cachedProducts,
|
|
1200
|
+
catalogRefreshOutcome: 'empty_result_preserved_cache',
|
|
1201
|
+
cacheUpdated: false,
|
|
1202
|
+
cachePreserved: true,
|
|
1203
|
+
cachedProductCount: cachedProducts.length,
|
|
1204
|
+
}));
|
|
1205
|
+
});
|
|
1206
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
1207
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('serves cache with an in-progress outcome when a forceRefresh wait times out', async () => {
|
|
1211
|
+
await clearProductSearchCache();
|
|
1212
|
+
|
|
1213
|
+
const cachedProducts = [{ productId: 'in-progress-product', options: [] }];
|
|
1214
|
+
const cacheKey = hash({
|
|
1215
|
+
userId: testUserId,
|
|
1216
|
+
hint: ttrTestHint,
|
|
1217
|
+
operationId: 'bookingsProductSearch',
|
|
1218
|
+
});
|
|
1219
|
+
const lockKey = `${cacheKey}:lock`;
|
|
1220
|
+
await cache.save({
|
|
1221
|
+
pluginName: testAppName,
|
|
1222
|
+
key: cacheKey,
|
|
1223
|
+
value: { products: cachedProducts },
|
|
1224
|
+
ttl: 60,
|
|
1225
|
+
});
|
|
1226
|
+
await cache.save({
|
|
1227
|
+
pluginName: testAppName,
|
|
1228
|
+
key: lockKey,
|
|
1229
|
+
value: 'active-refresh-owner',
|
|
1230
|
+
ttl: 60,
|
|
1231
|
+
});
|
|
1232
|
+
const originalLockWaitMs = process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
|
|
1233
|
+
process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = '10';
|
|
1234
|
+
|
|
1235
|
+
try {
|
|
1236
|
+
const result = await doApiPost({
|
|
1237
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
1238
|
+
token: userToken,
|
|
1239
|
+
payload: { forceRefresh: true },
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
expect(result).toEqual(expect.objectContaining({
|
|
1243
|
+
products: cachedProducts,
|
|
1244
|
+
catalogRefreshOutcome: 'refresh_in_progress_cache_served',
|
|
1245
|
+
cacheUpdated: false,
|
|
1246
|
+
cachePreserved: true,
|
|
1247
|
+
cachedProductCount: cachedProducts.length,
|
|
1248
|
+
}));
|
|
1249
|
+
expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
|
|
1250
|
+
} finally {
|
|
1251
|
+
await cache.drop({ pluginName: testAppName, key: lockKey });
|
|
1252
|
+
if (originalLockWaitMs === undefined) {
|
|
1253
|
+
delete process.env.PRODUCT_SEARCH_LOCK_WAIT_MS;
|
|
1254
|
+
} else {
|
|
1255
|
+
process.env.PRODUCT_SEARCH_LOCK_WAIT_MS = originalLockWaitMs;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
|
|
995
1260
|
it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
|
|
996
1261
|
await clearProductSearchCache();
|
|
997
1262
|
const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|