ti2 1.0.116 → 1.0.119

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 CHANGED
@@ -56,6 +56,7 @@ Plugins are the connectores to other systems and/or features you intent to use;
56
56
  |searchAvailability|✓|✓|✓|✓|✓|✓|
57
57
  |searchQuote|✓|✓|✓|✓|✓|✓|
58
58
  |createBooking|✓|✓|✓|✓|✓|✓|
59
+ |confirmBooking|✓|✓|✓|✓|✓|✓|
59
60
  |cancelBooking|✓|✓|✓|✓|✓|✓|
60
61
  |searchProductsForItinerary|||||||||✓|
61
62
  |searchAvailabilityForItinerary|||||||||✓|
@@ -39,6 +39,46 @@ describe('cache', () => {
39
39
  });
40
40
  });
41
41
 
42
+ describe('owner-token helpers', () => {
43
+ it('should only renew and drop a key when the expected value still owns it', async () => {
44
+ const key = 'owner-lock:test';
45
+ testKeys.push(key);
46
+
47
+ await cache.save({ pluginName: testPluginName, key, value: 'owner-a', ttl: 1 });
48
+
49
+ const renewedByWrongOwner = await cache.expireIfValue({
50
+ pluginName: testPluginName,
51
+ key,
52
+ value: 'owner-b',
53
+ ttl: 60,
54
+ });
55
+ const renewedByOwner = await cache.expireIfValue({
56
+ pluginName: testPluginName,
57
+ key,
58
+ value: 'owner-a',
59
+ ttl: 60,
60
+ });
61
+ const droppedByWrongOwner = await cache.dropIfValue({
62
+ pluginName: testPluginName,
63
+ key,
64
+ value: 'owner-b',
65
+ });
66
+
67
+ expect(renewedByWrongOwner).toBe(false);
68
+ expect(renewedByOwner).toBe(true);
69
+ expect(droppedByWrongOwner).toBe(false);
70
+ expect(await cache.get({ pluginName: testPluginName, key })).toBe('owner-a');
71
+
72
+ const droppedByOwner = await cache.dropIfValue({
73
+ pluginName: testPluginName,
74
+ key,
75
+ value: 'owner-a',
76
+ });
77
+ expect(droppedByOwner).toBe(true);
78
+ expect(await cache.get({ pluginName: testPluginName, key })).toBeFalsy();
79
+ });
80
+ });
81
+
42
82
  describe('scan', () => {
43
83
  it('should return keys matching the pattern without plugin prefix', async () => {
44
84
  // Save some test keys
package/api.yml CHANGED
@@ -358,6 +358,39 @@ components:
358
358
  type: object
359
359
  allOf: # Combines the BasicErrorModel and the inline model
360
360
  - $ref: '#/components/schemas/bookingDetail'
361
+ bookingDetailsForConfirm:
362
+ type: object
363
+ additionalProperties: true
364
+ description: Booking identifier; provide bookingId, id, ref, or reference
365
+ properties:
366
+ bookingId:
367
+ type: string
368
+ id:
369
+ type: string
370
+ ref:
371
+ type: string
372
+ reference:
373
+ type: string
374
+ confirmBookingReturn:
375
+ type: object
376
+ additionalProperties: true
377
+ properties:
378
+ confirmBookingReply:
379
+ type: object
380
+ booking:
381
+ type: object
382
+ properties:
383
+ ref:
384
+ type: string
385
+ id:
386
+ type: string
387
+ status:
388
+ type: string
389
+ serviceLines:
390
+ type: array
391
+ items:
392
+ type: object
393
+ additionalProperties: true
361
394
  packageSearchQuery:
362
395
  type: object
363
396
  additionalProperties: true
@@ -1815,6 +1848,85 @@ paths:
1815
1848
  application/json:
1816
1849
  schema:
1817
1850
  $ref: '#/components/schemas/cancellationReturn'
1851
+ /bookings/{appKey}/{userId}/confirm:
1852
+ post:
1853
+ security:
1854
+ - bearerAuth: ['admin', 'user']
1855
+ tags:
1856
+ - bookings
1857
+ summary: Confirms a quote as a booking
1858
+ operationId: confirmBooking
1859
+ parameters:
1860
+ - name: appKey
1861
+ in: path
1862
+ description: app to confirm the booking for, defaults to first token
1863
+ required: true
1864
+ example: 'travelgate-hotelx'
1865
+ schema:
1866
+ type: string
1867
+ - name: userId
1868
+ in: path
1869
+ description: the user/company that the appKey belongs to
1870
+ required: true
1871
+ schema:
1872
+ type: string
1873
+ requestBody:
1874
+ description: Booking identifier to confirm
1875
+ required: true
1876
+ content:
1877
+ application/json:
1878
+ schema:
1879
+ $ref: '#/components/schemas/bookingDetailsForConfirm'
1880
+ responses:
1881
+ '200':
1882
+ description: Booking confirmation details
1883
+ content:
1884
+ application/json:
1885
+ schema:
1886
+ $ref: '#/components/schemas/confirmBookingReturn'
1887
+ /bookings/{appKey}/{userId}/{hint}/confirm:
1888
+ post:
1889
+ security:
1890
+ - bearerAuth: ['admin', 'user']
1891
+ tags:
1892
+ - bookings
1893
+ summary: Confirms a quote as a booking for a specific hint
1894
+ operationId: confirmBooking
1895
+ parameters:
1896
+ - name: appKey
1897
+ in: path
1898
+ description: app to confirm the booking for
1899
+ required: true
1900
+ example: 'travelgate'
1901
+ schema:
1902
+ type: string
1903
+ - name: userId
1904
+ in: path
1905
+ description: the user/company that the appKey belongs to
1906
+ required: true
1907
+ schema:
1908
+ type: string
1909
+ - name: hint
1910
+ in: path
1911
+ description: a specific token hint to be used
1912
+ required: true
1913
+ example: 'travelgate-australia'
1914
+ schema:
1915
+ type: string
1916
+ requestBody:
1917
+ description: Booking identifier to confirm
1918
+ required: true
1919
+ content:
1920
+ application/json:
1921
+ schema:
1922
+ $ref: '#/components/schemas/bookingDetailsForConfirm'
1923
+ responses:
1924
+ '200':
1925
+ description: Booking confirmation details
1926
+ content:
1927
+ application/json:
1928
+ schema:
1929
+ $ref: '#/components/schemas/confirmBookingReturn'
1818
1930
  /packages/{appKey}/{userId}/search:
1819
1931
  post:
1820
1932
  security:
package/cache.js CHANGED
@@ -48,6 +48,39 @@ const saveIfNotExists = async ({
48
48
  return result === 'OK';
49
49
  };
50
50
 
51
+ const expireIfValue = async ({
52
+ pluginName,
53
+ key: keyParam,
54
+ value,
55
+ ttl = defaultTTL,
56
+ }) => {
57
+ const key = buildKey({ pluginName, key: keyParam });
58
+ const expectedValue = JSON.stringify(value);
59
+ const result = await cache.eval(`
60
+ if redis.call('get', KEYS[1]) == ARGV[1] then
61
+ return redis.call('expire', KEYS[1], ARGV[2])
62
+ end
63
+ return 0
64
+ `, 1, key, expectedValue, ttl);
65
+ return result === 1;
66
+ };
67
+
68
+ const dropIfValue = async ({
69
+ pluginName,
70
+ key: keyParam,
71
+ value,
72
+ }) => {
73
+ const key = buildKey({ pluginName, key: keyParam });
74
+ const expectedValue = JSON.stringify(value);
75
+ const result = await cache.eval(`
76
+ if redis.call('get', KEYS[1]) == ARGV[1] then
77
+ return redis.call('del', KEYS[1])
78
+ end
79
+ return 0
80
+ `, 1, key, expectedValue);
81
+ return result === 1;
82
+ };
83
+
51
84
  const get = async ({
52
85
  pluginName,
53
86
  key: keyParam,
@@ -125,6 +158,8 @@ module.exports = {
125
158
  cache,
126
159
  save,
127
160
  saveIfNotExists,
161
+ expireIfValue,
162
+ dropIfValue,
128
163
  getOrExec,
129
164
  drop,
130
165
  get,
@@ -9,7 +9,7 @@ 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
  }));
@@ -160,6 +160,64 @@ describe('user: bookings controller - searchProducts', () => {
160
160
  expect(products[0].options.length).toBe(1);
161
161
  expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
162
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
+ });
201
+
202
+ it('includes raw scope only on legacy cache-save events', async () => {
203
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
204
+ await doApiPost({
205
+ url: `/products/${testAppName}/${testUserId}/${testHint}/search`,
206
+ token: userToken,
207
+ payload: { forceRefresh: true },
208
+ });
209
+ const cacheSavePayload = emitSpy.mock.calls
210
+ .filter(call => call[0] === 'bookingsProductSearch:cache:save')
211
+ .map(call => call[1])
212
+ .find(payload => payload.action === 'cache_saved');
213
+
214
+ expect(cacheSavePayload).toBeTruthy();
215
+ expect(cacheSavePayload.userId).toBe(testUserId);
216
+ expect(cacheSavePayload.hint).toBe(testHint);
217
+ expect(cacheSavePayload.userIdHash).toBeTruthy();
218
+ expect(cacheSavePayload.hintHash).toBeTruthy();
219
+ emitSpy.mockRestore();
220
+ });
163
221
  });
164
222
  describe('doNotCallPluginForProducts flag', () => {
165
223
  const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
@@ -260,6 +318,7 @@ describe('user: bookings controller - searchProducts', () => {
260
318
  it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
261
319
  travelgatePlugin.searchProducts.mockClear(); // Clear before action
262
320
  addJob.mockClear(); // Clear addJob mock before this action that should trigger it
321
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
263
322
 
264
323
  const { products } = await doApiPost({
265
324
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
@@ -293,6 +352,15 @@ describe('user: bookings controller - searchProducts', () => {
293
352
  expect(jobData.postProcess.args.userId).toBe(testUserId);
294
353
  expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
295
354
  expect(jobParams).toEqual({ removeOnComplete: true });
355
+ expect(emitSpy).toHaveBeenCalledWith(
356
+ 'bookingsProductSearch:cache:decision',
357
+ expect.objectContaining({
358
+ action: 'stale_served',
359
+ reason: 'backgroundRefreshQueued',
360
+ jobId: 'mockJobId',
361
+ }),
362
+ );
363
+ emitSpy.mockRestore();
296
364
  });
297
365
  });
298
366
  // The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
@@ -430,13 +498,22 @@ describe('user: bookings controller - searchProducts', () => {
430
498
  expect(cached.products).toEqual(initialProductsInCache);
431
499
  expect(lastUpdated).toBeGreaterThan(1);
432
500
  expect(emitSpy).toHaveBeenCalledWith(
433
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
501
+ 'bookingsProductSearch:cache:decision',
434
502
  expect.objectContaining({
503
+ action: 'empty_refresh_skipped',
435
504
  reason: 'emptyResultPreservedExistingCache',
436
505
  cachePreserved: true,
437
506
  existingProductCount: initialProductsInCache.length,
438
507
  }),
439
508
  );
509
+ expect(emitSpy).toHaveBeenCalledWith(
510
+ 'bookingsProductSearch:cache:emptyRefreshSkipped',
511
+ expect.objectContaining({
512
+ action: 'empty_refresh_skipped',
513
+ reason: 'emptyResultPreservedExistingCache',
514
+ cachePreserved: true,
515
+ }),
516
+ );
440
517
  emitSpy.mockRestore();
441
518
  });
442
519
 
@@ -528,12 +605,21 @@ describe('user: bookings controller - searchProducts', () => {
528
605
  expect(cached.products).toEqual(initialProductsInCache);
529
606
  expect(lastUpdated).toBeGreaterThan(1);
530
607
  expect(emitSpy).toHaveBeenCalledWith(
531
- 'bookingsProductSearch:cache:partialRefreshSkipped',
608
+ 'bookingsProductSearch:cache:decision',
532
609
  expect.objectContaining({
610
+ action: 'partial_refresh_skipped',
533
611
  reason: 'partialResultPreservedExistingCache',
534
612
  cachePreserved: true,
535
613
  existingProductCount: initialProductsInCache.length,
536
- pluginResult,
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,
537
623
  }),
538
624
  );
539
625
  emitSpy.mockRestore();
@@ -575,13 +661,22 @@ describe('user: bookings controller - searchProducts', () => {
575
661
  value: { products: [] },
576
662
  }));
577
663
  expect(fakePlugin.events.emit).toHaveBeenCalledWith(
578
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
664
+ 'bookingsProductSearch:cache:decision',
579
665
  expect.objectContaining({
666
+ action: 'empty_refresh_skipped',
580
667
  reason: 'emptyResultPreservedConcurrentCache',
581
668
  cachePreserved: true,
582
669
  existingProductCount: 1,
583
670
  }),
584
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
+ );
585
680
  });
586
681
  });
587
682
  });
@@ -777,6 +872,98 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
777
872
  expect(addJob).not.toHaveBeenCalled();
778
873
  });
779
874
 
875
+ it('concurrent forceRefresh requests should wait for one plugin fetch', async () => {
876
+ await clearProductSearchCache();
877
+
878
+ const refreshedProducts = [{
879
+ productId: 'force-product',
880
+ productName: 'Force Product',
881
+ options: [{
882
+ optionId: 'force-option',
883
+ optionName: 'Force Option',
884
+ }],
885
+ }];
886
+ lockTestPlugin.searchProducts.mockReset();
887
+ const releasePluginFetches = [];
888
+ const pluginFetchStarted = new Promise(resolve => {
889
+ lockTestPlugin.searchProducts.mockImplementation(() => {
890
+ resolve();
891
+ return new Promise(pluginResolve => {
892
+ releasePluginFetches.push(() => pluginResolve({ products: refreshedProducts }));
893
+ });
894
+ });
895
+ });
896
+ const makeForceRequest = () => doApiPost({
897
+ url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`,
898
+ token: userToken,
899
+ payload: { forceRefresh: true },
900
+ });
901
+
902
+ const requestPromises = [makeForceRequest(), makeForceRequest(), makeForceRequest()];
903
+ await pluginFetchStarted;
904
+ await new Promise(resolve => setTimeout(resolve, 50));
905
+
906
+ releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
907
+ const results = await Promise.all(requestPromises);
908
+
909
+ results.forEach(result => {
910
+ expect(result.products).toEqual(refreshedProducts);
911
+ });
912
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
913
+ expect(addJob).not.toHaveBeenCalled();
914
+ });
915
+
916
+ it('renews the plugin execution lock while a cold product fetch exceeds the initial TTL', async () => {
917
+ await clearProductSearchCache();
918
+ const originalLockTtl = process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
919
+ process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = '1';
920
+
921
+ const slowProducts = [{
922
+ productId: 'slow-product',
923
+ productName: 'Slow Product',
924
+ options: [{
925
+ optionId: 'slow-option',
926
+ optionName: 'Slow Option',
927
+ }],
928
+ }];
929
+ lockTestPlugin.searchProducts.mockReset();
930
+ const expireIfValueSpy = jest.spyOn(lockTestPlugin.cache, 'expireIfValue');
931
+ const releasePluginFetches = [];
932
+ const pluginFetchStarted = new Promise(resolve => {
933
+ lockTestPlugin.searchProducts.mockImplementation(() => {
934
+ resolve();
935
+ return new Promise(pluginResolve => {
936
+ releasePluginFetches.push(() => pluginResolve({ products: slowProducts }));
937
+ });
938
+ });
939
+ });
940
+
941
+ try {
942
+ const firstRequest = makeProductSearchRequest();
943
+ await pluginFetchStarted;
944
+ await new Promise(resolve => setTimeout(resolve, 1500));
945
+ const secondRequest = makeProductSearchRequest();
946
+ await new Promise(resolve => setTimeout(resolve, 50));
947
+
948
+ releasePluginFetches.forEach(releasePluginFetch => releasePluginFetch());
949
+ const results = await Promise.all([firstRequest, secondRequest]);
950
+
951
+ results.forEach(result => {
952
+ expect(result.products).toEqual(slowProducts);
953
+ });
954
+ expect(lockTestPlugin.searchProducts).toHaveBeenCalledTimes(1);
955
+ expect(addJob).not.toHaveBeenCalled();
956
+ expect(expireIfValueSpy).toHaveBeenCalled();
957
+ } finally {
958
+ expireIfValueSpy.mockRestore();
959
+ if (originalLockTtl === undefined) {
960
+ delete process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS;
961
+ } else {
962
+ process.env.PRODUCT_SEARCH_LOCK_TTL_SECONDS = originalLockTtl;
963
+ }
964
+ }
965
+ });
966
+
780
967
  it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
781
968
  await clearProductSearchCache();
782
969
 
@@ -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(),
@@ -1,4 +1,5 @@
1
1
  const assert = require('assert');
2
+ const crypto = require('crypto');
2
3
  const hash = require('object-hash');
3
4
  const R = require('ramda');
4
5
  const { UserAppKey } = require('../models/index');
@@ -34,12 +35,86 @@ const getPositiveIntegerEnv = (name, defaultValue) => {
34
35
  return Number.isFinite(value) && integerValue > 0 ? integerValue : defaultValue;
35
36
  };
36
37
 
37
- const productSearchLockTtlSeconds = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
38
+ const getProductSearchLockTtlSeconds = () => getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_TTL_SECONDS', 120);
38
39
  const productSearchLockWaitMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_WAIT_MS', 25 * 1000);
39
40
  const productSearchLockPollMs = getPositiveIntegerEnv('PRODUCT_SEARCH_LOCK_POLL_MS', 250);
40
41
  const emptyProductSearchCacheTtlSeconds = 60;
42
+ const productSearchCacheTtlSeconds = 30 * 24 * 60 * 60;
43
+ const productSearchOperationId = 'bookingsProductSearch';
44
+ const productSearchCacheDecisionEvent = 'bookingsProductSearch:cache:decision';
45
+ const legacyProductSearchCacheEvents = {
46
+ cache_saved: 'bookingsProductSearch:cache:save',
47
+ partial_refresh_skipped: 'bookingsProductSearch:cache:partialRefreshSkipped',
48
+ empty_refresh_skipped: 'bookingsProductSearch:cache:emptyRefreshSkipped',
49
+ empty_cache_saved: 'bookingsProductSearch:cache:emptyRefresh',
50
+ };
41
51
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
42
52
 
53
+ const productSearchCacheKey = ({ userId, hint }) => hash({ userId, hint, operationId: productSearchOperationId });
54
+
55
+ const productCount = value => R.pathOr([], ['products'], value).length;
56
+
57
+ const hasProductCache = cacheContent => Boolean(cacheContent && cacheContent.products);
58
+
59
+ const safeHash = value => (value === undefined || value === null ? undefined : hash(String(value)));
60
+
61
+ const emitProductSearchCacheDecision = ({ app, cacheKey, userId, hint, requestId, searchInput, optionId, forceRefresh, startedAt }, action, extra = {}) => {
62
+ if (!app.events || !app.events.emit) return;
63
+ const payload = {
64
+ action,
65
+ operationId: productSearchOperationId,
66
+ requestId,
67
+ pluginName: app.name,
68
+ userIdHash: safeHash(userId),
69
+ hintHash: safeHash(hint),
70
+ cacheKeyHash: safeHash(cacheKey),
71
+ forceRefresh: Boolean(forceRefresh),
72
+ hasSearchInput: Boolean((searchInput || '').trim()),
73
+ hasOptionId: Boolean(optionId && optionId.length),
74
+ elapsedMs: Date.now() - startedAt,
75
+ ...extra,
76
+ };
77
+ app.events.emit(productSearchCacheDecisionEvent, payload);
78
+ if (legacyProductSearchCacheEvents[action]) {
79
+ app.events.emit(legacyProductSearchCacheEvents[action], {
80
+ ...payload,
81
+ userId,
82
+ hint,
83
+ });
84
+ }
85
+ };
86
+
87
+ const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
88
+ if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
89
+
90
+ const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
91
+ let stopped = false;
92
+ let inFlightRenewal = Promise.resolve();
93
+ const renew = async () => {
94
+ if (stopped) return;
95
+ const renewed = await app.cache.expireIfValue({ key, value, ttl });
96
+ if (!renewed) {
97
+ stopped = true;
98
+ onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
99
+ }
100
+ };
101
+ const interval = setInterval(() => {
102
+ inFlightRenewal = renew().catch(err => {
103
+ stopped = true;
104
+ onDecision('lock_renew_failed', { reason: err.message });
105
+ });
106
+ }, renewEveryMs);
107
+ if (interval.unref) interval.unref();
108
+
109
+ return {
110
+ stop: async () => {
111
+ stopped = true;
112
+ clearInterval(interval);
113
+ await inFlightRenewal;
114
+ },
115
+ };
116
+ };
117
+
43
118
  const createProductSearchUnavailableError = () => {
44
119
  const err = new Error('Product search cache refresh did not produce cached results');
45
120
  err.status = 503;
@@ -95,6 +170,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
95
170
  } = req;
96
171
  try {
97
172
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
173
+ assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
98
174
  const results = await app.cancelBooking({
99
175
  axios,
100
176
  token,
@@ -104,6 +180,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
104
180
  hint,
105
181
  requestId: req.requestId,
106
182
  });
183
+ app.events.emit('bookingsCancelBooking', {
184
+ userId,
185
+ hint,
186
+ operationId: 'cancelBooking',
187
+ requestId: req.requestId,
188
+ pluginName: app.name,
189
+ payload: results,
190
+ });
191
+ return res.json(results);
192
+ } catch (err) {
193
+ return next(err);
194
+ }
195
+ };
196
+
197
+ const confirmBooking = plugins => async (req, res, next) => {
198
+ const {
199
+ axios,
200
+ params: { appKey, userId, hint },
201
+ body,
202
+ } = req;
203
+ try {
204
+ const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
205
+ assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
206
+ const results = await app.confirmBooking({
207
+ axios,
208
+ token,
209
+ payload: body,
210
+ typeDefsAndQueries,
211
+ userId,
212
+ hint,
213
+ requestId: req.requestId,
214
+ });
215
+ app.events.emit('bookingsConfirmBooking', {
216
+ userId,
217
+ hint,
218
+ operationId: 'confirmBooking',
219
+ requestId: req.requestId,
220
+ pluginName: app.name,
221
+ payload: results,
222
+ });
107
223
  return res.json(results);
108
224
  } catch (err) {
109
225
  return next(err);
@@ -183,7 +299,19 @@ const $bookingsProductSearch = plugins => async ({
183
299
  assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
184
300
  const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
185
301
 
186
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
302
+ const cacheKey = productSearchCacheKey({ userId, hint });
303
+ const telemetryContext = {
304
+ app,
305
+ cacheKey,
306
+ userId,
307
+ hint,
308
+ requestId,
309
+ searchInput,
310
+ optionId,
311
+ forceRefresh,
312
+ startedAt: Date.now(),
313
+ };
314
+ const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
187
315
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
188
316
  const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
189
317
 
@@ -194,11 +322,17 @@ const $bookingsProductSearch = plugins => async ({
194
322
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
195
323
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
196
324
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
325
+ emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
326
+ cacheProductCount: productCount(initialActualCacheContent),
327
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
328
+ ttrMs: ttr * 1000,
329
+ reason: hasPluginExecutionLock ? 'lockActive' : undefined,
330
+ });
197
331
  assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
198
332
 
199
333
  const getCachedProductSearchResults = async () => {
200
334
  const cacheContent = await app.cache.get({ key: cacheKey });
201
- if (cacheContent && cacheContent.products) return cacheContent;
335
+ if (hasProductCache(cacheContent)) return cacheContent;
202
336
  return null;
203
337
  };
204
338
 
@@ -217,23 +351,46 @@ const $bookingsProductSearch = plugins => async ({
217
351
  return getCachedProductSearchResults();
218
352
  };
219
353
 
220
- const acquirePluginExecutionLock = async () => app.cache.saveIfNotExists({
354
+ const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
221
355
  key: pluginExecutionLockKey,
222
- value: true,
223
- ttl: productSearchLockTtlSeconds,
356
+ value: lockOwnerToken,
357
+ ttl: getProductSearchLockTtlSeconds(),
224
358
  });
225
359
 
226
360
  // Helper function to call the plugin, save cache, and return results
227
- const fetchFromPluginAndCache = async () => {
228
- const lockAcquired = await acquirePluginExecutionLock();
361
+ const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
362
+ const lockOwnerToken = crypto.randomBytes(16).toString('hex');
363
+ const lockStartedAt = Date.now();
364
+ const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
229
365
  if (!lockAcquired) {
366
+ emitDecision('lock_wait', { reason, lockWaitMs: 0 });
230
367
  const cacheContent = await waitForProductSearchCache();
231
- if (cacheContent) return cacheContent;
368
+ const lockWaitMs = Date.now() - lockStartedAt;
369
+ if (cacheContent) {
370
+ emitDecision('cache_hit', {
371
+ reason: 'waitedForLeader',
372
+ lockWaitMs,
373
+ cacheProductCount: productCount(cacheContent),
374
+ });
375
+ return cacheContent;
376
+ }
377
+ emitDecision('lock_timeout', { reason, lockWaitMs });
232
378
  throw createProductSearchUnavailableError();
233
379
  }
234
380
 
381
+ const lockTtlSeconds = getProductSearchLockTtlSeconds();
382
+ emitDecision('lock_acquired', { reason, lockTtlSeconds });
383
+ const lockRenewal = createLockRenewal({
384
+ app,
385
+ key: pluginExecutionLockKey,
386
+ value: lockOwnerToken,
387
+ ttl: lockTtlSeconds,
388
+ onDecision: emitDecision,
389
+ });
390
+
235
391
  let pluginResults;
236
392
  try {
393
+ const pluginStartedAt = Date.now();
237
394
  pluginResults = await func({
238
395
  axios,
239
396
  token,
@@ -243,51 +400,72 @@ const $bookingsProductSearch = plugins => async ({
243
400
  userId,
244
401
  hint,
245
402
  });
403
+ const pluginElapsedMs = Date.now() - pluginStartedAt;
246
404
 
247
405
  // This function is responsible for caching if it fetched usable results.
248
406
  // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
249
407
  // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
250
408
  if (hasCacheableProductResults(pluginResults)) {
251
- const monthInSeconds = 30 * 24 * 60 * 60;
252
- await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
253
- await app.cache.save({ key: cacheKey, value: pluginResults, ttl: monthInSeconds });
254
- app.events.emit('bookingsProductSearch:cache:save', {
255
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
409
+ await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
410
+ await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
411
+ emitDecision('cache_saved', {
412
+ reason,
413
+ pluginElapsedMs,
414
+ cacheProductCount: productCount(pluginResults),
256
415
  });
257
416
  } else if (pluginResults && (pluginResults.catalogPartial || pluginResults.partial)) {
258
- app.events.emit('bookingsProductSearch:cache:partialRefreshSkipped', {
259
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
260
- reason: 'directPartialResultNotCached', pluginResult: pluginResults,
417
+ emitDecision('partial_refresh_skipped', {
418
+ reason: 'directPartialResultNotCached',
419
+ pluginElapsedMs,
420
+ cachePreserved: false,
421
+ returnedProductCount: productCount(pluginResults),
261
422
  });
262
423
  } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
263
424
  // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
264
425
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
426
+ emitDecision('empty_cache_saved', {
427
+ reason,
428
+ pluginElapsedMs,
429
+ cacheProductCount: 0,
430
+ });
265
431
  }
266
432
  } finally {
267
- await app.cache.drop({ key: pluginExecutionLockKey });
433
+ await lockRenewal.stop();
434
+ if (app.cache.dropIfValue) {
435
+ await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
436
+ } else {
437
+ await app.cache.drop({ key: pluginExecutionLockKey });
438
+ }
268
439
  }
269
440
  return pluginResults || { products: [] }; // Ensure products array exists
270
441
  };
271
442
 
272
443
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
273
444
  if (doNotCallPluginForProducts && !forceRefresh) {
274
- if (initialActualCacheContent && initialActualCacheContent.products) {
445
+ if (hasProductCache(initialActualCacheContent)) {
275
446
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
447
+ emitDecision('cache_hit', {
448
+ reason: 'doNotCallPluginForProducts',
449
+ cacheProductCount: initialActualCacheContent.products.length,
450
+ returnedProductCount: searchResults.length,
451
+ });
276
452
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
277
453
  }
454
+ emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
278
455
  return { products: [], ...(token.configuration || {}) };
279
456
  }
280
457
 
281
458
  // 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
282
459
  // fetchFromPluginAndCache will handle caching the new results.
283
460
  if (forceRefresh) {
284
- const funcResults = await fetchFromPluginAndCache();
461
+ emitDecision('force_refresh');
462
+ const funcResults = await fetchFromPluginAndCache('force_refresh');
285
463
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
286
464
  return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
287
465
  }
288
466
 
289
467
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
290
- if (initialActualCacheContent && initialActualCacheContent.products) {
468
+ if (hasProductCache(initialActualCacheContent)) {
291
469
  const cacheIsEmpty = !initialActualCacheContent.products.length;
292
470
  const trimmedSearch = (searchInput || '').trim();
293
471
  const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
@@ -295,8 +473,15 @@ const $bookingsProductSearch = plugins => async ({
295
473
  // If cache is empty and no search filter, skip to case 4 to fetch fresh data
296
474
  const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
297
475
  if (!shouldSkipEmptyCache) {
298
- const returnCachedResults = () => {
476
+ const returnCachedResults = (action = 'cache_hit', extra = {}) => {
299
477
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
478
+ emitDecision(action, {
479
+ cacheProductCount: initialActualCacheContent.products.length,
480
+ returnedProductCount: searchResults.length,
481
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
482
+ ttrMs: ttr * 1000,
483
+ ...extra,
484
+ });
300
485
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
301
486
  };
302
487
 
@@ -304,7 +489,7 @@ const $bookingsProductSearch = plugins => async ({
304
489
 
305
490
  // Cache is fresh or plugin execution in progress: serve from cache
306
491
  if (!isEffectivelyStale || hasPluginExecutionLock) {
307
- return returnCachedResults();
492
+ return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
308
493
  }
309
494
 
310
495
  // Cache is stale - queue at most one background refresh job and serve stale data.
@@ -314,10 +499,10 @@ const $bookingsProductSearch = plugins => async ({
314
499
  ttl: 60,
315
500
  });
316
501
  if (!jobQueueLockAcquired) {
317
- return returnCachedResults();
502
+ return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
318
503
  }
319
504
 
320
- await addJob({
505
+ const jobId = await addJob({
321
506
  type: 'plugin',
322
507
  pluginName: appKey,
323
508
  method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
@@ -330,13 +515,16 @@ const $bookingsProductSearch = plugins => async ({
330
515
  },
331
516
  }, { removeOnComplete: true });
332
517
 
333
- return returnCachedResults();
518
+ return returnCachedResults('stale_served', {
519
+ reason: 'backgroundRefreshQueued',
520
+ jobId,
521
+ });
334
522
  }
335
523
  }
336
524
 
337
525
  // 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
338
526
  // Fetch from plugin. fetchFromPluginAndCache will handle caching.
339
- const funcResults = await fetchFromPluginAndCache();
527
+ const funcResults = await fetchFromPluginAndCache('cache_miss');
340
528
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
341
529
  return {
342
530
  ...funcResults,
@@ -625,22 +813,28 @@ const $updateProductSearchCache = plugins => async ({
625
813
  return; // Or throw error
626
814
  }
627
815
 
628
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
629
- const monthInSeconds = 30 * 24 * 60 * 60;
816
+ const cacheKey = productSearchCacheKey({ userId, hint });
630
817
  const markRefreshAttempted = () => app.cache.save({
631
818
  key: `${cacheKey}:lastUpdated`,
632
819
  value: Date.now(),
633
- ttl: monthInSeconds,
634
- });
635
- const emitCacheEvent = (eventName, extra = {}) => app.events.emit(eventName, {
636
- cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
637
- ...extra,
820
+ ttl: productSearchCacheTtlSeconds,
638
821
  });
822
+ const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
823
+ app,
824
+ cacheKey,
825
+ userId,
826
+ hint,
827
+ requestId,
828
+ searchInput: '',
829
+ optionId: '',
830
+ forceRefresh: false,
831
+ startedAt: Date.now(),
832
+ }, action, extra);
639
833
 
640
834
  if (hasCacheableProductResults(pluginResult)) {
641
835
  await markRefreshAttempted();
642
- await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
643
- emitCacheEvent('bookingsProductSearch:cache:save');
836
+ await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
837
+ emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
644
838
  } else {
645
839
  const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
646
840
  const existingCacheContent = await app.cache.get({ key: cacheKey });
@@ -651,9 +845,9 @@ const $updateProductSearchCache = plugins => async ({
651
845
  cachePreserved: true,
652
846
  existingProductCount: existingCacheContent.products.length,
653
847
  };
654
- if (isPartialRefresh) eventExtra.pluginResult = pluginResult;
848
+ if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
655
849
  emitCacheEvent(
656
- isPartialRefresh ? 'bookingsProductSearch:cache:partialRefreshSkipped' : 'bookingsProductSearch:cache:emptyRefreshSkipped',
850
+ isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
657
851
  eventExtra,
658
852
  );
659
853
  return;
@@ -661,10 +855,10 @@ const $updateProductSearchCache = plugins => async ({
661
855
 
662
856
  if (isPartialRefresh) {
663
857
  await markRefreshAttempted();
664
- emitCacheEvent('bookingsProductSearch:cache:partialRefreshSkipped', {
858
+ emitCacheEvent('partial_refresh_skipped', {
665
859
  reason: 'partialResultNotCached',
666
860
  cachePreserved: false,
667
- pluginResult,
861
+ returnedProductCount: productCount(pluginResult),
668
862
  });
669
863
  return;
670
864
  }
@@ -673,7 +867,7 @@ const $updateProductSearchCache = plugins => async ({
673
867
  const latestCacheContent = await app.cache.get({ key: cacheKey });
674
868
  if (hasNonEmptyProductCache(latestCacheContent)) {
675
869
  await markRefreshAttempted();
676
- emitCacheEvent('bookingsProductSearch:cache:emptyRefreshSkipped', {
870
+ emitCacheEvent('empty_refresh_skipped', {
677
871
  reason: 'emptyResultPreservedConcurrentCache',
678
872
  cachePreserved: true,
679
873
  existingProductCount: latestCacheContent.products.length,
@@ -683,8 +877,8 @@ const $updateProductSearchCache = plugins => async ({
683
877
 
684
878
  // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
685
879
  await markRefreshAttempted();
686
- await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
687
- emitCacheEvent('bookingsProductSearch:cache:emptyRefresh');
880
+ await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
881
+ emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
688
882
  }
689
883
  };
690
884
 
@@ -693,6 +887,7 @@ const controllerFactory = plugins => {
693
887
  const controllerFunctions = {
694
888
  bookingsSearch: bookingsSearch(plugins),
695
889
  bookingsCancel: bookingsCancel(plugins),
890
+ confirmBooking: confirmBooking(plugins),
696
891
  $bookingsProductSearch: $bookingsProductSearch(plugins),
697
892
  bookingsProductSearch: bookingsProductSearch(plugins),
698
893
  getProductPackages: getProductPackages(plugins),
package/index.js CHANGED
@@ -91,6 +91,8 @@ module.exports = async ({
91
91
  getOrExec: args => cache.getOrExec({ ...args, pluginName }),
92
92
  save: args => cache.save({ ...args, pluginName }),
93
93
  saveIfNotExists: args => cache.saveIfNotExists({ ...args, pluginName }),
94
+ expireIfValue: args => cache.expireIfValue({ ...args, pluginName }),
95
+ dropIfValue: args => cache.dropIfValue({ ...args, pluginName }),
94
96
  scan: args => cache.scan({ ...args, pluginName }),
95
97
  },
96
98
  axios: pluginAxios,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.116",
3
+ "version": "1.0.119",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/plugin.js CHANGED
@@ -75,6 +75,21 @@ class Plugin {
75
75
  });
76
76
  this.searchQuote = jestPlugin.fn(() => ({ quote: [{ id: chance.guid() }] }));
77
77
  this.createBooking = jestPlugin.fn(() => {});
78
+ this.cancelBooking = jestPlugin.fn(() => ({
79
+ cancelServicesReply: {},
80
+ cancellation: {
81
+ id: chance.guid(),
82
+ status: 'Cancelled',
83
+ },
84
+ }));
85
+ this.confirmBooking = jestPlugin.fn(() => ({
86
+ confirmBookingReply: {},
87
+ booking: {
88
+ id: chance.guid(),
89
+ status: 'OK',
90
+ serviceLines: [],
91
+ },
92
+ }));
78
93
  this.getAffiliateAgents = jestPlugin.fn(() => ({ agents: [] }));
79
94
  this.searchItineraries = jestPlugin.fn(() => ({ bookings: [] }));
80
95
  this.searchProductsForItinerary = jestPlugin.fn(() => ({ products: [] }));
@@ -535,6 +550,32 @@ class Plugin {
535
550
  */
536
551
  createBooking() {}
537
552
 
553
+ /**
554
+ * Cancel a booking
555
+ * @async
556
+ * @param {Object} args - Cancel booking arguments.
557
+ * @param {Object} args.token - A token definition, it's content varies between integrations.
558
+ * @param {Object} args.payload - Booking identifier payload.
559
+ * @returns {object} retVal - the return object.
560
+ */
561
+ cancelBooking() {}
562
+
563
+ /**
564
+ * Confirm a quote as a booking
565
+ * @async
566
+ * @param {Object} args - Confirm booking arguments.
567
+ * @param {Object} args.token - A token definition, it's content varies between integrations.
568
+ * @param {Object} args.payload - Booking identifier payload.
569
+ * @param {string} [args.payload.bookingId] - Booking id to confirm.
570
+ * @param {string} [args.payload.id] - Booking id to confirm.
571
+ * @param {string} [args.payload.ref] - Booking reference to confirm.
572
+ * @param {string} [args.payload.reference] - Booking reference to confirm.
573
+ * @returns {object} retVal - the return object.
574
+ * @returns {Object} retVal.confirmBookingReply - Raw integration reply.
575
+ * @returns {Booking} retVal.booking - A normalized Booking object.
576
+ */
577
+ confirmBooking() {}
578
+
538
579
  /**
539
580
  * @typedef {Object} Passenger
540
581
  * @property {string} firstName - Passenger first name
@@ -39,6 +39,8 @@ We are using a set of standardized methods to maintain compatibility; the follow
39
39
  * [searchAvailability]{@link Plugin#searchAvailability}
40
40
  * [searchQuote]{@link Plugin#searchQuote}
41
41
  * [createBooking]{@link Plugin#createBooking}
42
+ * [cancelBooking]{@link Plugin#cancelBooking}
43
+ * [confirmBooking]{@link Plugin#confirmBooking}
42
44
  * [searchProductsForItinerary]{@link Plugin#searchProductsForItinerary}
43
45
  * [searchAvailabilityForItinerary]{@link Plugin#searchAvailabilityForItinerary}
44
46
  * [addServiceToItinerary]{@link Plugin#addServiceToItinerary}