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.
@@ -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
- // NOTE: this is intend to cache the entire product list
152
- const cacheKey = hash({
153
- userId,
154
- hint,
155
- operationId: 'bookingsProductSearch',
156
- });
157
- // Check cache first if not forcing refresh
158
- const cacheValue = forceRefresh ? null : await app.cache.get({
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
- let isStale = lastUpdated && (Date.now() - lastUpdated > ttr * 1000);
167
- const doNotCallPluginForProducts = token.doNotCallPluginForProducts
168
- || R.path(['cacheSettings', 'bookingsProductSearch', 'doNotCall'], app);
169
- // if a company says never to call the plugin (their booking system)
170
- // we will never treat their cache as stale
171
- if (doNotCallPluginForProducts) {
172
- isStale = false;
173
- }
174
- const hasLock = await app.cache.get({
175
- key: `${cacheKey}:lock`,
176
- });
177
- // when there is a lock (meaning another request is already fetching the products)
178
- // return stale cache during this short window
179
- if (cacheValue && cacheValue.products && (hasLock || !isStale)) {
180
- const searchResults = $searchProductList(cacheValue.products, searchInput, optionId);
181
- return {
182
- ...cacheValue,
183
- products: searchResults,
184
- // this is for sending the product filters specifically being used by pyfilematch
185
- ...(token.configuration || {}),
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
- return { products: [] };
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
- // create a lock with 2 minute TTL
192
- await app.cache.save({
193
- key: `${cacheKey}:lock`,
194
- value: true,
195
- ttl: 120,
196
- });
197
- const funcResults = await func({
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
- // release the lock
232
- await app.cache.drop({
233
- key: `${cacheKey}:lock`,
234
- });
235
- const searchResults = $searchProductList(R.pathOr([], ['products'], funcResults), searchInput, optionId);
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
- module.exports = plugins => ({
565
- bookingsSearch: bookingsSearch(plugins),
566
- bookingsCancel: bookingsCancel(plugins),
567
- $bookingsProductSearch,
568
- bookingsProductSearch: bookingsProductSearch(plugins),
569
- getProductPackages: getProductPackages(plugins),
570
- bookingsAvailabilitySearch: bookingsAvailabilitySearch(plugins),
571
- $bookingsAvailabilityCalendar,
572
- bookingsAvailabilityCalendar: bookingsAvailabilityCalendar(plugins),
573
- searchQuote: searchQuote(plugins),
574
- createBooking: createBooking(plugins),
575
- getAffiliateAgents: getAffiliateAgents(plugins),
576
- getAffiliateDesks: getAffiliateDesks(plugins),
577
- getPickupPoints: getPickupPoints(plugins),
578
- getCreateBookingFields: getCreateBookingFields(plugins),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.95",
3
+ "version": "1.0.96",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
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 appName1 = appNameParam || slugify(
77
- chance.company(),
78
- ).toLowerCase();
79
- const newApp = {
80
- name: appName1,
81
- packageName: `ti2-${appName1}`,
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
- const apiKey1 = chance.guid();
85
- const token = {
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: apiKey1,
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
- let appKey;
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(`/${newApp.name}/${userId}`)
109
- .set('Authorization', `Bearer ${appKey}`)
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
+ });