ti2 1.0.85 → 1.0.87

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/cache.js CHANGED
@@ -18,6 +18,7 @@ const save = async ({
18
18
  }
19
19
  return keyParam;
20
20
  })()}`;
21
+ // console.log('saving key', key);
21
22
  await cache.set(key, JSON.stringify(value));
22
23
  if (!skipTTL) {
23
24
  await cache.expire(key, ttl);
@@ -77,7 +78,7 @@ const drop = async ({
77
78
  fn,
78
79
  fnParams,
79
80
  }) => {
80
- let key = `${pluginName}:${(() => {
81
+ const key = `${pluginName}:${(() => {
81
82
  if (!keyParam) {
82
83
  return hash({ fn, fnParams });
83
84
  }
@@ -86,14 +87,17 @@ const drop = async ({
86
87
  }
87
88
  return keyParam;
88
89
  })()}`;
89
- key = `${pluginName}:${key}`;
90
+ // console.log('dropping key', key);
90
91
  await cache.del(key);
91
92
  };
92
93
 
94
+ const keys = async () => cache.keys('*');
95
+
93
96
  module.exports = {
94
97
  cache,
95
98
  save,
96
99
  getOrExec,
97
100
  drop,
98
101
  get,
102
+ keys,
99
103
  };
@@ -1,11 +1,13 @@
1
- /* globals beforeAll describe it expect */
1
+ /* globals beforeAll describe it expect jest beforeEach */
2
2
 
3
3
  const chance = require('chance').Chance();
4
+ const hash = require('object-hash');
4
5
  const testUtils = require('../../test/utils');
6
+ const cache = require('../../cache');
5
7
 
6
8
  const { env: { adminKey } } = process;
7
9
 
8
- describe('user: booking search', () => {
10
+ describe('user: bookings controller', () => {
9
11
  const newApp = {
10
12
  name: 'travelgate',
11
13
  packageName: 'ti2-travelgate',
@@ -35,6 +37,16 @@ describe('user: booking search', () => {
35
37
  } = await testUtils({
36
38
  plugins: [newApp.name],
37
39
  }));
40
+
41
+ // drop all cache keys, clean slate
42
+ const cacheKeys = await cache.keys();
43
+ cacheKeys.forEach(key => cache.drop({
44
+ pluginName: key.split(':')[0],
45
+ key: key.split(':')[1],
46
+ }));
47
+ });
48
+ beforeEach(async () => {
49
+ jest.clearAllMocks();
38
50
  });
39
51
 
40
52
  let userToken;
@@ -77,7 +89,8 @@ describe('user: booking search', () => {
77
89
  url: `/user/${userId}/apps`,
78
90
  token: userToken,
79
91
  });
80
- if (!userAppKeys.map(e => e.integrationId).includes(newApp.name)) {
92
+ if (!userAppKeys.find(e => e.hint === newIntegration.tokenHint
93
+ && e.integrationId === newApp.name)) {
81
94
  // relation does not exits
82
95
  const { value } = await doApiPost({
83
96
  url: `/${appKey}/${userId}`,
@@ -92,7 +105,7 @@ describe('user: booking search', () => {
92
105
  bookingId: '', supplierBookingId: '', name: '',
93
106
  };
94
107
  const { bookings } = await doApiPost({
95
- url: `/bookings/${appKey}/${userId}/search`,
108
+ url: `/bookings/${appKey}/${userId}/testingToken/search`,
96
109
  token: userToken,
97
110
  payload,
98
111
  });
@@ -105,21 +118,127 @@ describe('user: booking search', () => {
105
118
  // expect(retVal.bookings.length > 0).toBeTruthy();
106
119
  });
107
120
 
108
- it('should be able to search a for a booking product', async () => {
109
- const payload = {};
110
- const { products } = await doApiPost({
111
- url: `/products/${appKey}/${userId}/search`,
112
- token: userToken,
113
- payload,
121
+ describe('searchProducts', () => {
122
+ it('should be able to get bookings products for most users without special setup: no cache', async () => {
123
+ const payload = {};
124
+ const { products } = await doApiPost({
125
+ url: `/products/${appKey}/${userId}/testingToken/search`,
126
+ token: userToken,
127
+ payload,
128
+ });
129
+ expect(plugins[0].searchProducts).toHaveBeenCalled();
130
+ expect(Array.isArray(products)).toBeTruthy();
131
+ expect(products.length).toBe(2);
132
+ expect(products[0].options.length).toBe(1);
133
+ expect(products[1].options.length).toBe(2);
134
+ });
135
+ it('should be able to get booking products: no cache, forceRefresh', async () => {
136
+ // NOTE: we need to remove the cache first
137
+ const cacheKey = hash({
138
+ appKey,
139
+ userId,
140
+ hint: 'testingToken',
141
+ operationId: 'bookingsProductSearch',
142
+ });
143
+ await cache.drop({
144
+ pluginName: appKey,
145
+ key: cacheKey,
146
+ });
147
+ const payload = {
148
+ forceRefresh: true,
149
+ };
150
+ const { products } = await doApiPost({
151
+ url: `/products/${appKey}/${userId}/testingToken/search`,
152
+ token: userToken,
153
+ payload,
154
+ });
155
+ expect(plugins[0].searchProducts).toHaveBeenCalled();
156
+ expect(Array.isArray(products)).toBeTruthy();
157
+ expect(products.length).toBe(2);
158
+ expect(products[0].options.length).toBe(1);
159
+ expect(products[1].options.length).toBe(2);
160
+ expect(plugins[0].searchProducts.mock.calls[0][0].token).toEqual(token);
161
+ });
162
+ it('should be able to get booking products: using cache', async () => {
163
+ const payload = {};
164
+ const { products } = await doApiPost({
165
+ url: `/products/${appKey}/${userId}/testingToken/search`,
166
+ token: userToken,
167
+ payload,
168
+ });
169
+ expect(plugins[0].searchProducts).not.toHaveBeenCalled();
170
+ expect(Array.isArray(products)).toBeTruthy();
171
+ expect(products.length).toBe(2);
172
+ expect(products[0].options.length).toBe(1);
173
+ expect(products[1].options.length).toBe(2);
174
+ });
175
+ it('should be able to get booking products with searchInput', async () => {
176
+ const payload = {
177
+ searchInput: 'Transfer from Sydney Harbor to Hilton Hotel',
178
+ };
179
+ const { products } = await doApiPost({
180
+ url: `/products/${appKey}/${userId}/testingToken/search`,
181
+ token: userToken,
182
+ payload,
183
+ });
184
+ expect(plugins[0].searchProducts).not.toHaveBeenCalled();
185
+ expect(Array.isArray(products)).toBeTruthy();
186
+ expect(products.length).toBe(1);
187
+ expect(products[0].productName).toBe('Davids');
188
+ expect(products[0].options.length).toBe(1);
189
+ expect(products[0].options[0].optionName).toBe('Transfer from Sydney Harbor Bridge to Hilton Hotel');
190
+ });
191
+ it('should be able to process doNotCallPluginForProducts in token', async () => {
192
+ const { userAppKeys } = await doApiGet({
193
+ url: `/user/${userId}/apps`,
194
+ token: userToken,
195
+ });
196
+ let newnewIntegration = userAppKeys.find(e => e.hint === 'hint_for_doNotCallPluginForProducts'
197
+ && e.integrationId === newApp.name);
198
+ if (!newnewIntegration) {
199
+ const newIntegrationContent = {
200
+ endpoint: 'https://api.travelgatex.com',
201
+ apiKey: chance.guid(),
202
+ client: 'tourconnect',
203
+ doNotCallPluginForProducts: true,
204
+ };
205
+ // create a new user token with the new content
206
+ newnewIntegration = await doApiPost({
207
+ url: `/travelgate/${userId}`,
208
+ token: adminKey,
209
+ payload: {
210
+ token: newIntegrationContent,
211
+ tokenHint: 'hint_for_doNotCallPluginForProducts',
212
+ },
213
+ });
214
+ }
215
+ expect(newnewIntegration).toBeTruthy();
216
+ // first time call, no cache expect no plugin call and empty products
217
+ let products;
218
+ await doApiPost({
219
+ url: `/products/${appKey}/${userId}/hint_for_doNotCallPluginForProducts/search`,
220
+ token: userToken,
221
+ payload: {},
222
+ }).then(({ products: p }) => {
223
+ products = p;
224
+ });
225
+ expect(plugins[0].searchProducts).not.toHaveBeenCalled();
226
+ expect(Array.isArray(products)).toBeTruthy();
227
+ expect(products.length).toBe(0);
228
+ // second time call, forceRefresh and still expect plugin call
229
+ await doApiPost({
230
+ url: `/products/${appKey}/${userId}/hint_for_doNotCallPluginForProducts/search`,
231
+ token: userToken,
232
+ payload: { forceRefresh: true },
233
+ }).then(({ products: p }) => {
234
+ products = p;
235
+ });
236
+ expect(plugins[0].searchProducts).toHaveBeenCalled();
237
+ expect(Array.isArray(products)).toBeTruthy();
238
+ expect(products.length).toBe(2);
114
239
  });
115
- expect(plugins[0].searchProducts).toHaveBeenCalled();
116
- expect(Array.isArray(products)).toBeTruthy();
117
- expect(plugins[0].searchProducts.mock.calls[0][0].payload).toEqual(payload);
118
- expect(plugins[0].searchProducts.mock.calls[0][0].token).toEqual(token);
119
-
120
- // expect(Array.isArray(retVal.bookings)).toBeTruthy();
121
- // expect(retVal.bookings.length > 0).toBeTruthy();
122
240
  });
241
+
123
242
  it('should be able to search for availabiility', async () => {
124
243
  const payload = {
125
244
  travelDateStart: '12/30/2025',
@@ -132,7 +251,7 @@ describe('user: booking search', () => {
132
251
  nationality: 'ES',
133
252
  };
134
253
  const { availability } = await doApiPost({
135
- url: `/bookings/${appKey}/${userId}/availability`,
254
+ url: `/bookings/${appKey}/${userId}/testingToken/availability`,
136
255
  token: userToken,
137
256
  payload,
138
257
  });
@@ -154,7 +273,7 @@ describe('user: booking search', () => {
154
273
  nationality: 'ES',
155
274
  };
156
275
  const { quote } = await doApiPost({
157
- url: `/bookings/${appKey}/${userId}/quote`,
276
+ url: `/bookings/${appKey}/${userId}/testingToken/quote`,
158
277
  token: userToken,
159
278
  payload,
160
279
  });
@@ -168,7 +287,7 @@ describe('user: booking search', () => {
168
287
  id: chance.guid(),
169
288
  };
170
289
  await doApiPost({
171
- url: `/bookings/${appKey}/${userId}/booking`,
290
+ url: `/bookings/${appKey}/${userId}/testingToken/booking`,
172
291
  token: userToken,
173
292
  payload,
174
293
  });
@@ -1,5 +1,7 @@
1
1
  const assert = require('assert');
2
-
2
+ const hash = require('object-hash');
3
+ const cache = require('../cache');
4
+ const R = require('ramda');
3
5
  const { UserAppKey } = require('../models/index');
4
6
  const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
5
7
  const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
@@ -89,12 +91,48 @@ const bookingsCancel = plugins => async (req, res, next) => {
89
91
  }
90
92
  };
91
93
 
94
+ const $searchProductList = (products, searchInput = '', optionId = '') => {
95
+ // NOTE: optionId could be a string or an array of strings
96
+ // NOTE: searchInput should not appear at the same time as optionId
97
+ if (!(searchInput && searchInput.trim()) && !(optionId && optionId.length)) {
98
+ return products;
99
+ }
100
+ const getFullSearchStr = (product, option) => `${
101
+ R.path(['productName'], product) || ''
102
+ } ${R.path(['optionName'], option) || ''
103
+ } ${R.path(['optionId'], option) || ''
104
+ } ${R.path(['supplierId'], product) || ''}`;
105
+ const inputValueLower = (searchInput || '').trim().toLowerCase();
106
+ const parts = inputValueLower.split(' ').filter(Boolean); // Filter out any empty strings just in case
107
+ const pwFilteredOptions = products.map(product => {
108
+ const filteredOptions = R.pathOr([], ['options'], product).filter(option => {
109
+ if (optionId && optionId.length) {
110
+ const optionIdArr = R.is(Array, optionId) ? optionId : [optionId];
111
+ return optionIdArr.includes(R.path(['optionId'], option));
112
+ }
113
+ const fullSearchStr = getFullSearchStr(product, option).toLowerCase();
114
+ return parts.every(part => fullSearchStr.includes(part));
115
+ });
116
+ return {
117
+ ...product,
118
+ options: filteredOptions,
119
+ };
120
+ });
121
+ const filteredProducts = pwFilteredOptions.filter(product => product.options.length > 0);
122
+ return filteredProducts;
123
+ };
124
+
92
125
  const $bookingsProductSearch = plugins => async ({
93
126
  axios,
94
127
  appKey,
95
128
  userId,
96
129
  hint,
97
- payload,
130
+ payload: {
131
+ searchInput,
132
+ optionId,
133
+ forceRefresh,
134
+ ...restPayload
135
+ },
98
136
  requestId,
99
137
  }) => {
100
138
  const app = plugins.find(({ name }) => name === appKey);
@@ -112,15 +150,68 @@ const $bookingsProductSearch = plugins => async ({
112
150
  assert(userAppKeys, 'could not find the app key');
113
151
  const token = await userAppKeys.token;
114
152
  const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
115
- const results = await func({
153
+ // NOTE: this is intend to cache the entire product list
154
+ const cacheKey = hash({
155
+ appKey,
156
+ userId,
157
+ hint,
158
+ operationId: 'bookingsProductSearch',
159
+ });
160
+ if (forceRefresh) {
161
+ // remove the cache
162
+ await cache.drop({
163
+ pluginName: appKey,
164
+ key: cacheKey,
165
+ });
166
+ }
167
+ const cacheValue = await cache.get({
168
+ pluginName: appKey,
169
+ key: cacheKey,
170
+ });
171
+ if (cacheValue && cacheValue.products) {
172
+ const searchResults = $searchProductList(cacheValue.products, searchInput, optionId);
173
+ console.log(`${appKey}/${userId}/${hint}: found cache and returning cached products: ${searchResults.length}`);
174
+ return {
175
+ ...cacheValue,
176
+ products: searchResults,
177
+ // this is for sending the product filters specifically being used by pyfilematch
178
+ ...(token.configuration || {}),
179
+ };
180
+ }
181
+ const doNotCallPluginForProducts = token.doNotCallPluginForProducts
182
+ || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
183
+ if (doNotCallPluginForProducts && !forceRefresh) {
184
+ console.log(`${appKey}/${userId}/${hint}: no cache found but not calling the plugin because doNotCallPluginForProducts is true and forceRefresh is false`);
185
+ return { products: [] };
186
+ }
187
+ console.log(`${appKey}/${userId}/${hint}: no cache found(forceRefresh: ${forceRefresh}) and calling func`);
188
+ const funcResults = await func({
116
189
  axios,
117
190
  token,
118
- payload,
191
+ payload: restPayload,
119
192
  typeDefsAndQueries,
120
193
  requestId,
121
194
  userId,
122
195
  });
123
- return results;
196
+ // save cache if products are found
197
+ if (funcResults && funcResults.products && funcResults.products.length > 0) {
198
+ // 25 hours, just to have some buffer
199
+ const ttl = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttl'], app) || 60 * 60 * 25;
200
+ console.log(`${appKey}/${userId}/${hint}: saving cache of ${funcResults.products.length} products for (ttl:${ttl})`);
201
+ await cache.save({
202
+ pluginName: appKey,
203
+ key: cacheKey,
204
+ value: funcResults,
205
+ ttl,
206
+ skipTTL: Boolean(doNotCallPluginForProducts),
207
+ });
208
+ }
209
+ const searchResults = $searchProductList(R.pathOr([], ['products'], funcResults), searchInput, optionId);
210
+ return {
211
+ ...funcResults,
212
+ products: searchResults,
213
+ ...(token.configuration || {}),
214
+ };
124
215
  };
125
216
 
126
217
  const bookingsProductSearch = plugins => async (req, res, next) => {
@@ -35,6 +35,7 @@ const typeDefs = `
35
35
  bookingStatus: String!
36
36
  ref: String!
37
37
  agentRef: String
38
+ currency: String!
38
39
  totalPrice: String!
39
40
  currency: String!
40
41
  travelDate: String!
@@ -50,6 +51,7 @@ const query = `{
50
51
  bookingStatus
51
52
  ref
52
53
  agentRef
54
+ currency
53
55
  totalPrice
54
56
  currency
55
57
  travelDate
package/index.js CHANGED
@@ -20,7 +20,7 @@ const cacheSettings = {
20
20
  'getAffiliateAgents',
21
21
  'getAffiliateDesks',
22
22
  'getPickupPoints',
23
- 'bookingsProductSearch',
23
+ // 'bookingsProductSearch',
24
24
  'getCreateBookingFields',
25
25
  ],
26
26
  };
@@ -263,10 +263,29 @@ module.exports = async ({
263
263
  const currentPlugin = req.pathParams
264
264
  ? plugins.find(p => p.name === req.pathParams.appKey)
265
265
  : null;
266
+ /*
267
+ currentPlugin = {
268
+ cacheSettings: {
269
+ getAffiliateDesks: {
270
+ ttl: 60 * 60 * 24, // one day
271
+ },
272
+ getPickupPoints: {
273
+ ttl: 60 * 60 * 24, // one day
274
+ // we will do middleware caching for getPickupPoints
275
+ cacheInMiddleware: true,
276
+ },
277
+ },
278
+ }
279
+ */
266
280
  const pluginCacheSettings = R.pathOr({}, ['cacheSettings'], currentPlugin);
281
+ // Filter plugin cache settings to only include those with cacheInMiddleware: true
282
+ const filteredPluginCacheSettings = R.pickBy(
283
+ value => R.pathOr(false, ['cacheInMiddleware'], value),
284
+ pluginCacheSettings,
285
+ );
267
286
  const cachingOperations = [
268
287
  ...cacheSettings['*'],
269
- ...(currentPlugin ? R.keys(pluginCacheSettings) : []),
288
+ ...(currentPlugin ? R.keys(filteredPluginCacheSettings) : []),
270
289
  ];
271
290
  const body = req.customBody;
272
291
  if (cachingOperations.indexOf(body.operationId) > -1) {
@@ -288,7 +307,7 @@ module.exports = async ({
288
307
  pluginName: req.pathParams.appKey,
289
308
  key: cacheKey,
290
309
  value: newData,
291
- skipTTL: R.path([body.operationId, 'skipTTL'], pluginCacheSettings),
310
+ skipTTL: R.path([body.operationId, 'skipTTL'], filteredPluginCacheSettings),
292
311
  ttl: 60 * 60 * 24, // one day
293
312
  });
294
313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.85",
3
+ "version": "1.0.87",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/test/plugin.js CHANGED
@@ -35,7 +35,28 @@ class Plugin {
35
35
  this.createLocation = jestPlugin.fn(() => ({ locationId: chance.guid() }));
36
36
  this.updateLocation = jestPlugin.fn(() => true);
37
37
  this.searchBooking = jestPlugin.fn(() => ({ bookings: [] }));
38
- this.searchProducts = jestPlugin.fn(() => ({ bookings: [], products: [] }));
38
+ this.searchProducts = jestPlugin.fn(() => {
39
+ return {
40
+ products: [{
41
+ productId: '123',
42
+ productName: 'Bonza Bike Tours',
43
+ options: [{
44
+ optionId: '123',
45
+ optionName: 'Sydney Harbour Bridge Climb',
46
+ }],
47
+ }, {
48
+ productId: '234',
49
+ productName: 'Davids',
50
+ options: [{
51
+ optionId: '2346',
52
+ optionName: 'Transfer from Sydney Airport to Hilton Hotel',
53
+ }, {
54
+ optionId: '2345',
55
+ optionName: 'Transfer from Sydney Harbor Bridge to Hilton Hotel',
56
+ }],
57
+ }],
58
+ };
59
+ });
39
60
  this.searchAvailability = jestPlugin.fn(({
40
61
  token,
41
62
  payload: {