ti2 1.0.95 → 1.0.96
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/README.md +70 -1
- package/controllers/__tests__/bookings-searchProducts.js +533 -0
- package/controllers/__tests__/bookings.js +28 -276
- package/controllers/app.js +11 -8
- package/controllers/bookings.js +188 -108
- package/package.json +1 -1
- package/test/utils.js +36 -35
- package/worker/__tests__/job-plugin.js +207 -0
- package/worker/index.js +193 -121
- package/worker/queue.js +1 -0
package/controllers/bookings.js
CHANGED
|
@@ -2,6 +2,7 @@ const assert = require('assert');
|
|
|
2
2
|
const hash = require('object-hash');
|
|
3
3
|
const R = require('ramda');
|
|
4
4
|
const { UserAppKey } = require('../models/index');
|
|
5
|
+
const { addJob } = require('../worker/queue');
|
|
5
6
|
const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
|
|
6
7
|
const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
|
|
7
8
|
const { typeDefs: bookingTypeDefs, query: bookingQuery } = require('./graphql-schemas/booking');
|
|
@@ -126,113 +127,140 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
126
127
|
appKey,
|
|
127
128
|
userId,
|
|
128
129
|
hint,
|
|
129
|
-
payload:
|
|
130
|
-
searchInput,
|
|
131
|
-
optionId,
|
|
132
|
-
forceRefresh,
|
|
133
|
-
...restPayload
|
|
134
|
-
},
|
|
130
|
+
payload: originalRequestBody, // Renamed for clarity, this is req.body
|
|
135
131
|
requestId,
|
|
132
|
+
headers, // To pass original request headers for background job
|
|
136
133
|
}) => {
|
|
134
|
+
// Extract controller-specific flags from originalRequestBody
|
|
135
|
+
const {
|
|
136
|
+
searchInput = '', // Provide defaults
|
|
137
|
+
optionId = '',
|
|
138
|
+
forceRefresh = false,
|
|
139
|
+
} = originalRequestBody;
|
|
140
|
+
|
|
141
|
+
// Payload for the plugin function (func) - omit controller-specific flags
|
|
142
|
+
const payloadForPlugin = R.omit(['forceRefresh'], originalRequestBody);
|
|
143
|
+
|
|
137
144
|
const app = plugins.find(({ name }) => name === appKey);
|
|
138
145
|
assert(userId, 'userId is required');
|
|
139
146
|
assert(appKey, 'appKey is required');
|
|
140
147
|
assert(app.searchProducts || app.searchProductsForItinerary, `searchProducts or searchProductsForItinerary is not available for ${appKey}`);
|
|
141
148
|
const userAppKeys = (await UserAppKey.findOne({
|
|
142
|
-
where: {
|
|
143
|
-
userId,
|
|
144
|
-
integrationId: appKey,
|
|
145
|
-
...(hint ? { hint } : {}),
|
|
146
|
-
},
|
|
149
|
+
where: { userId, integrationId: appKey, ...(hint ? { hint } : {}) },
|
|
147
150
|
}));
|
|
148
151
|
assert(userAppKeys, 'could not find the app key');
|
|
149
152
|
const token = await userAppKeys.token;
|
|
150
153
|
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
151
|
-
|
|
152
|
-
const cacheKey = hash({
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
key: cacheKey,
|
|
160
|
-
});
|
|
161
|
-
const lastUpdated = await app.cache.get({
|
|
162
|
-
key: `${cacheKey}:lastUpdated`,
|
|
163
|
-
});
|
|
164
|
-
// refresh every 24 hours
|
|
154
|
+
|
|
155
|
+
const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
|
|
156
|
+
const pluginExecutionLockKey = `${cacheKey}:lock`; // Lock for direct plugin execution
|
|
157
|
+
const jobQueueLockKey = `${cacheKey}:jobLock`; // Lock for preventing multiple job queues
|
|
158
|
+
|
|
159
|
+
// Fetch actualCacheContent once at the beginning.
|
|
160
|
+
const initialActualCacheContent = await app.cache.get({ key: cacheKey });
|
|
161
|
+
const lastUpdated = await app.cache.get({ key: `${cacheKey}:lastUpdated` });
|
|
165
162
|
const ttr = token.ttlForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'ttr'], app) || 60 * 60 * 24;
|
|
166
|
-
|
|
167
|
-
const doNotCallPluginForProducts = token.doNotCallPluginForProducts
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
163
|
+
const isStaleByTTR = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
|
|
164
|
+
const doNotCallPluginForProducts = token.doNotCallPluginForProducts || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
|
|
165
|
+
const hasPluginExecutionLock = await app.cache.get({ key: pluginExecutionLockKey });
|
|
166
|
+
|
|
167
|
+
// Helper function to call the plugin, save cache, and return results
|
|
168
|
+
const fetchFromPluginAndCache = async () => {
|
|
169
|
+
await app.cache.save({ key: pluginExecutionLockKey, value: true, ttl: 120 });
|
|
170
|
+
let pluginResults;
|
|
171
|
+
try {
|
|
172
|
+
pluginResults = await func({
|
|
173
|
+
axios, token, payload: payloadForPlugin, typeDefsAndQueries, requestId, userId,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// This function is responsible for caching if it fetched good results.
|
|
177
|
+
// This applies to forceRefresh, initial load, or direct calls that result in a fetch.
|
|
178
|
+
// The $updateProductSearchCache function handles caching for background jobs queued due to stale data.
|
|
179
|
+
if (pluginResults && pluginResults.products && pluginResults.products.length > 0) {
|
|
180
|
+
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
181
|
+
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
|
|
182
|
+
await app.cache.save({ key: cacheKey, value: pluginResults, ttl: monthInSeconds });
|
|
183
|
+
app.events.emit('bookingsProductSearch:cache:save', {
|
|
184
|
+
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
// If pluginResults are empty, cache is NOT updated here by fetchFromPluginAndCache.
|
|
188
|
+
// This maintains existing behavior for forceRefresh/initial load paths.
|
|
189
|
+
// $updateProductSearchCache has its own logic for handling empty results from background refresh.
|
|
190
|
+
} finally {
|
|
191
|
+
await app.cache.drop({ key: pluginExecutionLockKey });
|
|
192
|
+
}
|
|
193
|
+
return pluginResults || { products: [] }; // Ensure products array exists
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// 1. `doNotCallPluginForProducts` is true, and not `forceRefresh`: Serve from cache or empty.
|
|
188
197
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
189
|
-
|
|
198
|
+
if (initialActualCacheContent && initialActualCacheContent.products) {
|
|
199
|
+
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
200
|
+
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
201
|
+
}
|
|
202
|
+
return { products: [], ...(token.configuration || {}) };
|
|
190
203
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
axios,
|
|
199
|
-
token,
|
|
200
|
-
payload: restPayload,
|
|
201
|
-
typeDefsAndQueries,
|
|
202
|
-
requestId,
|
|
203
|
-
userId,
|
|
204
|
-
});
|
|
205
|
-
// save cache if products are found
|
|
206
|
-
if (funcResults && funcResults.products && funcResults.products.length > 0) {
|
|
207
|
-
// I initially wanted to let the cache live forever
|
|
208
|
-
// but just in case user left TC or something, we don't want to keep the cache forever
|
|
209
|
-
// so we set the TTL to 30 days, within 30 days,
|
|
210
|
-
// we will refresh the cache every 24 hours(or specified by the user) without deleting the stale cache
|
|
211
|
-
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
212
|
-
await app.cache.save({
|
|
213
|
-
key: `${cacheKey}:lastUpdated`,
|
|
214
|
-
value: Date.now(),
|
|
215
|
-
ttl: monthInSeconds,
|
|
216
|
-
});
|
|
217
|
-
await app.cache.save({
|
|
218
|
-
key: cacheKey,
|
|
219
|
-
value: funcResults,
|
|
220
|
-
ttl: monthInSeconds,
|
|
221
|
-
});
|
|
222
|
-
app.events.emit('bookingsProductSearch:cache:save', {
|
|
223
|
-
cacheKey,
|
|
224
|
-
userId,
|
|
225
|
-
hint,
|
|
226
|
-
operationId: 'bookingsProductSearch',
|
|
227
|
-
requestId,
|
|
228
|
-
pluginName: app.name,
|
|
229
|
-
});
|
|
204
|
+
|
|
205
|
+
// 2. `forceRefresh` is true (and not case 1): Fetch from plugin.
|
|
206
|
+
// fetchFromPluginAndCache will handle caching the new results.
|
|
207
|
+
if (forceRefresh) {
|
|
208
|
+
const funcResults = await fetchFromPluginAndCache();
|
|
209
|
+
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
210
|
+
return { ...funcResults, products: searchResults, ...(token.configuration || {}) };
|
|
230
211
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
212
|
+
|
|
213
|
+
// 3. Cache exists (initialActualCacheContent) and not forceRefresh:
|
|
214
|
+
if (initialActualCacheContent && initialActualCacheContent.products) {
|
|
215
|
+
// Effective staleness check for deciding if a background refresh is needed.
|
|
216
|
+
const isEffectivelyStale = isStaleByTTR && !doNotCallPluginForProducts;
|
|
217
|
+
|
|
218
|
+
if (!isEffectivelyStale || hasPluginExecutionLock) {
|
|
219
|
+
// Cache is fresh OR (is stale BUT a plugin execution is already in progress for refresh): Serve from cache.
|
|
220
|
+
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
221
|
+
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
222
|
+
} else {
|
|
223
|
+
// Cache is effectively stale AND no plugin execution is currently in progress.
|
|
224
|
+
// Try to queue a background job.
|
|
225
|
+
const hasJobQueueLock = await app.cache.get({ key: jobQueueLockKey });
|
|
226
|
+
|
|
227
|
+
if (hasJobQueueLock) {
|
|
228
|
+
// A background job has recently been queued by another request. Serve stale data.
|
|
229
|
+
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
230
|
+
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
231
|
+
} else {
|
|
232
|
+
// No job queue lock. Set one, then queue the job, then serve stale data.
|
|
233
|
+
await app.cache.save({ key: jobQueueLockKey, value: true, ttl: 60 }); // Lock for 60 seconds
|
|
234
|
+
|
|
235
|
+
const searchResults = $searchProductList(initialActualCacheContent.products, searchInput, optionId);
|
|
236
|
+
|
|
237
|
+
const pluginMethodPayload = {
|
|
238
|
+
payload: payloadForPlugin,
|
|
239
|
+
userId,
|
|
240
|
+
};
|
|
241
|
+
const jobData = {
|
|
242
|
+
type: 'plugin',
|
|
243
|
+
pluginName: appKey,
|
|
244
|
+
method: app.searchProducts ? 'searchProducts' : 'searchProductsForItinerary',
|
|
245
|
+
token,
|
|
246
|
+
payload: pluginMethodPayload,
|
|
247
|
+
postProcess: {
|
|
248
|
+
controller: 'bookings',
|
|
249
|
+
action: '$updateProductSearchCache',
|
|
250
|
+
args: { appKey, userId, hint },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
await addJob(jobData, { removeOnComplete: true });
|
|
254
|
+
|
|
255
|
+
return { ...initialActualCacheContent, products: searchResults, ...(token.configuration || {}) };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 4. No cache content (and not caught by previous conditions like forceRefresh or doNotCallPluginForProducts):
|
|
261
|
+
// Fetch from plugin. fetchFromPluginAndCache will handle caching.
|
|
262
|
+
const funcResults = await fetchFromPluginAndCache();
|
|
263
|
+
const searchResults = $searchProductList(funcResults.products, searchInput, optionId);
|
|
236
264
|
return {
|
|
237
265
|
...funcResults,
|
|
238
266
|
products: searchResults,
|
|
@@ -253,6 +281,7 @@ const bookingsProductSearch = plugins => async (req, res, next) => {
|
|
|
253
281
|
...params,
|
|
254
282
|
payload,
|
|
255
283
|
requestId,
|
|
284
|
+
headers: req.headers, // Pass original request headers
|
|
256
285
|
}));
|
|
257
286
|
} catch (err) {
|
|
258
287
|
return next(err);
|
|
@@ -561,19 +590,70 @@ const getCreateBookingFields = plugins => async (req, res, next) => {
|
|
|
561
590
|
}
|
|
562
591
|
};
|
|
563
592
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
593
|
+
const $updateProductSearchCache = plugins => async ({
|
|
594
|
+
appKey,
|
|
595
|
+
userId,
|
|
596
|
+
hint,
|
|
597
|
+
pluginResult, // Result from the plugin method call
|
|
598
|
+
requestId,
|
|
599
|
+
// 'plugins' is available via the factory closure
|
|
600
|
+
}) => {
|
|
601
|
+
const app = plugins.find(({ name }) => name === appKey);
|
|
602
|
+
if (!app) {
|
|
603
|
+
console.error(`[$updateProductSearchCache][requestId: ${requestId}] Plugin ${appKey} not found.`);
|
|
604
|
+
return; // Or throw error
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const cacheKey = hash({ userId, hint, operationId: 'bookingsProductSearch' });
|
|
608
|
+
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
609
|
+
|
|
610
|
+
// Always update lastUpdated to prevent immediate re-trigger of background jobs
|
|
611
|
+
// even if the plugin returned empty results.
|
|
612
|
+
await app.cache.save({ key: `${cacheKey}:lastUpdated`, value: Date.now(), ttl: monthInSeconds });
|
|
613
|
+
|
|
614
|
+
if (pluginResult && pluginResult.products && pluginResult.products.length > 0) {
|
|
615
|
+
await app.cache.save({ key: cacheKey, value: pluginResult, ttl: monthInSeconds });
|
|
616
|
+
app.events.emit('bookingsProductSearch:cache:save', {
|
|
617
|
+
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
618
|
+
});
|
|
619
|
+
console.log(`[$updateProductSearchCache][requestId: ${requestId}] Saved products to cache for ${appKey}, user ${userId}, hint ${hint}.`);
|
|
620
|
+
} else {
|
|
621
|
+
// If plugin returned no products, update the cache to reflect this.
|
|
622
|
+
// This prevents serving stale data indefinitely if the source truly has no products anymore.
|
|
623
|
+
await app.cache.save({ key: cacheKey, value: { products: [] }, ttl: monthInSeconds });
|
|
624
|
+
app.events.emit('bookingsProductSearch:cache:emptyRefresh', {
|
|
625
|
+
cacheKey, userId, hint, operationId: 'bookingsProductSearch', requestId, pluginName: app.name,
|
|
626
|
+
pluginResult, // Log what the plugin returned
|
|
627
|
+
});
|
|
628
|
+
console.log(`[$updateProductSearchCache][requestId: ${requestId}] Plugin returned empty/no products. Cached empty for ${appKey}, user ${userId}, hint ${hint}.`);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// Update the module.exports to correctly assign the plugin-wrapped function
|
|
633
|
+
const controllerFactory = plugins => {
|
|
634
|
+
const controllerFunctions = {
|
|
635
|
+
bookingsSearch: bookingsSearch(plugins),
|
|
636
|
+
bookingsCancel: bookingsCancel(plugins),
|
|
637
|
+
$bookingsProductSearch: $bookingsProductSearch(plugins),
|
|
638
|
+
bookingsProductSearch: bookingsProductSearch(plugins),
|
|
639
|
+
getProductPackages: getProductPackages(plugins),
|
|
640
|
+
bookingsAvailabilitySearch: bookingsAvailabilitySearch(plugins),
|
|
641
|
+
$bookingsAvailabilityCalendar: $bookingsAvailabilityCalendar(plugins),
|
|
642
|
+
bookingsAvailabilityCalendar: bookingsAvailabilityCalendar(plugins),
|
|
643
|
+
searchQuote: searchQuote(plugins),
|
|
644
|
+
createBooking: createBooking(plugins),
|
|
645
|
+
getAffiliateAgents: getAffiliateAgents(plugins),
|
|
646
|
+
getAffiliateDesks: getAffiliateDesks(plugins),
|
|
647
|
+
getPickupPoints: getPickupPoints(plugins),
|
|
648
|
+
getCreateBookingFields: getCreateBookingFields(plugins),
|
|
649
|
+
$updateProductSearchCache: $updateProductSearchCache(plugins), // Add the new function here
|
|
650
|
+
};
|
|
651
|
+
// Ensure $bookingsProductSearch can be called internally by worker with plugins already bound
|
|
652
|
+
// This is more of a conceptual note as the factory pattern already handles this.
|
|
653
|
+
return controllerFunctions;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// Add this line to attach typeDefsAndQueries to the factory:
|
|
657
|
+
controllerFactory.typeDefsAndQueries = typeDefsAndQueries;
|
|
658
|
+
|
|
659
|
+
module.exports = controllerFactory;
|
package/package.json
CHANGED
package/test/utils.js
CHANGED
|
@@ -71,51 +71,51 @@ module.exports = async (appParams = {}) => {
|
|
|
71
71
|
const doApiPut = params => doApi({ ...params, verb: 'put' });
|
|
72
72
|
const doApiDelete = params => doApi({ ...params, verb: 'delete' });
|
|
73
73
|
|
|
74
|
-
const appSetup = async ({ userId: userIdParam, appName: appNameParam } = {}) => {
|
|
74
|
+
const appSetup = async ({ userId: userIdParam, appName: appNameParam, token: tokenDataParam, tokenHint: hintParam } = {}) => {
|
|
75
75
|
const userId = userIdParam || chance.guid();
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
adminEmail: chance.email(),
|
|
76
|
+
const appName = appNameParam || slugify(chance.company()).toLowerCase();
|
|
77
|
+
|
|
78
|
+
const integrationDetails = { // Details for the Integration (app) to be created/ensured
|
|
79
|
+
name: appName,
|
|
80
|
+
packageName: `ti2-${appName}`, // Default package name
|
|
81
|
+
adminEmail: chance.email(), // Default admin email
|
|
83
82
|
};
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
|
|
84
|
+
// Ensure the Integration (app) record exists.
|
|
85
|
+
// POST /app is an admin endpoint to create an Integration.
|
|
86
|
+
// This call ensures the 'Integrations' table has the required 'appName'.
|
|
87
|
+
// It's assumed this endpoint can be called safely even if the integration already exists.
|
|
88
|
+
await request(app)
|
|
89
|
+
.post('/app')
|
|
90
|
+
.set('Authorization', `Bearer ${adminKey}`) // Admin key to authorize Integration creation
|
|
91
|
+
.send(integrationDetails)
|
|
92
|
+
.expect(200); // Or handle potential non-200 responses if it errors on duplicate
|
|
93
|
+
|
|
94
|
+
// Prepare the token content for the UserAppKey.
|
|
95
|
+
// If tokenDataParam is provided by the test, use it; otherwise, generate default content.
|
|
96
|
+
const userAppKeyTokenContent = tokenDataParam || {
|
|
86
97
|
endpoint: chance.url(),
|
|
87
|
-
apiKey:
|
|
98
|
+
apiKey: chance.guid(),
|
|
88
99
|
};
|
|
100
|
+
|
|
101
|
+
// Determine the hint for the UserAppKey.
|
|
102
|
+
// If hintParam is provided, use it; otherwise, derive from apiKey or generate a random word.
|
|
103
|
+
const hint = hintParam || (userAppKeyTokenContent.apiKey ? userAppKeyTokenContent.apiKey.split('-')[0] : chance.word({ length: 8 }));
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Only create a new app if appNameParam is not provided
|
|
93
|
-
if (!appNameParam) {
|
|
94
|
-
const { body: { value: newAppKey } } = await request(app)
|
|
95
|
-
.post('/app')
|
|
96
|
-
.set('Authorization', `Bearer ${adminKey}`)
|
|
97
|
-
.send(newApp);
|
|
98
|
-
appKey = newAppKey;
|
|
99
|
-
} else {
|
|
100
|
-
// If appNameParam is provided, we'll use the adminKey directly
|
|
101
|
-
// This avoids the need for a GET /app/{appName} endpoint
|
|
102
|
-
appKey = adminKey;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Create user token for the app
|
|
106
|
-
const hint = token.apiKey.split('-')[0];
|
|
105
|
+
// Create the UserAppKey, linking the user to the integration with specific token data and hint.
|
|
106
|
+
// This call depends on the Integration (appName) existing.
|
|
107
107
|
await request(app)
|
|
108
|
-
.post(`/${
|
|
109
|
-
.set('Authorization', `Bearer ${
|
|
108
|
+
.post(`/${appName}/${userId}`) // URL uses the Integration's name (appName)
|
|
109
|
+
.set('Authorization', `Bearer ${adminKey}`) // Admin key to authorize UserAppKey creation
|
|
110
110
|
.send({
|
|
111
111
|
tokenHint: hint,
|
|
112
|
-
token,
|
|
113
|
-
})
|
|
112
|
+
token: userAppKeyTokenContent, // This is the data to be stored in UserAppKey.appKey (will be encrypted)
|
|
113
|
+
})
|
|
114
|
+
.expect(200); // Expect success for UserAppKey creation
|
|
114
115
|
|
|
115
116
|
return {
|
|
116
|
-
newApp,
|
|
117
|
-
token,
|
|
118
|
-
appKey,
|
|
117
|
+
newApp: integrationDetails, // Information about the integration that was ensured/created
|
|
118
|
+
token: userAppKeyTokenContent, // The plaintext content that was intended for UserAppKey.appKey
|
|
119
119
|
userId,
|
|
120
120
|
hint,
|
|
121
121
|
};
|
|
@@ -131,5 +131,6 @@ module.exports = async (appParams = {}) => {
|
|
|
131
131
|
createUserToken,
|
|
132
132
|
slugify,
|
|
133
133
|
plugins: app.plugins,
|
|
134
|
+
sqldb, // Expose sqldb for direct DB interaction if needed in tests (e.g., cleanup)
|
|
134
135
|
};
|
|
135
136
|
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Mock throng to directly execute the worker function
|
|
2
|
+
jest.mock('throng', () => jest.fn(config => {
|
|
3
|
+
if (typeof config.worker === 'function') {
|
|
4
|
+
config.worker('test-worker-id-plugin', jest.fn());
|
|
5
|
+
} else {
|
|
6
|
+
console.error('Mocked throng (plugin test): config.worker is not a function or not provided.');
|
|
7
|
+
}
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock the queue to avoid actual Redis operations and capture the handler
|
|
11
|
+
jest.mock('../queue', () => {
|
|
12
|
+
const original = jest.requireActual('../queue');
|
|
13
|
+
return {
|
|
14
|
+
...original,
|
|
15
|
+
queue: {
|
|
16
|
+
...original.queue,
|
|
17
|
+
process: jest.fn(), // Used to capture the job handler
|
|
18
|
+
},
|
|
19
|
+
addJob: jest.fn().mockResolvedValue('test-plugin-job-id'), // Mock addJob
|
|
20
|
+
saveResult: jest.fn().mockResolvedValue(undefined), // Mock saveResult
|
|
21
|
+
// listJobs, jobStatus etc. can be original or mocked if needed by other tests
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Mock bookingsControllerFactory to provide a mock for postProcess actions
|
|
26
|
+
const mockBookingsPostProcessAction = jest.fn();
|
|
27
|
+
jest.mock('../../controllers/bookings', () => {
|
|
28
|
+
return jest.fn(() => ({
|
|
29
|
+
// This action name must match what's in job.data.postProcess.action
|
|
30
|
+
$updateProductSearchCache: mockBookingsPostProcessAction,
|
|
31
|
+
// Add other methods if the worker's initialization of bookingsCtrl calls them
|
|
32
|
+
}));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Import the mocked queue and the actual worker module
|
|
36
|
+
const { queue, addJob, saveResult } = require('../queue');
|
|
37
|
+
const actualWorkerModule = require('../index'); // Actual worker module
|
|
38
|
+
const testUtils = require('../../test/utils'); // For setting up plugins
|
|
39
|
+
|
|
40
|
+
describe('worker: Plugin job handling', () => {
|
|
41
|
+
let instantiatedPlugins; // Array of instantiated mock plugins from testUtils
|
|
42
|
+
let actualJobHandler; // The job processing function from the worker
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
46
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
const utils = await testUtils({
|
|
50
|
+
plugins: ['mockPluginForWorker'], // Provide a name for our mock plugin
|
|
51
|
+
startServer: false, // Worker tests usually don't need a live server
|
|
52
|
+
worker: false,
|
|
53
|
+
});
|
|
54
|
+
instantiatedPlugins = utils.plugins; // These are already instantiated mock plugins
|
|
55
|
+
|
|
56
|
+
// Capture the job handler from queue.process
|
|
57
|
+
queue.process.mockImplementation((concurrency, handler) => {
|
|
58
|
+
if (typeof handler === 'function') {
|
|
59
|
+
actualJobHandler = handler;
|
|
60
|
+
} else if (typeof concurrency === 'function') { // Handle case where concurrency is omitted
|
|
61
|
+
actualJobHandler = concurrency;
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error('queue.process mock in plugin test could not capture handler');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Initialize the worker module. This should cause queue.process to be called.
|
|
68
|
+
await actualWorkerModule({ plugins: instantiatedPlugins });
|
|
69
|
+
|
|
70
|
+
if (!actualJobHandler) {
|
|
71
|
+
throw new Error('Job handler was not captured in plugin test. Worker might not have initialized queue processing correctly.');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
jest.clearAllMocks(); // Clears all mocks, including plugin method spies and postProcess action
|
|
77
|
+
// Reset specific mocks if needed, e.g., return values for plugin methods
|
|
78
|
+
if (instantiatedPlugins && instantiatedPlugins.length > 0 && instantiatedPlugins[0].searchProducts) {
|
|
79
|
+
instantiatedPlugins[0].searchProducts.mockClear();
|
|
80
|
+
}
|
|
81
|
+
mockBookingsPostProcessAction.mockClear();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(() => {
|
|
85
|
+
jest.restoreAllMocks();
|
|
86
|
+
actualJobHandler = null;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should call postProcess action after successful plugin method execution', async () => {
|
|
90
|
+
const mockPluginName = 'mockPluginForWorker'; // Must match plugin name used in testUtils setup
|
|
91
|
+
const mockPluginMethod = 'searchProducts'; // Method to be called on the plugin
|
|
92
|
+
const mockPluginPayload = { query: 'test query' };
|
|
93
|
+
const mockPluginToken = { apiKey: 'test-api-key-for-plugin' };
|
|
94
|
+
const mockPluginResult = { products: [{ id: 'prod123', name: 'Test Product' }] };
|
|
95
|
+
const jobId = 'plugin-job-with-pp-001';
|
|
96
|
+
|
|
97
|
+
// Ensure the target mock plugin and its method exist
|
|
98
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
99
|
+
if (!targetPlugin || typeof targetPlugin[mockPluginMethod] !== 'function') {
|
|
100
|
+
throw new Error(`Mock plugin ${mockPluginName} or method ${mockPluginMethod} not found or not a function.`);
|
|
101
|
+
}
|
|
102
|
+
// Mock the plugin method's return value for this specific test
|
|
103
|
+
targetPlugin[mockPluginMethod].mockResolvedValue(mockPluginResult);
|
|
104
|
+
|
|
105
|
+
const jobDataWithPostProcess = {
|
|
106
|
+
type: 'plugin',
|
|
107
|
+
pluginName: mockPluginName,
|
|
108
|
+
method: mockPluginMethod,
|
|
109
|
+
payload: mockPluginPayload, // This is job.data.payload
|
|
110
|
+
token: mockPluginToken, // This is job.data.token
|
|
111
|
+
inTesting: true,
|
|
112
|
+
postProcess: {
|
|
113
|
+
controller: 'bookings',
|
|
114
|
+
action: '$updateProductSearchCache', // Must match the mocked action name in bookings mock
|
|
115
|
+
args: { staticArg1: 'value1', appKey: mockPluginName }, // Static args for postProcess
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const mockJob = {
|
|
120
|
+
data: jobDataWithPostProcess,
|
|
121
|
+
id: jobId,
|
|
122
|
+
log: jest.fn(), // Bull's job.log
|
|
123
|
+
progress: jest.fn(), // Bull's job.progress
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await actualJobHandler(mockJob);
|
|
127
|
+
|
|
128
|
+
// 1. Verify the plugin method was called correctly
|
|
129
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledWith(expect.objectContaining({
|
|
131
|
+
...mockPluginPayload, // job.data.payload is spread
|
|
132
|
+
token: mockPluginToken, // job.data.token is passed as 'token'
|
|
133
|
+
requestId: jobId,
|
|
134
|
+
// axios and typeDefsAndQueries are also injected by the worker
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// 2. Verify the postProcess action was called correctly
|
|
138
|
+
expect(mockBookingsPostProcessAction).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(mockBookingsPostProcessAction).toHaveBeenCalledWith(expect.objectContaining({
|
|
140
|
+
staticArg1: 'value1', // From postProcess.args
|
|
141
|
+
appKey: mockPluginName, // From postProcess.args
|
|
142
|
+
pluginResult: mockPluginResult, // The result from the plugin method
|
|
143
|
+
requestId: jobId,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// 3. Verify saveResult was called (implicitly tests success path)
|
|
147
|
+
expect(saveResult).toHaveBeenCalledWith({ id: jobId, resultValue: mockPluginResult });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should still save result even if postProcess is not defined', async () => {
|
|
151
|
+
const mockPluginName = 'mockPluginForWorker';
|
|
152
|
+
const mockPluginMethod = 'searchProducts';
|
|
153
|
+
const mockPluginResult = { products: [{ id: 'prod456' }] };
|
|
154
|
+
const jobId = 'plugin-job-no-pp-002';
|
|
155
|
+
|
|
156
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
157
|
+
targetPlugin[mockPluginMethod].mockResolvedValue(mockPluginResult);
|
|
158
|
+
|
|
159
|
+
const jobDataNoPostProcess = {
|
|
160
|
+
type: 'plugin',
|
|
161
|
+
pluginName: mockPluginName,
|
|
162
|
+
method: mockPluginMethod,
|
|
163
|
+
payload: { query: 'another query' },
|
|
164
|
+
token: { apiKey: 'key-for-no-pp' },
|
|
165
|
+
inTesting: true,
|
|
166
|
+
// No postProcess field
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const mockJob = { data: jobDataNoPostProcess, id: jobId, log: jest.fn(), progress: jest.fn() };
|
|
170
|
+
await actualJobHandler(mockJob);
|
|
171
|
+
|
|
172
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(mockBookingsPostProcessAction).not.toHaveBeenCalled();
|
|
174
|
+
expect(saveResult).toHaveBeenCalledWith({ id: jobId, resultValue: mockPluginResult });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should throw error and not call postProcess if plugin method fails', async () => {
|
|
178
|
+
const mockPluginName = 'mockPluginForWorker';
|
|
179
|
+
const mockPluginMethod = 'searchProducts';
|
|
180
|
+
const jobId = 'plugin-job-fail-003';
|
|
181
|
+
const pluginError = new Error('Plugin method failed');
|
|
182
|
+
|
|
183
|
+
const targetPlugin = instantiatedPlugins.find(p => p.name === mockPluginName);
|
|
184
|
+
targetPlugin[mockPluginMethod].mockRejectedValue(pluginError);
|
|
185
|
+
|
|
186
|
+
const jobDataWithPpForFailure = {
|
|
187
|
+
type: 'plugin',
|
|
188
|
+
pluginName: mockPluginName,
|
|
189
|
+
method: mockPluginMethod,
|
|
190
|
+
payload: {},
|
|
191
|
+
token: {},
|
|
192
|
+
inTesting: true,
|
|
193
|
+
postProcess: {
|
|
194
|
+
controller: 'bookings',
|
|
195
|
+
action: '$updateProductSearchCache',
|
|
196
|
+
args: { appKey: mockPluginName },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const mockJob = { data: jobDataWithPpForFailure, id: jobId, log: jest.fn(), progress: jest.fn() };
|
|
200
|
+
|
|
201
|
+
await expect(actualJobHandler(mockJob)).rejects.toThrow(pluginError.message);
|
|
202
|
+
|
|
203
|
+
expect(targetPlugin[mockPluginMethod]).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(mockBookingsPostProcessAction).not.toHaveBeenCalled();
|
|
205
|
+
expect(saveResult).not.toHaveBeenCalled(); // saveResult should not be called on failure
|
|
206
|
+
});
|
|
207
|
+
});
|