ti2 1.0.94 → 1.0.96

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.
@@ -0,0 +1,533 @@
1
+ /* globals beforeAll describe it expect jest beforeEach */
2
+
3
+ const chance = require('chance').Chance();
4
+ const hash = require('object-hash');
5
+ const cache = require('../../cache');
6
+ // Remove direct import of listJobs, jobStatus as we'll mock addJob
7
+ // const { listJobs, jobStatus } = require('../../worker/queue');
8
+
9
+ // Mock the worker/queue module
10
+ jest.mock('../../worker/queue', () => ({
11
+ ...jest.requireActual('../../worker/queue'), // Import and retain default behavior
12
+ addJob: jest.fn().mockResolvedValue({ id: 'mockJobId' }), // Mock addJob
13
+ // Provide mock for jobStatus as it might be called by other parts of the code or tests
14
+ jobStatus: jest.fn().mockResolvedValue({ status: 'completed', progress: 100, returnValue: null }),
15
+ }));
16
+ const { addJob, jobStatus: mockJobStatus } = require('../../worker/queue'); // Now addJob is the mock
17
+
18
+ const testUtils = require('../../test/utils'); // Require the module itself
19
+
20
+ // Global setup for the entire test file
21
+ let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
22
+
23
+ beforeAll(async () => {
24
+ // Initialize utils once for the entire test file
25
+ // Ensure all plugins needed across different describe blocks are listed here.
26
+ globalUtils = await testUtils({ plugins: ['lockTestPlugin', 'travelgate'] });
27
+ globalDoApiPost = globalUtils.doApiPost;
28
+ globalPlugins = globalUtils.plugins; // Array of instantiated plugins from the app instance
29
+ globalSqldb = globalUtils.sqldb;
30
+ });
31
+
32
+ afterAll(async () => {
33
+ // Close DB connection after all tests in this file have run
34
+ if (globalSqldb && globalSqldb.sequelize && globalSqldb.sequelize.close) {
35
+ await globalSqldb.sequelize.close();
36
+ }
37
+ });
38
+
39
+ describe('user: bookings controller - searchProducts', () => {
40
+ let doApiGet;
41
+ let doApiPost;
42
+ let travelgatePlugin; // Specific plugin instance for this suite
43
+ let userToken;
44
+ let testAppName;
45
+ let testUserId;
46
+ let testPluginToken;
47
+ let testHint;
48
+
49
+ beforeAll(async () => {
50
+ // Use globally initialized utilities and plugins
51
+ doApiGet = globalUtils.doApiGet;
52
+ doApiPost = globalUtils.doApiPost;
53
+ travelgatePlugin = globalPlugins.find(p => p.name === 'travelgate');
54
+ if (!travelgatePlugin) {
55
+ throw new Error("travelgatePlugin not found. Ensure it's included in global testUtils setup.");
56
+ }
57
+
58
+ // Setup a new app, user, and integration using globalUtils.appSetup
59
+ // This appName ('travelgate') should match the plugin name we want to test.
60
+ const setupData = await globalUtils.appSetup({
61
+ appName: 'travelgate',
62
+ // userId will be generated by appSetup if not provided
63
+ // token and tokenHint will be default from appSetup unless specified
64
+ });
65
+ testAppName = setupData.newApp.name; // Should be 'travelgate'
66
+ testUserId = setupData.userId;
67
+ testPluginToken = setupData.token; // Token config for the 'travelgate' integration
68
+ testHint = setupData.hint; // Hint for this 'travelgate' integration
69
+
70
+ userToken = globalUtils.createUserToken(testUserId); // JWT for API calls
71
+ expect(userToken).toBeTruthy();
72
+
73
+ // Drop cache keys for this specific context
74
+ const cacheKey = hash({
75
+ appKey: testAppName,
76
+ userId: testUserId,
77
+ hint: testHint, // Use the hint from the default integration
78
+ operationId: 'bookingsProductSearch',
79
+ });
80
+ await cache.drop({
81
+ pluginName: testAppName,
82
+ key: cacheKey,
83
+ });
84
+ await cache.drop({
85
+ pluginName: testAppName,
86
+ key: `${cacheKey}:lock`,
87
+ });
88
+ });
89
+
90
+ beforeEach(async () => {
91
+ // Clear mocks relevant to this suite.
92
+ if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockClear) {
93
+ travelgatePlugin.searchProducts.mockClear();
94
+ }
95
+ if (addJob && addJob.mockClear) { // Ensure addJob is cleared if tests in this suite might call it
96
+ addJob.mockClear();
97
+ }
98
+ });
99
+
100
+ describe('searchProducts', () => {
101
+ it('should be able to get bookings products for most users without special setup: no cache', async () => {
102
+ const payload = {};
103
+ const { products } = await doApiPost({
104
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
105
+ token: userToken,
106
+ payload,
107
+ });
108
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
109
+ expect(Array.isArray(products)).toBeTruthy();
110
+ expect(products.length).toBe(2);
111
+ expect(products[0].options.length).toBe(1);
112
+ expect(products[1].options.length).toBe(2);
113
+ });
114
+ it('should be able to get booking products: no cache, forceRefresh', async () => {
115
+ // 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
116
+ const payload = {
117
+ forceRefresh: true,
118
+ };
119
+ const { products } = await doApiPost({
120
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
121
+ token: userToken,
122
+ payload,
123
+ });
124
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
125
+ expect(Array.isArray(products)).toBeTruthy();
126
+ expect(products.length).toBe(2);
127
+ expect(products[0].options.length).toBe(1);
128
+ expect(products[1].options.length).toBe(2);
129
+ expect(travelgatePlugin.searchProducts.mock.calls[0][0].token).toEqual(testPluginToken);
130
+ });
131
+ describe('cache exists and is valid', () => {
132
+ it('should be able to get booking products: using cache', async () => {
133
+ const payload = {};
134
+ const { products } = await doApiPost({
135
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
136
+ token: userToken,
137
+ payload,
138
+ });
139
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
140
+ expect(Array.isArray(products)).toBeTruthy();
141
+ expect(products.length).toBe(2);
142
+ expect(products[0].options.length).toBe(1);
143
+ expect(products[1].options.length).toBe(2);
144
+ });
145
+ it('should be able to get booking products with searchInput', async () => {
146
+
147
+ const payload = {
148
+ searchInput: 'Transfer from Sydney Harbor to Hilton Hotel',
149
+ };
150
+ const { products } = await doApiPost({
151
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
152
+ token: userToken,
153
+ payload,
154
+ });
155
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
156
+ expect(Array.isArray(products)).toBeTruthy();
157
+ expect(products.length).toBe(1);
158
+ expect(products[0].productName).toBe('Davids');
159
+ expect(products[0].options.length).toBe(1);
160
+ expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
161
+ });
162
+ });
163
+ describe('doNotCallPluginForProducts flag', () => {
164
+ const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
165
+ beforeAll(async () => {
166
+ // Create a specific integration for this test suite
167
+ const newIntegrationContent = {
168
+ endpoint: 'https://api.travelgatex.com', // Can be any valid URL
169
+ apiKey: chance.guid(),
170
+ client: 'tourconnect', // Or any other fields your plugin might expect
171
+ doNotCallPluginForProducts: true,
172
+ };
173
+ // Use globalUtils.appSetup to create this specific integration (UserAppKey)
174
+ await globalUtils.appSetup({
175
+ appName: testAppName, // Still 'travelgate' context for the plugin
176
+ userId: testUserId, // Same user
177
+ tokenHint: doNotCallHint,
178
+ token: newIntegrationContent,
179
+ });
180
+ // Clear mocks that might have been called during appSetup
181
+ if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
182
+ });
183
+
184
+ it('productSearch should not call the plugin', async () => {
185
+ // first time call, no cache expect no plugin call and empty products
186
+ let products;
187
+ await doApiPost({
188
+ url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
189
+ token: userToken,
190
+ payload: {},
191
+ }).then(({ products: p }) => {
192
+ products = p;
193
+ });
194
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
195
+ expect(Array.isArray(products)).toBeTruthy();
196
+ expect(products.length).toBe(0);
197
+
198
+ });
199
+ it('a forceRefresh should trigger the call to the plugin', async() => {
200
+ // second time call, forceRefresh and still expect plugin call
201
+ let products; // Define products here to ensure it's in scope for the then block
202
+ await doApiPost({
203
+ url: `/products/${testAppName}/${testUserId}/${doNotCallHint}/search`,
204
+ token: userToken,
205
+ payload: { forceRefresh: true },
206
+ }).then(({ products: p }) => {
207
+ products = p; // Assign to the outer scoped 'products'
208
+ });
209
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalled();
210
+ expect(Array.isArray(products)).toBeTruthy();
211
+ expect(products.length).toBe(2);
212
+
213
+ });
214
+ });
215
+ describe('cache TTR and lock mechanism', () => {
216
+ const ttrTestHint = 'ttr-test'; // This hint is for UserAppKey within 'travelgate' appName
217
+ const shortTTRToken = {
218
+ endpoint: 'https://api.travelgatex.com', // Can be any valid URL
219
+ apiKey: chance.guid(),
220
+ client: 'tourconnect', // Or any other fields
221
+ ttlForProducts: 2, // 2 seconds TTR
222
+ };
223
+ beforeAll(async () => {
224
+ // Create/Update UserAppKey for testAppName ('travelgate') with this specific hint and TTR token
225
+ await globalUtils.appSetup({
226
+ appName: testAppName, // 'travelgate'
227
+ userId: testUserId,
228
+ tokenHint: ttrTestHint,
229
+ token: shortTTRToken,
230
+ });
231
+ if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
232
+ });
233
+ describe('inside of the TTR period', () => {
234
+ it('first call should create the cache', async ()=> {
235
+ await doApiPost({
236
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
237
+ token: userToken,
238
+ payload: {},
239
+ });
240
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
241
+ });
242
+ it('inmediate call should not call the plugin mehthod', async () => {
243
+ // Second immediate call should use cache
244
+ await doApiPost({
245
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
246
+ token: userToken,
247
+ payload: {},
248
+ });
249
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
250
+ });
251
+ });
252
+ describe('outside of the TTR period', () => {
253
+ it('wait for the TTR to expire', async () => {
254
+ // Wait for TTR to expire (2 seconds + buffer)
255
+ await new Promise(resolve => {
256
+ setTimeout(resolve, 2100);
257
+ });
258
+ });
259
+ it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
260
+ travelgatePlugin.searchProducts.mockClear(); // Clear before action
261
+ addJob.mockClear(); // Clear addJob mock before this action that should trigger it
262
+
263
+ const { products } = await doApiPost({
264
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
265
+ token: userToken,
266
+ payload: {},
267
+ });
268
+ // This synchronous call should serve stale data.
269
+ // The plugin should NOT be called by *this* request directly as a background job is queued.
270
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
271
+ expect(Array.isArray(products)).toBeTruthy();
272
+ expect(products.length).toBe(2); // Assuming stale data (from initial cache population) is available and has 2 products
273
+
274
+ // Check that addJob was called and verify its parameters
275
+ expect(addJob).toHaveBeenCalledTimes(1);
276
+
277
+ const jobCall = addJob.mock.calls[0]; // Get the first (and only expected) call to addJob
278
+ const jobData = jobCall[0]; // First argument to addJob is the job payload
279
+ const jobParams = jobCall[1]; // Second argument is params like removeOnComplete
280
+
281
+ const expectedPluginMethod = travelgatePlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
282
+
283
+ expect(jobData.type).toBe('plugin');
284
+ expect(jobData.pluginName).toBe(testAppName); // 'travelgate'
285
+ expect(jobData.method).toBe(expectedPluginMethod);
286
+ expect(jobData.token).toBeDefined(); // Token passed to the job
287
+ expect(jobData.payload.userId).toBe(testUserId);
288
+ expect(jobData.payload.payload).toEqual({}); // Original payload for the plugin method was empty
289
+ expect(jobData.postProcess.controller).toBe('bookings');
290
+ expect(jobData.postProcess.action).toBe('$updateProductSearchCache');
291
+ expect(jobData.postProcess.args.appKey).toBe(testAppName);
292
+ expect(jobData.postProcess.args.userId).toBe(testUserId);
293
+ expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
294
+ expect(jobParams).toEqual({ removeOnComplete: true });
295
+ });
296
+ });
297
+ // The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
298
+ });
299
+ });
300
+
301
+ describe('bookingsProductSearch caching - stale cache on TTR expiry', () => {
302
+ // This suite tests behavior when TTR expires and a refresh yields empty results,
303
+ // expecting stale cache to be served. It uses a real cache with a short TTL.
304
+ // This suite also uses the global utils and travelgatePlugin. testAppName and testUserId are from parent suite.
305
+
306
+ const staleCacheTestHint = 'stale-cache-expiry-test-hint'; // For 'travelgate' appName (testAppName)
307
+ const shortTtlForProducts = 2; // 2 seconds
308
+ const staleCacheTokenConfig = {
309
+ endpoint: 'https://api.travelgatex.com/stale-test', // Unique endpoint for clarity
310
+ apiKey: chance.guid(),
311
+ client: 'tourconnect-stale-test',
312
+ ttlForProducts: shortTtlForProducts,
313
+ };
314
+
315
+ beforeEach(async () => {
316
+ // Clear mocks
317
+ if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockClear) {
318
+ travelgatePlugin.searchProducts.mockClear();
319
+ }
320
+ if (addJob && addJob.mockClear) {
321
+ addJob.mockClear();
322
+ }
323
+
324
+ // Ensure a clean cache state for this specific hint before each test run
325
+ // testAppName here is 'travelgate' from the outer describe block's setup
326
+ const cacheKeyForTest = hash({
327
+ userId: testUserId, // User from the 'user: bookings controller' suite
328
+ hint: staleCacheTestHint,
329
+ operationId: 'bookingsProductSearch',
330
+ });
331
+ await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
332
+ await cache.drop({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
333
+ await cache.drop({ pluginName: testAppName, key: `${cacheKeyForTest}:lock` });
334
+
335
+ // Setup the UserAppKey (integration) with the short TTL for products
336
+ // This uses the testUserId and testAppName ('travelgate') from the parent describe
337
+ await globalUtils.appSetup({
338
+ appName: testAppName,
339
+ userId: testUserId,
340
+ tokenHint: staleCacheTestHint,
341
+ token: staleCacheTokenConfig,
342
+ });
343
+ // Clear mocks that might have been called during appSetup
344
+ if (travelgatePlugin.searchProducts.mockClear) travelgatePlugin.searchProducts.mockClear();
345
+ if (addJob.mockClear) addJob.mockClear();
346
+ });
347
+
348
+ it('should return stale (non-empty) products when TTR expires and refresh yields empty products', async () => {
349
+ const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
350
+ const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
351
+
352
+ // 1. First call: Populate the cache with initialProductsInCache
353
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
354
+ const { products: firstCallResult } = await doApiPost({
355
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
356
+ token: userToken,
357
+ payload: { searchInput: '' },
358
+ });
359
+
360
+ expect(firstCallResult).toEqual(initialProductsInCache);
361
+ expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
362
+ expect(addJob).not.toHaveBeenCalled(); // No job on initial population
363
+ travelgatePlugin.searchProducts.mockClear();
364
+
365
+ // 2. Wait for TTL to expire (shortTtlForProducts is 2s, wait 3s)
366
+ await new Promise(resolve => setTimeout(resolve, (shortTtlForProducts + 1) * 1000));
367
+
368
+ // 3. Second call: TTR has expired. A background job will be queued.
369
+ // The plugin method for the *background job* will return empty results.
370
+ // This mock is for the plugin call that the *worker* would make.
371
+ travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: productsFromPluginRefresh });
372
+
373
+ const { products: secondCallResult } = await doApiPost({
374
+ url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
375
+ token: userToken,
376
+ payload: { searchInput: '' },
377
+ });
378
+
379
+ // Assert that the stale data (initialProductsInCache) is returned by this synchronous call.
380
+ expect(secondCallResult).toEqual(initialProductsInCache);
381
+ expect(secondCallResult.length).toBeGreaterThan(0);
382
+
383
+ // Verify that addJob was called to queue the background refresh.
384
+ expect(addJob).toHaveBeenCalledTimes(1);
385
+ // The travelgatePlugin.searchProducts mock was for the *job*, so it shouldn't be called by the API directly here.
386
+ // The controller serves stale and queues job; it doesn't call plugin directly in this path.
387
+ expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
388
+
389
+ // Note: This test verifies the immediate response serves stale data and a job is queued.
390
+ // It does not verify the cache state *after* the job (which would use the empty refresh)
391
+ // because $updateProductSearchCache currently caches empty results.
392
+ });
393
+ });
394
+ });
395
+
396
+ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)', () => {
397
+ let doApiPost;
398
+ let lockTestPlugin; // Specific plugin instance for this suite
399
+ let userToken;
400
+ let testAppName;
401
+ let testUserId;
402
+ let ttrTestHint;
403
+ const shortTTRTokenConfig = {
404
+ endpoint: 'https://api.travelgatex.com/lock-test',
405
+ apiKey: chance.guid(),
406
+ client: 'tourconnect-lock-test',
407
+ ttlForProducts: 1, // 1 second TTR for faster testing
408
+ };
409
+
410
+ beforeAll(async () => {
411
+ doApiPost = globalDoApiPost;
412
+ lockTestPlugin = globalPlugins.find(p => p.name === 'lockTestPlugin');
413
+ if (!lockTestPlugin) {
414
+ throw new Error("lockTestPlugin not found. Ensure it's included in global testUtils setup.");
415
+ }
416
+
417
+ ttrTestHint = 'lock-mechanism-hint';
418
+
419
+ // Use globalUtils.appSetup to create the app, user, integration, and userAppKey
420
+ // This ensures the Integration 'lockTestPlugin' is created before UserAppKey.
421
+ // The appName here must match an actual plugin name.
422
+ // A unique userId is generated by appSetup if not provided.
423
+ const setupData = await globalUtils.appSetup({
424
+ appName: 'lockTestPlugin', // This will be the integrationId and plugin name
425
+ token: shortTTRTokenConfig, // Pass the specific token config for UserAppKey
426
+ tokenHint: ttrTestHint, // Pass the specific hint for UserAppKey
427
+ });
428
+
429
+ testAppName = setupData.newApp.name; // Should be 'lockTestPlugin'
430
+ testUserId = setupData.userId;
431
+ userToken = globalUtils.createUserToken(testUserId); // JWT for API calls
432
+
433
+ // The UserAppKey with shortTTRTokenConfig and ttrTestHint is now created by appSetup.
434
+ // No need for an additional doApiPost to create/update it here.
435
+
436
+ // Initial cache clear for this specific context. testAppName is 'lockTestPlugin'.
437
+ const cacheKey = hash({
438
+ userId: testUserId,
439
+ hint: ttrTestHint,
440
+ operationId: 'bookingsProductSearch',
441
+ });
442
+ await cache.drop({ pluginName: testAppName, key: cacheKey });
443
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
444
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` }); // pluginExecutionLock
445
+ await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }); // jobQueueLock
446
+ });
447
+
448
+ beforeEach(async () => {
449
+ // Clear mocks before each test in this suite
450
+ addJob.mockClear(); // Reset addJob mock calls
451
+ if (lockTestPlugin && lockTestPlugin.searchProducts && lockTestPlugin.searchProducts.mockClear) {
452
+ lockTestPlugin.searchProducts.mockClear();
453
+ }
454
+ // Clear other global mocks if they were used by this suite and need resetting
455
+ if (mockJobStatus && mockJobStatus.mockClear) mockJobStatus.mockClear();
456
+ });
457
+
458
+ it('multiple concurrent requests to stale cache should serve stale data and queue only one new refresh job', async () => {
459
+ // 1. First call: Populate the cache using the specific lockTestPlugin
460
+ lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod1', name: 'Initial Product' }] });
461
+
462
+ await doApiPost({
463
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // testAppName is 'lockTestApp'
464
+ token: userToken,
465
+ payload: {},
466
+ });
467
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
468
+ expect(addJob).not.toHaveBeenCalled(); // No job queued on initial population
469
+ lockTestPlugin.searchProducts.mockClear(); // Clear for next phase
470
+
471
+ // 2. Wait for TTR to expire (ttlForProducts is 1s, wait 1.5s)
472
+ await new Promise(resolve => setTimeout(resolve, 1500));
473
+
474
+ // 3. Make multiple concurrent requests to the now stale cache
475
+ // Mock plugin response for the background refresh. This mock is for the job that addJob is supposed to queue.
476
+ lockTestPlugin.searchProducts.mockResolvedValueOnce({ products: [{ id: 'prod2', name: 'Refreshed Product' }] });
477
+
478
+ const makeRequest = () => doApiPost({
479
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
480
+ token: userToken,
481
+ payload: {},
482
+ });
483
+
484
+ const requestPromises = [];
485
+ requestPromises.push(makeRequest()); // First request hits stale cache, should queue job
486
+ await global.sleep(50); // Small delay to simulate near concurrency
487
+ requestPromises.push(makeRequest()); // Second request should also hit stale cache, but not queue another job
488
+ await global.sleep(50);
489
+ requestPromises.push(makeRequest()); // Third request
490
+
491
+ const results = await Promise.all(requestPromises);
492
+
493
+ // Assertions:
494
+ // a. All requests should serve stale data (the "Initial Product")
495
+ results.forEach(result => {
496
+ expect(result.products).toEqual([{ id: 'prod1', name: 'Initial Product' }]);
497
+ });
498
+
499
+ // b. The plugin's searchProducts method (on lockTestPlugin) should NOT have been called directly by these API requests
500
+ expect(lockTestPlugin.searchProducts).not.toHaveBeenCalled();
501
+
502
+ // c. addJob should have been called exactly once
503
+ expect(addJob).toHaveBeenCalledTimes(1);
504
+
505
+ // d. Verify arguments of addJob
506
+ if (addJob.mock.calls.length > 0) {
507
+ const expectedPluginMethodName = lockTestPlugin.searchProducts ? 'searchProducts' : 'searchProductsForItinerary';
508
+ expect(addJob).toHaveBeenCalledWith(
509
+ expect.objectContaining({
510
+ type: 'plugin',
511
+ pluginName: testAppName, // This should be 'lockTestPlugin'
512
+ method: expectedPluginMethodName,
513
+ token: expect.objectContaining({ ttlForProducts: shortTTRTokenConfig.ttlForProducts }),
514
+ payload: expect.objectContaining({
515
+ payload: {}, // originalRequestBody was empty for the product search
516
+ userId: testUserId,
517
+ }),
518
+ postProcess: expect.objectContaining({
519
+ controller: 'bookings',
520
+ action: '$updateProductSearchCache',
521
+ args: expect.objectContaining({
522
+ appKey: testAppName, // 'lockTestPlugin'
523
+ userId: testUserId,
524
+ hint: ttrTestHint,
525
+ }),
526
+ }),
527
+ }),
528
+ { removeOnComplete: true }
529
+ );
530
+ }
531
+ });
532
+ });
533
+