ti2 1.0.90 → 1.0.91
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/controllers/__tests__/bookings.js +120 -0
- package/controllers/bookings.js +54 -21
- package/package.json +1 -1
|
@@ -237,6 +237,126 @@ describe('user: bookings controller', () => {
|
|
|
237
237
|
expect(Array.isArray(products)).toBeTruthy();
|
|
238
238
|
expect(products.length).toBe(2);
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('cache TTR and lock mechanism', () => {
|
|
242
|
+
const shortTTRToken = {
|
|
243
|
+
endpoint: 'https://api.travelgatex.com',
|
|
244
|
+
apiKey: chance.guid(),
|
|
245
|
+
client: 'tourconnect',
|
|
246
|
+
ttlForProducts: 2, // 2 seconds TTR
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
beforeEach(async () => {
|
|
250
|
+
// Clear any existing integration
|
|
251
|
+
try {
|
|
252
|
+
await doApiDelete({
|
|
253
|
+
url: `/${appKey}/${userId}`,
|
|
254
|
+
token: adminKey,
|
|
255
|
+
payload: { tokenHint: 'ttr-test' },
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.debug(err);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create new integration with short TTR
|
|
262
|
+
await doApiPost({
|
|
263
|
+
url: `/${appKey}/${userId}`,
|
|
264
|
+
token: adminKey,
|
|
265
|
+
payload: {
|
|
266
|
+
token: shortTTRToken,
|
|
267
|
+
tokenHint: 'ttr-test',
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Clear mock calls
|
|
272
|
+
jest.clearAllMocks();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should use cache within TTR period', async () => {
|
|
276
|
+
// First call should hit the plugin
|
|
277
|
+
await doApiPost({
|
|
278
|
+
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
279
|
+
token: userToken,
|
|
280
|
+
payload: {},
|
|
281
|
+
});
|
|
282
|
+
expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
|
|
283
|
+
|
|
284
|
+
// Second immediate call should use cache
|
|
285
|
+
await doApiPost({
|
|
286
|
+
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
287
|
+
token: userToken,
|
|
288
|
+
payload: {},
|
|
289
|
+
});
|
|
290
|
+
expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1); // Still 1, used cache
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should refresh cache after TTR expires', async () => {
|
|
294
|
+
// Wait for TTR to expire (2 seconds + buffer) so the cache from previous test is stale
|
|
295
|
+
await new Promise(resolve => {
|
|
296
|
+
setTimeout(resolve, 2100);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// First call to populate cache
|
|
300
|
+
await doApiPost({
|
|
301
|
+
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
302
|
+
token: userToken,
|
|
303
|
+
payload: {},
|
|
304
|
+
});
|
|
305
|
+
expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
|
|
306
|
+
|
|
307
|
+
// Wait for TTR to expire (2 seconds + buffer)
|
|
308
|
+
await new Promise(resolve => {
|
|
309
|
+
setTimeout(resolve, 2100);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Call after TTR expired should hit the plugin again
|
|
313
|
+
await doApiPost({
|
|
314
|
+
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
315
|
+
token: userToken,
|
|
316
|
+
payload: {},
|
|
317
|
+
});
|
|
318
|
+
expect(plugins[0].searchProducts).toHaveBeenCalledTimes(2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should use lock mechanism to prevent concurrent calls', async () => {
|
|
322
|
+
// Wait for TTR to expire (2 seconds + buffer) so the cache from previous test is stale
|
|
323
|
+
await new Promise(resolve => {
|
|
324
|
+
setTimeout(resolve, 2100);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Make multiple requests with slight delays to simulate real-world concurrent requests
|
|
328
|
+
const makeRequest = () => doApiPost({
|
|
329
|
+
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
330
|
+
token: userToken,
|
|
331
|
+
payload: {},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Start first request
|
|
335
|
+
const request1 = makeRequest();
|
|
336
|
+
// Start second request after 10ms
|
|
337
|
+
await new Promise(resolve => {
|
|
338
|
+
setTimeout(resolve, 10);
|
|
339
|
+
});
|
|
340
|
+
const request2 = makeRequest();
|
|
341
|
+
// Start third request after another 10ms
|
|
342
|
+
await new Promise(resolve => {
|
|
343
|
+
setTimeout(resolve, 10);
|
|
344
|
+
});
|
|
345
|
+
const request3 = makeRequest();
|
|
346
|
+
|
|
347
|
+
// Wait for all requests to complete
|
|
348
|
+
const results = await Promise.all([request1, request2, request3]);
|
|
349
|
+
|
|
350
|
+
// All requests should return valid products
|
|
351
|
+
results.forEach(({ products }) => {
|
|
352
|
+
expect(Array.isArray(products)).toBeTruthy();
|
|
353
|
+
expect(products.length).toBe(2); // Based on existing test expectations
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Plugin should only be called once due to lock mechanism
|
|
357
|
+
expect(plugins[0].searchProducts).toHaveBeenCalledTimes(1);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
240
360
|
});
|
|
241
361
|
|
|
242
362
|
it('should be able to search for availabiility', async () => {
|
package/controllers/bookings.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const assert = require('assert');
|
|
2
2
|
const hash = require('object-hash');
|
|
3
|
-
const cache = require('../cache');
|
|
4
3
|
const R = require('ramda');
|
|
4
|
+
const cache = require('../cache');
|
|
5
5
|
const { UserAppKey } = require('../models/index');
|
|
6
6
|
const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
|
|
7
7
|
const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
|
|
@@ -136,7 +136,6 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
136
136
|
requestId,
|
|
137
137
|
}) => {
|
|
138
138
|
const app = plugins.find(({ name }) => name === appKey);
|
|
139
|
-
// const app = load(appKey);
|
|
140
139
|
assert(userId, 'userId is required');
|
|
141
140
|
assert(appKey, 'appKey is required');
|
|
142
141
|
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
@@ -157,20 +156,36 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
157
156
|
hint,
|
|
158
157
|
operationId: 'bookingsProductSearch',
|
|
159
158
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
pluginName: appKey,
|
|
164
|
-
key: cacheKey,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
const cacheValue = await cache.get({
|
|
159
|
+
// TODO: remove debugging console.logs after no related issues reported for a while
|
|
160
|
+
// Check cache first if not forcing refresh
|
|
161
|
+
const cacheValue = forceRefresh ? null : await cache.get({
|
|
168
162
|
pluginName: appKey,
|
|
169
163
|
key: cacheKey,
|
|
170
164
|
});
|
|
171
|
-
|
|
165
|
+
const lastUpdated = await cache.get({
|
|
166
|
+
pluginName: appKey,
|
|
167
|
+
key: `${cacheKey}:lastUpdated`,
|
|
168
|
+
});
|
|
169
|
+
// refresh every 24 hours
|
|
170
|
+
const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
|
|
171
|
+
let isStale = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
172
|
+
const doNotCallPluginForProducts = token.doNotCallPluginForProducts
|
|
173
|
+
|| R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
174
|
+
// if a company says never to call the plugin (their booking system)
|
|
175
|
+
// we will never treat their cache as stale
|
|
176
|
+
if (doNotCallPluginForProducts) {
|
|
177
|
+
isStale = false;
|
|
178
|
+
}
|
|
179
|
+
const hasLock = await cache.get({
|
|
180
|
+
pluginName: appKey,
|
|
181
|
+
key: `${cacheKey}:lock`,
|
|
182
|
+
});
|
|
183
|
+
console.log(`${appKey}/${userId}/${hint}: lastUpdated: ${lastUpdated}, ttr: ${ttr}, isStale: ${isStale}, hasLock: ${hasLock}, foundCache: ${!!cacheValue}`);
|
|
184
|
+
// when there is a lock (meaning another request is already fetching the products)
|
|
185
|
+
// return stale cache during this short window
|
|
186
|
+
if (cacheValue && cacheValue.products && (hasLock || !isStale)) {
|
|
172
187
|
const searchResults = $searchProductList(cacheValue.products, searchInput, optionId);
|
|
173
|
-
console.log(`${appKey}/${userId}/${hint}:
|
|
188
|
+
console.log(`${appKey}/${userId}/${hint}: returning cached products: ${searchResults.length}`);
|
|
174
189
|
return {
|
|
175
190
|
...cacheValue,
|
|
176
191
|
products: searchResults,
|
|
@@ -178,13 +193,18 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
178
193
|
...(token.configuration || {}),
|
|
179
194
|
};
|
|
180
195
|
}
|
|
181
|
-
const doNotCallPluginForProducts = token.doNotCallPluginForProducts
|
|
182
|
-
|| R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
183
196
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
184
|
-
console.log(`${appKey}/${userId}/${hint}:
|
|
197
|
+
console.log(`${appKey}/${userId}/${hint}:not calling the plugin because doNotCallPluginForProducts is true and forceRefresh is false`);
|
|
185
198
|
return { products: [] };
|
|
186
199
|
}
|
|
187
|
-
console.log(`${appKey}/${userId}/${hint}:
|
|
200
|
+
console.log(`${appKey}/${userId}/${hint}: (forceRefresh: ${forceRefresh}) so calling func to get fresh products`);
|
|
201
|
+
// create a lock with 2 minute TTL
|
|
202
|
+
await cache.save({
|
|
203
|
+
pluginName: appKey,
|
|
204
|
+
key: `${cacheKey}:lock`,
|
|
205
|
+
value: true,
|
|
206
|
+
ttl: 120,
|
|
207
|
+
});
|
|
188
208
|
const funcResults = await func({
|
|
189
209
|
axios,
|
|
190
210
|
token,
|
|
@@ -195,17 +215,30 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
195
215
|
});
|
|
196
216
|
// save cache if products are found
|
|
197
217
|
if (funcResults && funcResults.products && funcResults.products.length > 0) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
218
|
+
console.log(`${appKey}/${userId}/${hint}: saving cache of ${funcResults.products.length} products`);
|
|
219
|
+
// I initially wanted to let the cache live forever
|
|
220
|
+
// but just in case user left TC or something, we don't want to keep the cache forever
|
|
221
|
+
// so we set the TTL to 30 days, within 30 days,
|
|
222
|
+
// we will refresh the cache every 24 hours(or specified by the user) without deleting the stale cache
|
|
223
|
+
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
224
|
+
await cache.save({
|
|
225
|
+
pluginName: appKey,
|
|
226
|
+
key: `${cacheKey}:lastUpdated`,
|
|
227
|
+
value: Date.now(),
|
|
228
|
+
ttl: monthInSeconds,
|
|
229
|
+
});
|
|
201
230
|
await cache.save({
|
|
202
231
|
pluginName: appKey,
|
|
203
232
|
key: cacheKey,
|
|
204
233
|
value: funcResults,
|
|
205
|
-
ttl,
|
|
206
|
-
skipTTL: Boolean(doNotCallPluginForProducts),
|
|
234
|
+
ttl: monthInSeconds,
|
|
207
235
|
});
|
|
208
236
|
}
|
|
237
|
+
// release the lock
|
|
238
|
+
await cache.drop({
|
|
239
|
+
pluginName: appKey,
|
|
240
|
+
key: `${cacheKey}:lock`,
|
|
241
|
+
});
|
|
209
242
|
const searchResults = $searchProductList(R.pathOr([], ['products'], funcResults), searchInput, optionId);
|
|
210
243
|
return {
|
|
211
244
|
...funcResults,
|