ti2 1.0.115 → 1.0.118
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 +1 -0
- package/__tests__/cache.js +64 -0
- package/api.yml +112 -0
- package/cache.js +74 -30
- package/controllers/__tests__/bookings-searchProducts.js +483 -8
- package/controllers/__tests__/bookings.js +53 -0
- package/controllers/bookings.js +352 -48
- package/index.js +3 -0
- package/package.json +1 -1
- package/test/plugin.js +41 -0
- package/tutorials/plugin-development.md +2 -0
- package/.windsurfrules +0 -9
- package/CLAUDE.md +0 -159
|
@@ -9,13 +9,14 @@ const cache = require('../../cache');
|
|
|
9
9
|
// Mock the worker/queue module
|
|
10
10
|
jest.mock('../../worker/queue', () => ({
|
|
11
11
|
...jest.requireActual('../../worker/queue'), // Import and retain default behavior
|
|
12
|
-
addJob: jest.fn().mockResolvedValue(
|
|
12
|
+
addJob: jest.fn().mockResolvedValue('mockJobId'), // Mock addJob
|
|
13
13
|
// Provide mock for jobStatus as it might be called by other parts of the code or tests
|
|
14
14
|
jobStatus: jest.fn().mockResolvedValue({ status: 'completed', progress: 100, returnValue: null }),
|
|
15
15
|
}));
|
|
16
16
|
const { addJob, jobStatus: mockJobStatus } = require('../../worker/queue'); // Now addJob is the mock
|
|
17
17
|
|
|
18
18
|
const testUtils = require('../../test/utils'); // Require the module itself
|
|
19
|
+
const bookingsControllerFactory = require('../bookings');
|
|
19
20
|
|
|
20
21
|
// Global setup for the entire test file
|
|
21
22
|
let globalDoApiPost, globalPlugins, globalUtils, globalSqldb;
|
|
@@ -159,6 +160,44 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
159
160
|
expect(products[0].options.length).toBe(1);
|
|
160
161
|
expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
|
|
161
162
|
});
|
|
163
|
+
it('should filter cached full catalog by optionId without another plugin call', async () => {
|
|
164
|
+
const { products } = await doApiPost({
|
|
165
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
166
|
+
token: userToken,
|
|
167
|
+
payload: { optionId: '2345' },
|
|
168
|
+
});
|
|
169
|
+
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
170
|
+
expect(products).toHaveLength(1);
|
|
171
|
+
expect(products[0].options).toHaveLength(1);
|
|
172
|
+
expect(products[0].options[0].optionId).toBe('2345');
|
|
173
|
+
});
|
|
174
|
+
it('should emit sanitized cache-decision telemetry without raw ids, hints, payloads, or plugin results', async () => {
|
|
175
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
176
|
+
await doApiPost({
|
|
177
|
+
url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
|
|
178
|
+
token: userToken,
|
|
179
|
+
payload: { searchInput: 'Transfer from Sydney Harbor to Hilton Hotel' },
|
|
180
|
+
});
|
|
181
|
+
const decisionPayloads = emitSpy.mock.calls
|
|
182
|
+
.filter(call => call[0] === 'bookingsProductSearch:cache:decision')
|
|
183
|
+
.map(call => call[1]);
|
|
184
|
+
expect(decisionPayloads.length).toBeGreaterThan(0);
|
|
185
|
+
decisionPayloads.forEach(payload => {
|
|
186
|
+
expect(payload.userId).toBeUndefined();
|
|
187
|
+
expect(payload.hint).toBeUndefined();
|
|
188
|
+
expect(payload.cacheKey).toBeUndefined();
|
|
189
|
+
expect(payload.pluginResult).toBeUndefined();
|
|
190
|
+
expect(payload.searchInput).toBeUndefined();
|
|
191
|
+
expect(payload.optionId).toBeUndefined();
|
|
192
|
+
expect(payload.userIdHash).toBeTruthy();
|
|
193
|
+
expect(payload.hintHash).toBeTruthy();
|
|
194
|
+
expect(payload.cacheKeyHash).toBeTruthy();
|
|
195
|
+
expect(JSON.stringify(payload)).not.toContain(testUserId);
|
|
196
|
+
expect(JSON.stringify(payload)).not.toContain(testHint);
|
|
197
|
+
expect(JSON.stringify(payload)).not.toContain('Transfer from Sydney Harbor');
|
|
198
|
+
});
|
|
199
|
+
emitSpy.mockRestore();
|
|
200
|
+
});
|
|
162
201
|
});
|
|
163
202
|
describe('doNotCallPluginForProducts flag', () => {
|
|
164
203
|
const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
|
|
@@ -259,6 +298,7 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
259
298
|
it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
|
|
260
299
|
travelgatePlugin.searchProducts.mockClear(); // Clear before action
|
|
261
300
|
addJob.mockClear(); // Clear addJob mock before this action that should trigger it
|
|
301
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
262
302
|
|
|
263
303
|
const { products } = await doApiPost({
|
|
264
304
|
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
|
|
@@ -292,6 +332,15 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
292
332
|
expect(jobData.postProcess.args.userId).toBe(testUserId);
|
|
293
333
|
expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
|
|
294
334
|
expect(jobParams).toEqual({ removeOnComplete: true });
|
|
335
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
336
|
+
'bookingsProductSearch:cache:decision',
|
|
337
|
+
expect.objectContaining({
|
|
338
|
+
action: 'stale_served',
|
|
339
|
+
reason: 'backgroundRefreshQueued',
|
|
340
|
+
jobId: 'mockJobId',
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
emitSpy.mockRestore();
|
|
295
344
|
});
|
|
296
345
|
});
|
|
297
346
|
// The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
|
|
@@ -314,8 +363,8 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
314
363
|
|
|
315
364
|
beforeEach(async () => {
|
|
316
365
|
// Clear mocks
|
|
317
|
-
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.
|
|
318
|
-
travelgatePlugin.searchProducts.
|
|
366
|
+
if (travelgatePlugin && travelgatePlugin.searchProducts && travelgatePlugin.searchProducts.mockReset) {
|
|
367
|
+
travelgatePlugin.searchProducts.mockReset();
|
|
319
368
|
}
|
|
320
369
|
if (addJob && addJob.mockClear) {
|
|
321
370
|
addJob.mockClear();
|
|
@@ -341,13 +390,18 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
341
390
|
token: staleCacheTokenConfig,
|
|
342
391
|
});
|
|
343
392
|
// Clear mocks that might have been called during appSetup
|
|
344
|
-
if (travelgatePlugin.searchProducts.
|
|
393
|
+
if (travelgatePlugin.searchProducts.mockReset) travelgatePlugin.searchProducts.mockReset();
|
|
345
394
|
if (addJob.mockClear) addJob.mockClear();
|
|
346
395
|
});
|
|
347
396
|
|
|
348
|
-
it('should return stale
|
|
397
|
+
it('should return stale products and preserve the cache when refresh yields empty products', async () => {
|
|
349
398
|
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
350
399
|
const productsFromPluginRefresh = []; // Simulate plugin returning empty on refresh
|
|
400
|
+
const cacheKeyForTest = hash({
|
|
401
|
+
userId: testUserId,
|
|
402
|
+
hint: staleCacheTestHint,
|
|
403
|
+
operationId: 'bookingsProductSearch',
|
|
404
|
+
});
|
|
351
405
|
|
|
352
406
|
// 1. First call: Populate the cache with initialProductsInCache
|
|
353
407
|
travelgatePlugin.searchProducts.mockResolvedValueOnce({ products: initialProductsInCache });
|
|
@@ -387,8 +441,222 @@ describe('user: bookings controller - searchProducts', () => {
|
|
|
387
441
|
expect(travelgatePlugin.searchProducts).not.toHaveBeenCalled();
|
|
388
442
|
|
|
389
443
|
// Note: This test verifies the immediate response serves stale data and a job is queued.
|
|
390
|
-
//
|
|
391
|
-
|
|
444
|
+
// Cache preservation for empty background refreshes is covered below.
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should keep existing non-empty cache when background refresh returns empty products', async () => {
|
|
448
|
+
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
449
|
+
const cacheKeyForTest = hash({
|
|
450
|
+
userId: testUserId,
|
|
451
|
+
hint: staleCacheTestHint,
|
|
452
|
+
operationId: 'bookingsProductSearch',
|
|
453
|
+
});
|
|
454
|
+
await cache.save({
|
|
455
|
+
pluginName: testAppName,
|
|
456
|
+
key: cacheKeyForTest,
|
|
457
|
+
value: { products: initialProductsInCache },
|
|
458
|
+
ttl: 60,
|
|
459
|
+
});
|
|
460
|
+
await cache.save({
|
|
461
|
+
pluginName: testAppName,
|
|
462
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
463
|
+
value: 1,
|
|
464
|
+
ttl: 60,
|
|
465
|
+
});
|
|
466
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
467
|
+
|
|
468
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
469
|
+
appKey: testAppName,
|
|
470
|
+
userId: testUserId,
|
|
471
|
+
hint: staleCacheTestHint,
|
|
472
|
+
pluginResult: { products: [] },
|
|
473
|
+
requestId: 'empty-refresh-test',
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
477
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
478
|
+
expect(cached.products).toEqual(initialProductsInCache);
|
|
479
|
+
expect(lastUpdated).toBeGreaterThan(1);
|
|
480
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
481
|
+
'bookingsProductSearch:cache:decision',
|
|
482
|
+
expect.objectContaining({
|
|
483
|
+
action: 'empty_refresh_skipped',
|
|
484
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
485
|
+
cachePreserved: true,
|
|
486
|
+
existingProductCount: initialProductsInCache.length,
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
490
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
action: 'empty_refresh_skipped',
|
|
493
|
+
reason: 'emptyResultPreservedExistingCache',
|
|
494
|
+
cachePreserved: true,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
emitSpy.mockRestore();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('should not cache partial product refreshes as complete results', async () => {
|
|
501
|
+
const cacheKeyForTest = hash({
|
|
502
|
+
userId: testUserId,
|
|
503
|
+
hint: staleCacheTestHint,
|
|
504
|
+
operationId: 'bookingsProductSearch',
|
|
505
|
+
});
|
|
506
|
+
await cache.drop({ pluginName: testAppName, key: cacheKeyForTest });
|
|
507
|
+
|
|
508
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
509
|
+
appKey: testAppName,
|
|
510
|
+
userId: testUserId,
|
|
511
|
+
hint: staleCacheTestHint,
|
|
512
|
+
pluginResult: {
|
|
513
|
+
catalogPartial: true,
|
|
514
|
+
products: [{ productId: 'partialProd1' }],
|
|
515
|
+
},
|
|
516
|
+
requestId: 'partial-refresh-test',
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
520
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
521
|
+
expect(cached).toBeFalsy();
|
|
522
|
+
expect(lastUpdated).toBeTruthy();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should not update cache metadata when direct force refresh returns partial products', async () => {
|
|
526
|
+
const pluginResult = {
|
|
527
|
+
catalogPartial: true,
|
|
528
|
+
products: [{ productId: 'partialProd1' }],
|
|
529
|
+
};
|
|
530
|
+
const cacheKeyForTest = hash({
|
|
531
|
+
userId: testUserId,
|
|
532
|
+
hint: staleCacheTestHint,
|
|
533
|
+
operationId: 'bookingsProductSearch',
|
|
534
|
+
});
|
|
535
|
+
travelgatePlugin.searchProducts.mockResolvedValueOnce(pluginResult);
|
|
536
|
+
|
|
537
|
+
await doApiPost({
|
|
538
|
+
url: `/products/${testAppName}/${testUserId}/${staleCacheTestHint}/search`,
|
|
539
|
+
token: userToken,
|
|
540
|
+
payload: { forceRefresh: true },
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
544
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
545
|
+
expect(travelgatePlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
546
|
+
expect(cached).toBeFalsy();
|
|
547
|
+
expect(lastUpdated).toBeFalsy();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should keep existing non-empty cache when background refresh returns partial products', async () => {
|
|
551
|
+
const initialProductsInCache = [{ productId: 'staleProd1', name: 'Stale Product One', optionId: 'optStale1' }];
|
|
552
|
+
const pluginResult = {
|
|
553
|
+
catalogPartial: true,
|
|
554
|
+
products: [{ productId: 'partialProd1' }],
|
|
555
|
+
};
|
|
556
|
+
const cacheKeyForTest = hash({
|
|
557
|
+
userId: testUserId,
|
|
558
|
+
hint: staleCacheTestHint,
|
|
559
|
+
operationId: 'bookingsProductSearch',
|
|
560
|
+
});
|
|
561
|
+
await cache.save({
|
|
562
|
+
pluginName: testAppName,
|
|
563
|
+
key: cacheKeyForTest,
|
|
564
|
+
value: { products: initialProductsInCache },
|
|
565
|
+
ttl: 60,
|
|
566
|
+
});
|
|
567
|
+
await cache.save({
|
|
568
|
+
pluginName: testAppName,
|
|
569
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
570
|
+
value: 1,
|
|
571
|
+
ttl: 60,
|
|
572
|
+
});
|
|
573
|
+
const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
|
|
574
|
+
|
|
575
|
+
await bookingsControllerFactory(globalPlugins).$updateProductSearchCache({
|
|
576
|
+
appKey: testAppName,
|
|
577
|
+
userId: testUserId,
|
|
578
|
+
hint: staleCacheTestHint,
|
|
579
|
+
pluginResult,
|
|
580
|
+
requestId: 'partial-refresh-test',
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
const cached = await cache.get({ pluginName: testAppName, key: cacheKeyForTest });
|
|
584
|
+
const lastUpdated = await cache.get({ pluginName: testAppName, key: `${cacheKeyForTest}:lastUpdated` });
|
|
585
|
+
expect(cached.products).toEqual(initialProductsInCache);
|
|
586
|
+
expect(lastUpdated).toBeGreaterThan(1);
|
|
587
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
588
|
+
'bookingsProductSearch:cache:decision',
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
action: 'partial_refresh_skipped',
|
|
591
|
+
reason: 'partialResultPreservedExistingCache',
|
|
592
|
+
cachePreserved: true,
|
|
593
|
+
existingProductCount: initialProductsInCache.length,
|
|
594
|
+
returnedProductCount: pluginResult.products.length,
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
598
|
+
'bookingsProductSearch:cache:partialRefreshSkipped',
|
|
599
|
+
expect.objectContaining({
|
|
600
|
+
action: 'partial_refresh_skipped',
|
|
601
|
+
reason: 'partialResultPreservedExistingCache',
|
|
602
|
+
cachePreserved: true,
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
emitSpy.mockRestore();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should not cache empty products if non-empty cache appears before the empty save', async () => {
|
|
609
|
+
const cacheKeyForTest = hash({
|
|
610
|
+
userId: 'race-user',
|
|
611
|
+
hint: 'race-hint',
|
|
612
|
+
operationId: 'bookingsProductSearch',
|
|
613
|
+
});
|
|
614
|
+
const pluginResult = { products: [] };
|
|
615
|
+
const fakePlugin = {
|
|
616
|
+
name: 'racePlugin',
|
|
617
|
+
cache: {
|
|
618
|
+
get: jest.fn()
|
|
619
|
+
.mockResolvedValueOnce(null)
|
|
620
|
+
.mockResolvedValueOnce({ products: [{ productId: 'raceProd1' }] }),
|
|
621
|
+
save: jest.fn(async () => {}),
|
|
622
|
+
},
|
|
623
|
+
events: {
|
|
624
|
+
emit: jest.fn(),
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
await bookingsControllerFactory([fakePlugin]).$updateProductSearchCache({
|
|
629
|
+
appKey: fakePlugin.name,
|
|
630
|
+
userId: 'race-user',
|
|
631
|
+
hint: 'race-hint',
|
|
632
|
+
pluginResult,
|
|
633
|
+
requestId: 'race-refresh-test',
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
expect(fakePlugin.cache.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
637
|
+
key: `${cacheKeyForTest}:lastUpdated`,
|
|
638
|
+
}));
|
|
639
|
+
expect(fakePlugin.cache.save).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
640
|
+
key: cacheKeyForTest,
|
|
641
|
+
value: { products: [] },
|
|
642
|
+
}));
|
|
643
|
+
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
644
|
+
'bookingsProductSearch:cache:decision',
|
|
645
|
+
expect.objectContaining({
|
|
646
|
+
action: 'empty_refresh_skipped',
|
|
647
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
648
|
+
cachePreserved: true,
|
|
649
|
+
existingProductCount: 1,
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
expect(fakePlugin.events.emit).toHaveBeenCalledWith(
|
|
653
|
+
'bookingsProductSearch:cache:emptyRefreshSkipped',
|
|
654
|
+
expect.objectContaining({
|
|
655
|
+
action: 'empty_refresh_skipped',
|
|
656
|
+
reason: 'emptyResultPreservedConcurrentCache',
|
|
657
|
+
cachePreserved: true,
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
392
660
|
});
|
|
393
661
|
});
|
|
394
662
|
});
|
|
@@ -445,6 +713,24 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
445
713
|
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` }); // jobQueueLock
|
|
446
714
|
});
|
|
447
715
|
|
|
716
|
+
const clearProductSearchCache = async () => {
|
|
717
|
+
const cacheKey = hash({
|
|
718
|
+
userId: testUserId,
|
|
719
|
+
hint: ttrTestHint,
|
|
720
|
+
operationId: 'bookingsProductSearch',
|
|
721
|
+
});
|
|
722
|
+
await cache.drop({ pluginName: testAppName, key: cacheKey });
|
|
723
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lastUpdated` });
|
|
724
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:lock` });
|
|
725
|
+
await cache.drop({ pluginName: testAppName, key: `${cacheKey}:jobLock` });
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const makeProductSearchRequest = () => doApiPost({
|
|
729
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
730
|
+
token: userToken,
|
|
731
|
+
payload: {},
|
|
732
|
+
});
|
|
733
|
+
|
|
448
734
|
beforeEach(async () => {
|
|
449
735
|
// Clear mocks before each test in this suite
|
|
450
736
|
addJob.mockClear(); // Reset addJob mock calls
|
|
@@ -529,5 +815,194 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
|
|
|
529
815
|
);
|
|
530
816
|
}
|
|
531
817
|
});
|
|
532
|
-
});
|
|
533
818
|
|
|
819
|
+
it('multiple concurrent requests with no cache should wait for one plugin fetch', async () => {
|
|
820
|
+
await clearProductSearchCache();
|
|
821
|
+
|
|
822
|
+
const coldProducts = [{
|
|
823
|
+
productId: 'cold-product',
|
|
824
|
+
productName: 'Cold Product',
|
|
825
|
+
options: [{
|
|
826
|
+
optionId: 'cold-option',
|
|
827
|
+
optionName: 'Cold Option',
|
|
828
|
+
}],
|
|
829
|
+
}];
|
|
830
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
831
|
+
const releasePluginFetches = [];
|
|
832
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
833
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
834
|
+
resolve();
|
|
835
|
+
return new Promise(pluginResolve => {
|
|
836
|
+
releasePluginFetches.push(() => pluginResolve({ products: coldProducts }));
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
|
|
842
|
+
await pluginFetchStarted;
|
|
843
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
844
|
+
|
|
845
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
846
|
+
const results = await Promise.all(requestPromises);
|
|
847
|
+
|
|
848
|
+
results.forEach(result => {
|
|
849
|
+
expect(result.products).toEqual(coldProducts);
|
|
850
|
+
});
|
|
851
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
852
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
|
|
856
|
+
await clearProductSearchCache();
|
|
857
|
+
|
|
858
|
+
const refreshedProducts = [{
|
|
859
|
+
productId: 'force-product',
|
|
860
|
+
productName: 'Force Product',
|
|
861
|
+
options: [{
|
|
862
|
+
optionId: 'force-option',
|
|
863
|
+
optionName: 'Force Option',
|
|
864
|
+
}],
|
|
865
|
+
}];
|
|
866
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
867
|
+
const releasePluginFetches = [];
|
|
868
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
869
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
870
|
+
resolve();
|
|
871
|
+
return new Promise(pluginResolve => {
|
|
872
|
+
releasePluginFetches.push(() => pluginResolve({ products: refreshedProducts }));
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
const makeForceRequest = () => doApiPost({
|
|
877
|
+
url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
|
|
878
|
+
token: userToken,
|
|
879
|
+
payload: { forceRefresh: true },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
|
|
883
|
+
await pluginFetchStarted;
|
|
884
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
885
|
+
|
|
886
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
887
|
+
const results = await Promise.all(requestPromises);
|
|
888
|
+
|
|
889
|
+
results.forEach(result => {
|
|
890
|
+
expect(result.products).toEqual(refreshedProducts);
|
|
891
|
+
});
|
|
892
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
893
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
|
|
897
|
+
await clearProductSearchCache();
|
|
898
|
+
const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
899
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = '1';
|
|
900
|
+
|
|
901
|
+
const slowProducts = [{
|
|
902
|
+
productId: 'slow-product',
|
|
903
|
+
productName: 'Slow Product',
|
|
904
|
+
options: [{
|
|
905
|
+
optionId: 'slow-option',
|
|
906
|
+
optionName: 'Slow Option',
|
|
907
|
+
}],
|
|
908
|
+
}];
|
|
909
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
910
|
+
const expireIfValueSpy = jest.spyOn(lockTestPlugin.cache, 'expireIfValue');
|
|
911
|
+
const releasePluginFetches = [];
|
|
912
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
913
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
914
|
+
resolve();
|
|
915
|
+
return new Promise(pluginResolve => {
|
|
916
|
+
releasePluginFetches.push(() => pluginResolve({ products: slowProducts }));
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const firstRequest = makeProductSearchRequest();
|
|
923
|
+
await pluginFetchStarted;
|
|
924
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
925
|
+
const secondRequest = makeProductSearchRequest();
|
|
926
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
927
|
+
|
|
928
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
929
|
+
const results = await Promise.all([firstRequest, secondRequest]);
|
|
930
|
+
|
|
931
|
+
results.forEach(result => {
|
|
932
|
+
expect(result.products).toEqual(slowProducts);
|
|
933
|
+
});
|
|
934
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
935
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
936
|
+
expect(expireIfValueSpy).toHaveBeenCalled();
|
|
937
|
+
} finally {
|
|
938
|
+
expireIfValueSpy.mockRestore();
|
|
939
|
+
if (originalLockTtl === undefined) {
|
|
940
|
+
delete process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
|
|
941
|
+
} else {
|
|
942
|
+
process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = originalLockTtl;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
|
|
948
|
+
await clearProductSearchCache();
|
|
949
|
+
|
|
950
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
951
|
+
const releasePluginFetches = [];
|
|
952
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
953
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
954
|
+
resolve();
|
|
955
|
+
return new Promise(pluginResolve => {
|
|
956
|
+
releasePluginFetches.push(() => pluginResolve({ products: [] }));
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()];
|
|
962
|
+
await pluginFetchStarted;
|
|
963
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
964
|
+
|
|
965
|
+
releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
|
|
966
|
+
const results = await Promise.all(requestPromises);
|
|
967
|
+
|
|
968
|
+
results.forEach(result => {
|
|
969
|
+
expect(result.products).toEqual([]);
|
|
970
|
+
});
|
|
971
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
972
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('multiple concurrent requests with no cache should fail when the leader throws', async () => {
|
|
976
|
+
await clearProductSearchCache();
|
|
977
|
+
|
|
978
|
+
lockTestPlugin.searchProducts.mockReset();
|
|
979
|
+
let rejectPluginFetch;
|
|
980
|
+
const pluginFetchStarted = new Promise(resolve => {
|
|
981
|
+
lockTestPlugin.searchProducts.mockImplementation(() => {
|
|
982
|
+
resolve();
|
|
983
|
+
return new Promise((_, reject) => {
|
|
984
|
+
rejectPluginFetch = () => reject(new Error('boom'));
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const requestPromises = [makeProductSearchRequest(), makeProductSearchRequest(), makeProductSearchRequest()]
|
|
990
|
+
.map(requestPromise => requestPromise
|
|
991
|
+
.then(result => ({ status: 'fulfilled', result }))
|
|
992
|
+
.catch(error => ({ status: 'rejected', error })));
|
|
993
|
+
await pluginFetchStarted;
|
|
994
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
995
|
+
|
|
996
|
+
rejectPluginFetch();
|
|
997
|
+
const results = await Promise.all(requestPromises);
|
|
998
|
+
|
|
999
|
+
results.forEach(result => {
|
|
1000
|
+
expect(result.status).toBe('rejected');
|
|
1001
|
+
});
|
|
1002
|
+
expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
|
|
1003
|
+
expect(addJob).not.toHaveBeenCalled();
|
|
1004
|
+
|
|
1005
|
+
const statuses = results.map(result => result.error.response.status).sort();
|
|
1006
|
+
expect(statuses).toEqual([500, 503, 503]);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
@@ -170,6 +170,59 @@ describe('user: bookings controller', () => {
|
|
|
170
170
|
expect(plugins[0].createBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
171
171
|
expect(plugins[0].createBooking.mock.calls[0][0].token).toEqual(token);
|
|
172
172
|
});
|
|
173
|
+
it('should be able to confirm a booking (with hint)', async () => {
|
|
174
|
+
const payload = {
|
|
175
|
+
bookingId: '14226',
|
|
176
|
+
};
|
|
177
|
+
await doApiPost({
|
|
178
|
+
url: `/bookings/${appKey}/${userId}/testingToken/confirm`,
|
|
179
|
+
token: userToken,
|
|
180
|
+
payload,
|
|
181
|
+
});
|
|
182
|
+
expect(plugins[0].confirmBooking).toHaveBeenCalled();
|
|
183
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
184
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].token).toEqual(token);
|
|
185
|
+
});
|
|
186
|
+
it('should be able to confirm a booking (no hint)', async () => {
|
|
187
|
+
const payload = {
|
|
188
|
+
bookingId: '14226',
|
|
189
|
+
};
|
|
190
|
+
await doApiPost({
|
|
191
|
+
url: `/bookings/${appKey}/${userId}/confirm`,
|
|
192
|
+
token: userToken,
|
|
193
|
+
payload,
|
|
194
|
+
});
|
|
195
|
+
expect(plugins[0].confirmBooking).toHaveBeenCalled();
|
|
196
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
197
|
+
expect(plugins[0].confirmBooking.mock.calls[0][0].token).toEqual(token);
|
|
198
|
+
});
|
|
199
|
+
it('should emit cancelBooking operation metadata when cancelling', async () => {
|
|
200
|
+
const payload = {
|
|
201
|
+
bookingId: chance.guid(),
|
|
202
|
+
};
|
|
203
|
+
const emitSpy = jest.spyOn(plugins[0].events, 'emit');
|
|
204
|
+
try {
|
|
205
|
+
await doApiPost({
|
|
206
|
+
url: `/bookings/${appKey}/${userId}/testingToken/cancel`,
|
|
207
|
+
token: userToken,
|
|
208
|
+
payload,
|
|
209
|
+
});
|
|
210
|
+
expect(plugins[0].cancelBooking).toHaveBeenCalled();
|
|
211
|
+
expect(plugins[0].cancelBooking.mock.calls[0][0].payload).toEqual(payload);
|
|
212
|
+
expect(plugins[0].cancelBooking.mock.calls[0][0].token).toEqual(token);
|
|
213
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
214
|
+
'bookingsCancelBooking',
|
|
215
|
+
expect.objectContaining({
|
|
216
|
+
userId,
|
|
217
|
+
hint: 'testingToken',
|
|
218
|
+
operationId: 'cancelBooking',
|
|
219
|
+
pluginName: appKey,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
emitSpy.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
173
226
|
it('should forward inline credentials directly to create booking', async () => {
|
|
174
227
|
const payload = {
|
|
175
228
|
id: chance.guid(),
|