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.
@@ -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({ id: 'mockJobId' }), // Mock addJob
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.mockClear) {
318
- travelgatePlugin.searchProducts.mockClear();
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.mockClear) travelgatePlugin.searchProducts.mockClear();
393
+ if (travelgatePlugin.searchProducts.mockReset) travelgatePlugin.searchProducts.mockReset();
345
394
  if (addJob.mockClear) addJob.mockClear();
346
395
  });
347
396
 
348
- it('should return stale (non-empty) products when TTR expires and refresh yields empty products', async () => {
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
- // It does not verify the cache state *after* the job (which would use the empty refresh)
391
- // because $updateProductSearchCache currently caches empty results.
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(),