ti2 1.0.116 → 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 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,44 @@ 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
+ });
163
201
  });
164
202
  describe('doNotCallPluginForProducts flag', () => {
165
203
  const doNotCallHint = 'hint_for_doNotCallPluginForProducts';
@@ -260,6 +298,7 @@ describe('user: bookings controller - searchProducts', () => {
260
298
  it('call outside of TTR should serve stale data and queue background refresh with correct parameters', async () => {
261
299
  travelgatePlugin.searchProducts.mockClear(); // Clear before action
262
300
  addJob.mockClear(); // Clear addJob mock before this action that should trigger it
301
+ const emitSpy = jest.spyOn(travelgatePlugin.events, 'emit');
263
302
 
264
303
  const { products } = await doApiPost({
265
304
  url: `/products/${testAppName}/${testUserId}/${ttrTestHint}/search`, // appName is 'travelgate'
@@ -293,6 +332,15 @@ describe('user: bookings controller - searchProducts', () => {
293
332
  expect(jobData.postProcess.args.userId).toBe(testUserId);
294
333
  expect(jobData.postProcess.args.hint).toBe(ttrTestHint);
295
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();
296
344
  });
297
345
  });
298
346
  // The 'lock mechanism (job queuing on stale cache)' describe block has been moved to the top level.
@@ -430,13 +478,22 @@ describe('user: bookings controller - searchProducts', () => {
430
478
  expect(cached.products).toEqual(initialProductsInCache);
431
479
  expect(lastUpdated).toBeGreaterThan(1);
432
480
  expect(emitSpy).toHaveBeenCalledWith(
433
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
481
+ 'bookingsProductSearch:cache:decision',
434
482
  expect.objectContaining({
483
+ action: 'empty_refresh_skipped',
435
484
  reason: 'emptyResultPreservedExistingCache',
436
485
  cachePreserved: true,
437
486
  existingProductCount: initialProductsInCache.length,
438
487
  }),
439
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
+ );
440
497
  emitSpy.mockRestore();
441
498
  });
442
499
 
@@ -528,12 +585,21 @@ describe('user: bookings controller - searchProducts', () => {
528
585
  expect(cached.products).toEqual(initialProductsInCache);
529
586
  expect(lastUpdated).toBeGreaterThan(1);
530
587
  expect(emitSpy).toHaveBeenCalledWith(
531
- 'bookingsProductSearch:cache:partialRefreshSkipped',
588
+ 'bookingsProductSearch:cache:decision',
532
589
  expect.objectContaining({
590
+ action: 'partial_refresh_skipped',
533
591
  reason: 'partialResultPreservedExistingCache',
534
592
  cachePreserved: true,
535
593
  existingProductCount: initialProductsInCache.length,
536
- pluginResult,
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,
537
603
  }),
538
604
  );
539
605
  emitSpy.mockRestore();
@@ -575,13 +641,22 @@ describe('user: bookings controller - searchProducts', () => {
575
641
  value: { products: [] },
576
642
  }));
577
643
  expect(fakePlugin.events.emit).toHaveBeenCalledWith(
578
- 'bookingsProductSearch:cache:emptyRefreshSkipped',
644
+ 'bookingsProductSearch:cache:decision',
579
645
  expect.objectContaining({
646
+ action: 'empty_refresh_skipped',
580
647
  reason: 'emptyResultPreservedConcurrentCache',
581
648
  cachePreserved: true,
582
649
  existingProductCount: 1,
583
650
  }),
584
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
+ );
585
660
  });
586
661
  });
587
662
  });
@@ -777,6 +852,98 @@ describe('Bookings Product Search Lock Mechanism (Job Queuing on Stale Cache)',
777
852
  expect(addJob).not.toHaveBeenCalled();
778
853
  });
779
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
+
780
947
  it('multiple concurrent requests with no cache should share an empty plugin result', async () => {
781
948
  await clearProductSearchCache();
782
949
 
@@ -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,82 @@ 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], payload);
80
+ }
81
+ };
82
+
83
+ const createLockRenewal = ({ app, key, value, ttl, onDecision }) => {
84
+ if (!app.cache.expireIfValue || ttl <= 0) return { stop: async () => {} };
85
+
86
+ const renewEveryMs = Math.max(250, Math.floor(ttl * 500));
87
+ let stopped = false;
88
+ let inFlightRenewal = Promise.resolve();
89
+ const renew = async () => {
90
+ if (stopped) return;
91
+ const renewed = await app.cache.expireIfValue({ key, value, ttl });
92
+ if (!renewed) {
93
+ stopped = true;
94
+ onDecision('lock_renew_lost', { reason: 'ownerTokenMismatch' });
95
+ }
96
+ };
97
+ const interval = setInterval(() => {
98
+ inFlightRenewal = renew().catch(err => {
99
+ stopped = true;
100
+ onDecision('lock_renew_failed', { reason: err.message });
101
+ });
102
+ }, renewEveryMs);
103
+ if (interval.unref) interval.unref();
104
+
105
+ return {
106
+ stop: async () => {
107
+ stopped = true;
108
+ clearInterval(interval);
109
+ await inFlightRenewal;
110
+ },
111
+ };
112
+ };
113
+
43
114
  const createProductSearchUnavailableError = () => {
44
115
  const err = new Error('Product search cache refresh did not produce cached results');
45
116
  err.status = 503;
@@ -95,6 +166,7 @@ const bookingsCancel = plugins => async (req, res, next) => {
95
166
  } = req;
96
167
  try {
97
168
  const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
169
+ assert(app.cancelBooking, `cancelBooking is not available for ${appKey}`);
98
170
  const results = await app.cancelBooking({
99
171
  axios,
100
172
  token,
@@ -104,6 +176,46 @@ const bookingsCancel = plugins => async (req, res, next) => {
104
176
  hint,
105
177
  requestId: req.requestId,
106
178
  });
179
+ app.events.emit('bookingsCancelBooking', {
180
+ userId,
181
+ hint,
182
+ operationId: 'cancelBooking',
183
+ requestId: req.requestId,
184
+ pluginName: app.name,
185
+ payload: results,
186
+ });
187
+ return res.json(results);
188
+ } catch (err) {
189
+ return next(err);
190
+ }
191
+ };
192
+
193
+ const confirmBooking = plugins => async (req, res, next) => {
194
+ const {
195
+ axios,
196
+ params: { appKey, userId, hint },
197
+ body,
198
+ } = req;
199
+ try {
200
+ const { app, token } = await getAppAndToken({ plugins, appKey, userId, hint });
201
+ assert(app.confirmBooking, `confirmBooking is not available for ${appKey}`);
202
+ const results = await app.confirmBooking({
203
+ axios,
204
+ token,
205
+ payload: body,
206
+ typeDefsAndQueries,
207
+ userId,
208
+ hint,
209
+ requestId: req.requestId,
210
+ });
211
+ app.events.emit('bookingsConfirmBooking', {
212
+ userId,
213
+ hint,
214
+ operationId: 'confirmBooking',
215
+ requestId: req.requestId,
216
+ pluginName: app.name,
217
+ payload: results,
218
+ });
107
219
  return res.json(results);
108
220
  } catch (err) {
109
221
  return next(err);
@@ -183,7 +295,19 @@ const $bookingsProductSearch = plugins => async ({
183
295
  assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
184
296
  const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
185
297
 
186
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
298
+ const cacheKey = productSearchCacheKey({ userId, hint });
299
+ const telemetryContext = {
300
+ app,
301
+ cacheKey,
302
+ userId,
303
+ hint,
304
+ requestId,
305
+ searchInput,
306
+ optionId,
307
+ forceRefresh,
308
+ startedAt: Date.now(),
309
+ };
310
+ const emitDecision = (action, extra = {}) => emitProductSearchCacheDecision(telemetryContext, action, extra);
187
311
  const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
188
312
  const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
189
313
 
@@ -194,11 +318,17 @@ const $bookingsProductSearch = plugins => async ({
194
318
  const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
195
319
  const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
196
320
  const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
321
+ emitDecision(hasProductCache(initialActualCacheContent) ? 'cache_hit' : 'cache_miss', {
322
+ cacheProductCount: productCount(initialActualCacheContent),
323
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
324
+ ttrMs: ttr * 1000,
325
+ reason: hasPluginExecutionLock ? 'lockActive' : undefined,
326
+ });
197
327
  assert(app.cache.saveIfNotExists, 'cache adapter must expose saveIfNotExists');
198
328
 
199
329
  const getCachedProductSearchResults = async () => {
200
330
  const cacheContent = await app.cache.get({ key: cacheKey });
201
- if (cacheContent && cacheContent.products) return cacheContent;
331
+ if (hasProductCache(cacheContent)) return cacheContent;
202
332
  return null;
203
333
  };
204
334
 
@@ -217,23 +347,46 @@ const $bookingsProductSearch = plugins => async ({
217
347
  return getCachedProductSearchResults();
218
348
  };
219
349
 
220
- const acquirePluginExecutionLock = async () => app.cache.saveIfNotExists({
350
+ const acquirePluginExecutionLock = async lockOwnerToken => app.cache.saveIfNotExists({
221
351
  key: pluginExecutionLockKey,
222
- value: true,
223
- ttl: productSearchLockTtlSeconds,
352
+ value: lockOwnerToken,
353
+ ttl: getProductSearchLockTtlSeconds(),
224
354
  });
225
355
 
226
356
  // Helper function to call the plugin, save cache, and return results
227
- const fetchFromPluginAndCache = async () => {
228
- const lockAcquired = await acquirePluginExecutionLock();
357
+ const fetchFromPluginAndCache = async (reason = 'cache_miss') => {
358
+ const lockOwnerToken = crypto.randomBytes(16).toString('hex');
359
+ const lockStartedAt = Date.now();
360
+ const lockAcquired = await acquirePluginExecutionLock(lockOwnerToken);
229
361
  if (!lockAcquired) {
362
+ emitDecision('lock_wait', { reason, lockWaitMs: 0 });
230
363
  const cacheContent = await waitForProductSearchCache();
231
- if (cacheContent) return cacheContent;
364
+ const lockWaitMs = Date.now() - lockStartedAt;
365
+ if (cacheContent) {
366
+ emitDecision('cache_hit', {
367
+ reason: 'waitedForLeader',
368
+ lockWaitMs,
369
+ cacheProductCount: productCount(cacheContent),
370
+ });
371
+ return cacheContent;
372
+ }
373
+ emitDecision('lock_timeout', { reason, lockWaitMs });
232
374
  throw createProductSearchUnavailableError();
233
375
  }
234
376
 
377
+ const lockTtlSeconds = getProductSearchLockTtlSeconds();
378
+ emitDecision('lock_acquired', { reason, lockTtlSeconds });
379
+ const lockRenewal = createLockRenewal({
380
+ app,
381
+ key: pluginExecutionLockKey,
382
+ value: lockOwnerToken,
383
+ ttl: lockTtlSeconds,
384
+ onDecision: emitDecision,
385
+ });
386
+
235
387
  let pluginResults;
236
388
  try {
389
+ const pluginStartedAt = Date.now();
237
390
  pluginResults = await func({
238
391
  axios,
239
392
  token,
@@ -243,51 +396,72 @@ const $bookingsProductSearch = plugins => async ({
243
396
  userId,
244
397
  hint,
245
398
  });
399
+ const pluginElapsedMs = Date.now() - pluginStartedAt;
246
400
 
247
401
  // This function is responsible for caching if it fetched usable results.
248
402
  // This applies to forceRefresh, initial load, or direct calls that result in a fetch.
249
403
  // The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
250
404
  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,
405
+ await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: productSearchCacheTtlSeconds });
406
+ await app.cache.save({ key: cacheKey, value: pluginResults, ttl: productSearchCacheTtlSeconds });
407
+ emitDecision('cache_saved', {
408
+ reason,
409
+ pluginElapsedMs,
410
+ cacheProductCount: productCount(pluginResults),
256
411
  });
257
412
  } 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,
413
+ emitDecision('partial_refresh_skipped', {
414
+ reason: 'directPartialResultNotCached',
415
+ pluginElapsedMs,
416
+ cachePreserved: false,
417
+ returnedProductCount: productCount(pluginResults),
261
418
  });
262
419
  } else if (pluginResults && pluginResults.products && pluginResults.products.length === 0) {
263
420
  // Short-lived empty cache gives concurrent waiters the same answer as the lock holder.
264
421
  await app.cache.save({ key: cacheKey, value: pluginResults, ttl: emptyProductSearchCacheTtlSeconds });
422
+ emitDecision('empty_cache_saved', {
423
+ reason,
424
+ pluginElapsedMs,
425
+ cacheProductCount: 0,
426
+ });
265
427
  }
266
428
  } finally {
267
- await app.cache.drop({ key: pluginExecutionLockKey });
429
+ await lockRenewal.stop();
430
+ if (app.cache.dropIfValue) {
431
+ await app.cache.dropIfValue({ key: pluginExecutionLockKey, value: lockOwnerToken });
432
+ } else {
433
+ await app.cache.drop({ key: pluginExecutionLockKey });
434
+ }
268
435
  }
269
436
  return pluginResults || { products: [] }; // Ensure products array exists
270
437
  };
271
438
 
272
439
  // 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
273
440
  if (doNotCallPluginForProducts && !forceRefresh) {
274
- if (initialActualCacheContent && initialActualCacheContent.products) {
441
+ if (hasProductCache(initialActualCacheContent)) {
275
442
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
443
+ emitDecision('cache_hit', {
444
+ reason: 'doNotCallPluginForProducts',
445
+ cacheProductCount: initialActualCacheContent.products.length,
446
+ returnedProductCount: searchResults.length,
447
+ });
276
448
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
277
449
  }
450
+ emitDecision('cache_miss', { reason: 'doNotCallPluginForProducts' });
278
451
  return { products: [], ...(token.configuration || {}) };
279
452
  }
280
453
 
281
454
  // 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
282
455
  // fetchFromPluginAndCache will handle caching the new results.
283
456
  if (forceRefresh) {
284
- const funcResults = await fetchFromPluginAndCache();
457
+ emitDecision('force_refresh');
458
+ const funcResults = await fetchFromPluginAndCache('force_refresh');
285
459
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
286
460
  return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
287
461
  }
288
462
 
289
463
  // 3. Cache exists (initialActualCacheContent) and not forceRefresh:
290
- if (initialActualCacheContent && initialActualCacheContent.products) {
464
+ if (hasProductCache(initialActualCacheContent)) {
291
465
  const cacheIsEmpty = !initialActualCacheContent.products.length;
292
466
  const trimmedSearch = (searchInput || '').trim();
293
467
  const searchFilterIsEmpty = (!trimmedSearch || trimmedSearch === '*') && !(optionId && optionId.length);
@@ -295,8 +469,15 @@ const $bookingsProductSearch = plugins => async ({
295
469
  // If cache is empty and no search filter, skip to case 4 to fetch fresh data
296
470
  const shouldSkipEmptyCache = cacheIsEmpty && searchFilterIsEmpty;
297
471
  if (!shouldSkipEmptyCache) {
298
- const returnCachedResults = () => {
472
+ const returnCachedResults = (action = 'cache_hit', extra = {}) => {
299
473
  const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
474
+ emitDecision(action, {
475
+ cacheProductCount: initialActualCacheContent.products.length,
476
+ returnedProductCount: searchResults.length,
477
+ cacheAgeMs: lastUpdated ? Date.now() - lastUpdated : undefined,
478
+ ttrMs: ttr * 1000,
479
+ ...extra,
480
+ });
300
481
  return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
301
482
  };
302
483
 
@@ -304,7 +485,7 @@ const $bookingsProductSearch = plugins => async ({
304
485
 
305
486
  // Cache is fresh or plugin execution in progress: serve from cache
306
487
  if (!isEffectivelyStale || hasPluginExecutionLock) {
307
- return returnCachedResults();
488
+ return returnCachedResults('cache_hit', { reason: hasPluginExecutionLock ? 'lockActive' : 'freshCache' });
308
489
  }
309
490
 
310
491
  // Cache is stale - queue at most one background refresh job and serve stale data.
@@ -314,10 +495,10 @@ const $bookingsProductSearch = plugins => async ({
314
495
  ttl: 60,
315
496
  });
316
497
  if (!jobQueueLockAcquired) {
317
- return returnCachedResults();
498
+ return returnCachedResults('stale_served', { reason: 'backgroundRefreshDeduped' });
318
499
  }
319
500
 
320
- await addJob({
501
+ const jobId = await addJob({
321
502
  type: 'plugin',
322
503
  pluginName: appKey,
323
504
  method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
@@ -330,13 +511,16 @@ const $bookingsProductSearch = plugins => async ({
330
511
  },
331
512
  }, { removeOnComplete: true });
332
513
 
333
- return returnCachedResults();
514
+ return returnCachedResults('stale_served', {
515
+ reason: 'backgroundRefreshQueued',
516
+ jobId,
517
+ });
334
518
  }
335
519
  }
336
520
 
337
521
  // 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
338
522
  // Fetch from plugin. fetchFromPluginAndCache will handle caching.
339
- const funcResults = await fetchFromPluginAndCache();
523
+ const funcResults = await fetchFromPluginAndCache('cache_miss');
340
524
  const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
341
525
  return {
342
526
  ...funcResults,
@@ -625,22 +809,28 @@ const $updateProductSearchCache = plugins => async ({
625
809
  return; // Or throw error
626
810
  }
627
811
 
628
- const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
629
- const monthInSeconds = 30 * 24 * 60 * 60;
812
+ const cacheKey = productSearchCacheKey({ userId, hint });
630
813
  const markRefreshAttempted = () => app.cache.save({
631
814
  key: `${cacheKey}:lastUpdated`,
632
815
  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,
816
+ ttl: productSearchCacheTtlSeconds,
638
817
  });
818
+ const emitCacheEvent = (action, extra = {}) => emitProductSearchCacheDecision({
819
+ app,
820
+ cacheKey,
821
+ userId,
822
+ hint,
823
+ requestId,
824
+ searchInput: '',
825
+ optionId: '',
826
+ forceRefresh: false,
827
+ startedAt: Date.now(),
828
+ }, action, extra);
639
829
 
640
830
  if (hasCacheableProductResults(pluginResult)) {
641
831
  await markRefreshAttempted();
642
- await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
643
- emitCacheEvent('bookingsProductSearch:cache:save');
832
+ await app.cache.save({ key: cacheKey, value: pluginResult, ttl: productSearchCacheTtlSeconds });
833
+ emitCacheEvent('cache_saved', { cacheProductCount: productCount(pluginResult) });
644
834
  } else {
645
835
  const isPartialRefresh = pluginResult && (pluginResult.catalogPartial || pluginResult.partial);
646
836
  const existingCacheContent = await app.cache.get({ key: cacheKey });
@@ -651,9 +841,9 @@ const $updateProductSearchCache = plugins => async ({
651
841
  cachePreserved: true,
652
842
  existingProductCount: existingCacheContent.products.length,
653
843
  };
654
- if (isPartialRefresh) eventExtra.pluginResult = pluginResult;
844
+ if (isPartialRefresh) eventExtra.returnedProductCount = productCount(pluginResult);
655
845
  emitCacheEvent(
656
- isPartialRefresh ? 'bookingsProductSearch:cache:partialRefreshSkipped' : 'bookingsProductSearch:cache:emptyRefreshSkipped',
846
+ isPartialRefresh ? 'partial_refresh_skipped' : 'empty_refresh_skipped',
657
847
  eventExtra,
658
848
  );
659
849
  return;
@@ -661,10 +851,10 @@ const $updateProductSearchCache = plugins => async ({
661
851
 
662
852
  if (isPartialRefresh) {
663
853
  await markRefreshAttempted();
664
- emitCacheEvent('bookingsProductSearch:cache:partialRefreshSkipped', {
854
+ emitCacheEvent('partial_refresh_skipped', {
665
855
  reason: 'partialResultNotCached',
666
856
  cachePreserved: false,
667
- pluginResult,
857
+ returnedProductCount: productCount(pluginResult),
668
858
  });
669
859
  return;
670
860
  }
@@ -673,7 +863,7 @@ const $updateProductSearchCache = plugins => async ({
673
863
  const latestCacheContent = await app.cache.get({ key: cacheKey });
674
864
  if (hasNonEmptyProductCache(latestCacheContent)) {
675
865
  await markRefreshAttempted();
676
- emitCacheEvent('bookingsProductSearch:cache:emptyRefreshSkipped', {
866
+ emitCacheEvent('empty_refresh_skipped', {
677
867
  reason: 'emptyResultPreservedConcurrentCache',
678
868
  cachePreserved: true,
679
869
  existingProductCount: latestCacheContent.products.length,
@@ -683,8 +873,8 @@ const $updateProductSearchCache = plugins => async ({
683
873
 
684
874
  // Empty complete refreshes are authoritative only when there is no existing non-empty cache.
685
875
  await markRefreshAttempted();
686
- await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
687
- emitCacheEvent('bookingsProductSearch:cache:emptyRefresh');
876
+ await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: productSearchCacheTtlSeconds });
877
+ emitCacheEvent('empty_cache_saved', { cacheProductCount: 0 });
688
878
  }
689
879
  };
690
880
 
@@ -693,6 +883,7 @@ const controllerFactory = plugins => {
693
883
  const controllerFunctions = {
694
884
  bookingsSearch: bookingsSearch(plugins),
695
885
  bookingsCancel: bookingsCancel(plugins),
886
+ confirmBooking: confirmBooking(plugins),
696
887
  $bookingsProductSearch: $bookingsProductSearch(plugins),
697
888
  bookingsProductSearch: bookingsProductSearch(plugins),
698
889
  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.118",
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}
package/.windsurfrules DELETED
@@ -1,9 +0,0 @@
1
- This is a Node.js project using Sequelize for database operations and Express.js for the web server.
2
- The API is defined in api.yml using OpenAPI 3.0.2
3
- The database is defined in models/index.js
4
- The models are defined in models/
5
- to run tests you have to run them on the ti2 named docker container, and consider this codebase is part of node_modules/ti2 path for example :
6
- `` $ docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"``
7
- When writing / editing tests, make use of test/utils.js and review their implementation to understand how to mock the environment.
8
- Do not make any changes until you 95% confident of the changes you want to make, ask follow up questions until you have that confidence.
9
- when sending jobs to the background worker using the queue, asssume a worker is always runing to execute it but is on a different instance thread, so to review / wait for jobs we have to use the wokrer/queue.js listJobs fn to check for it
package/CLAUDE.md DELETED
@@ -1,166 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- Ti2 (Tourism Information Interchange) is an open-source integration framework for the tourism industry. It provides standardized functions for bookings, content, and rates management through a plugin-based architecture.
8
-
9
- ## Critical Development Rules
10
-
11
- 1. **Docker Container Execution**: ALL commands must be run inside the `ti2` Docker container
12
- - The codebase is located at `/ti2` path inside the container
13
- - This project is part of `node_modules/ti2` in the container environment
14
- - **NEVER run npm, node, or npx commands on the host machine**
15
- - Always use: `docker exec ti2 bash -c "cd /ti2 && <command>"`
16
- - Example for npm install: `docker exec ti2 bash -c "cd /ti2 && npm install <package>"`
17
-
18
- 2. **Testing Rules**:
19
- - Tests MUST be run inside the Docker container
20
- - Example: `docker exec ti2 bash -c "cd /ti2 && npx jest lib/__tests__/callback.js --forceExit"`
21
- - Use `test/utils.js` for mocking the environment
22
- - Review test utility implementations before writing/editing tests
23
-
24
- 3. **Background Jobs**:
25
- - Jobs sent to the queue run on a separate worker thread/instance
26
- - Use `worker/queue.js` `listJobs` function to check job status
27
- - Workers are always assumed to be running
28
-
29
- 4. **Code Confidence**: Do not make changes until 95% confident. Ask follow-up questions if uncertain.
30
-
31
- ## Public Repo Privacy
32
-
33
- - `ti2` is open source. Before opening or updating public PRs, review summaries, inline comments, release notes, or commit messages, scrub internal TourConnect service names and private implementation details.
34
- - Do not mention internal services such as `pyfilematch`, `filematch`, `tcoutlook`, private frontend names, private trace URLs, private customer identifiers, or internal deployment architecture in public GitHub text.
35
- - Use generic wording such as `upstream caller`, `internal catalog caller`, `private frontend`, `caller-side timeout`, or `internal workflow` when context is needed.
36
- - After editing public GitHub text, search the PR body, issue comments, review comments, and review summaries for internal service names before handoff.
37
-
38
- ## Common Development Commands
39
-
40
- ```bash
41
- # Run tests (inside Docker container)
42
- docker exec ti2 bash -c "cd /ti2 && npm test"
43
- docker exec ti2 bash -c "cd /ti2 && npx jest [test-file] --forceExit"
44
-
45
- # Run specific test file
46
- docker exec ti2 bash -c "cd /ti2 && npx jest controllers/__tests__/bookings.js --forceExit"
47
-
48
- # Database migrations
49
- docker exec ti2 bash -c "cd /ti2 && npm run sequelize db:migrate"
50
-
51
- # Generate documentation
52
- docker exec ti2 bash -c "cd /ti2 && npm run doc"
53
- ```
54
-
55
- ## Architecture
56
-
57
- ### Core Components
58
-
59
- 1. **Main Entry Point** (`index.js`):
60
- - Initializes Express server on port 10010 (default)
61
- - Sets up Swagger documentation at `/api-docs`
62
- - Handles plugin instantiation and middleware
63
- - Implements caching mechanism
64
- - Manages background job processing via `worker/queue.js`
65
-
66
- 2. **API Definition** (`api.yml`):
67
- - OpenAPI 3.0.0 specification
68
- - Defines all endpoints and schemas
69
- - JWT Bearer authentication
70
-
71
- 3. **Plugin System**:
72
- - Two types: Integration Plugins (connect systems) and App Plugins (value-added tools)
73
- - Plugins are instantiated with cache, axios, events, and configuration
74
- - Plugin schemas are merged with main API schema
75
- - Each plugin gets its own routes under `/apps/{pluginName}`
76
-
77
- 4. **Controllers** (`controllers/`):
78
- - `app.js`: Core application controller
79
- - `bookings.js`: Booking operations (searchProducts, createBooking, etc.)
80
- - `admin.js`: Administrative functions
81
- - `user.js`: User management
82
- - `ping.js`: Health check endpoints
83
- - `allotment.js`: Allotment management
84
-
85
- 5. **Database Models** (`models/`):
86
- - Sequelize ORM with MySQL
87
- - Key models: Integration, User, UserAppKey, CronJobs, ApiCronJobs
88
- - Migrations in `migrations/` directory
89
-
90
- 6. **Worker System** (`worker/`):
91
- - Bull queue for background job processing
92
- - Redis-backed job queue
93
- - Supports plugin jobs, API jobs, and callback jobs
94
- - Multi-worker support with throng
95
-
96
- 7. **Authentication** (`auth/authHandler.js`):
97
- - JWT-based authentication
98
- - Bearer token scheme
99
-
100
- ### Key Features
101
-
102
- - **Caching**: Redis-based caching with configurable TTL
103
- - **Event System**: EventEmitter2 for plugin communication
104
- - **Background Jobs**: Async job processing with Bull/Redis
105
- - **Cron Jobs**: Scheduled task execution
106
- - **Multi-tenancy**: Support for multiple integrations/plugins
107
-
108
- ### Plugin Development
109
-
110
- Plugins must implement standard methods:
111
- - `validateToken`: Token validation
112
- - `tokenTemplate`: Token configuration template
113
- - Booking methods: `searchProducts`, `searchAvailability`, `createBooking`, `cancelBooking`
114
- - Content methods: `getProducts`, `getProduct`, `createProduct`, `updateProduct`
115
-
116
- ### Testing Strategy
117
-
118
- - Jest testing framework
119
- - Test fixtures in `__fixtures__/`
120
- - Mock plugins in `__mocks__/`
121
- - Use `test/utils.js` for creating test environments
122
- - Tests organized by controller in `controllers/__tests__/`
123
-
124
- ## Database
125
-
126
- - Sequelize ORM with MySQL/MariaDB
127
- - Connection managed through `models/db.js`
128
- - Migrations handled via sequelize-cli
129
- - Models auto-loaded from `models/` directory
130
-
131
- ## Environment Variables
132
-
133
- Key variables:
134
- - `PORT`: Server port (default: 10010)
135
- - `REDIS_URL`: Redis connection (default: redis://redis:6379)
136
- - `jwtSecret`: JWT signing secret
137
- - `adminKey`: Admin authentication key
138
- - `WEB_CONCURRENCY`: Number of workers
139
- - `MAX_JOBS_PER_WORKER`: Job concurrency per worker
140
- - `SSL_INSECURE_ALLOWED_DOMAINS`: Pipe-separated list of domains where SSL certificate verification is skipped (e.g., `localhost|staging.example.com|192.168.1.100`)
141
-
142
- Plugin-specific variables follow pattern: `ti2_{pluginName}_{setting}`
143
-
144
- ### SSL Configuration
145
-
146
- The `SSL_INSECURE_ALLOWED_DOMAINS` environment variable allows you to specify domains where SSL certificate verification should be skipped. This is useful for:
147
- - Development environments with self-signed certificates
148
- - Testing against staging servers with invalid certificates
149
- - Local development with HTTPS
150
-
151
- Example usage:
152
- ```bash
153
- # Skip SSL verification for specific domains
154
- export SSL_INSECURE_ALLOWED_DOMAINS="localhost|staging.api.com|192.168.1.100"
155
-
156
- # Multiple domains separated by pipe
157
- export SSL_INSECURE_ALLOWED_DOMAINS="dev.example.com|test.example.com"
158
- ```
159
-
160
- **Important Implementation Notes**:
161
- - SSL configuration is ONLY applied to plugin axios instances (for external API calls) and worker axios instances
162
- - Internal communication (callbacks, per-request axios) do NOT use SSL configuration
163
- - SSL configuration uses a per-request interceptor approach for better isolation
164
- - The configuration checks domains dynamically on each request
165
-
166
- **Warning**: Only use this in development/testing environments. Never skip SSL verification in production for security reasons.