ti2 1.0.94 → 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/index.js CHANGED
@@ -312,9 +312,8 @@ module.exports = async ({
312
312
  addJob({
313
313
  type: 'callback',
314
314
  payload: callbackBody,
315
- }).then(jobId => {
316
- console.debug('created callback job', jobId);
317
- });
315
+ removeOnComplete: true,
316
+ }).then();
318
317
  return originalJson.call(this, data);
319
318
  };
320
319
  }
@@ -0,0 +1,12 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ await queryInterface.addColumn('CronJobs', 'token', {
4
+ type: Sequelize.STRING,
5
+ allowNull: true
6
+ });
7
+ },
8
+
9
+ down: async (queryInterface) => {
10
+ await queryInterface.removeColumn('CronJobs', 'token');
11
+ }
12
+ };
@@ -0,0 +1,61 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ // Create a new table with the updated schema
4
+ await queryInterface.createTable('ApiCronJobs', {
5
+ id: {
6
+ type: Sequelize.UUID,
7
+ defaultValue: Sequelize.UUIDV4,
8
+ primaryKey: true,
9
+ },
10
+ userId: {
11
+ type: Sequelize.STRING,
12
+ allowNull: false,
13
+ },
14
+ bullJobId: {
15
+ type: Sequelize.STRING,
16
+ allowNull: true,
17
+ },
18
+ cron: {
19
+ type: Sequelize.STRING,
20
+ allowNull: false,
21
+ },
22
+ method: {
23
+ type: Sequelize.STRING,
24
+ allowNull: false,
25
+ },
26
+ url: {
27
+ type: Sequelize.STRING,
28
+ allowNull: false,
29
+ },
30
+ callbackUrl: {
31
+ type: Sequelize.STRING,
32
+ allowNull: true,
33
+ },
34
+ token: {
35
+ type: Sequelize.STRING,
36
+ allowNull: false,
37
+ },
38
+ body: {
39
+ type: Sequelize.JSON,
40
+ allowNull: true,
41
+ },
42
+ removeOnComplete: {
43
+ type: Sequelize.BOOLEAN,
44
+ allowNull: false,
45
+ defaultValue: false,
46
+ },
47
+ createdAt: {
48
+ type: Sequelize.DATE,
49
+ allowNull: false,
50
+ },
51
+ updatedAt: {
52
+ type: Sequelize.DATE,
53
+ allowNull: false,
54
+ },
55
+ });
56
+ },
57
+
58
+ down: async (queryInterface, Sequelize) => {
59
+ await queryInterface.dropTable('ApiCronJobs');
60
+ },
61
+ };
@@ -0,0 +1,96 @@
1
+ const Sequelize = require('sequelize');
2
+ const db = require('./db');
3
+
4
+ const ApiCronJobs = db.define('ApiCronJobs', {
5
+ id: {
6
+ type: Sequelize.UUID,
7
+ defaultValue: Sequelize.UUIDV4,
8
+ primaryKey: true,
9
+ },
10
+ userId: {
11
+ type: Sequelize.STRING,
12
+ allowNull: false,
13
+ },
14
+ bullJobId: {
15
+ type: Sequelize.STRING,
16
+ // It might be null initially until the afterCreate hook successfully adds the job
17
+ allowNull: true,
18
+ },
19
+ cron: {
20
+ type: Sequelize.STRING,
21
+ allowNull: false,
22
+ },
23
+ method: {
24
+ type: Sequelize.STRING,
25
+ allowNull: false,
26
+ },
27
+ url: {
28
+ type: Sequelize.STRING,
29
+ allowNull: false,
30
+ },
31
+ token: {
32
+ type: Sequelize.STRING,
33
+ allowNull: false,
34
+ },
35
+ body: {
36
+ type: Sequelize.JSON,
37
+ allowNull: true, // Assuming body can be optional
38
+ },
39
+ removeOnComplete: {
40
+ type: Sequelize.BOOLEAN,
41
+ allowNull: false,
42
+ defaultValue: false,
43
+ }
44
+ }, {
45
+ hooks: {
46
+ afterCreate: async (apiCronJob, options) => {
47
+ const { addJob } = require('../worker/queue');
48
+ const jobPayload = {
49
+ type: 'api',
50
+ method: apiCronJob.method,
51
+ url: apiCronJob.url,
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ 'Authorization': `Bearer ${apiCronJob.token}`,
55
+ },
56
+ payload: (apiCronJob.body || {}),
57
+ };
58
+
59
+ const jobParams = {
60
+ repeat: {
61
+ cron: apiCronJob.cron,
62
+ },
63
+ removeOnComplete: apiCronJob.removeOnComplete,
64
+ };
65
+
66
+ try {
67
+ // The addJob must be part of the same transaction that created apiCronJob
68
+ const bullJobId = await addJob(jobPayload, jobParams, options.transaction);
69
+ // Update the instance with the bullJobId and save it within the same transaction
70
+ // Note: This will trigger update hooks if any are defined, which is generally fine.
71
+ // apiCronJob.bullJobId = bullJobId;
72
+ await apiCronJob.update({ bullJobId }, { transaction: options.transaction, hooks: false });
73
+ } catch (err) {
74
+ // If addJob or the subsequent save fails, the transaction should be rolled back
75
+ // by the controller that initiated it.
76
+ // We re-throw the error to ensure the transaction is rolled back.
77
+ console.error('Failed to create Bull job or save bullJobId:', err);
78
+ throw err; // This will trigger the rollback in the controller
79
+ }
80
+ },
81
+ beforeDestroy: async (apiCronJob, options) => {
82
+ const { removeJob } = require('../worker/queue');
83
+ try {
84
+ // The removeJob must be part of the same transaction
85
+ await removeJob(apiCronJob.bullJobId, options.transaction);
86
+ } catch (err) {
87
+ // If removeJob fails, the transaction should be rolled back.
88
+ console.error('Failed to remove Bull job:', err);
89
+ throw err; // This will trigger the rollback in the controller
90
+ }
91
+ },
92
+ },
93
+ });
94
+
95
+ module.exports = ApiCronJobs;
96
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.94",
3
+ "version": "1.0.96",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -28,7 +28,7 @@
28
28
  "axios": "^0.27.2",
29
29
  "axios-curlirize": "^1.3.7",
30
30
  "bluebird": "^3.7.2",
31
- "bull": "^4.8.2",
31
+ "bull": "^4.16.5",
32
32
  "commander": "^9.2.0",
33
33
  "compression": "^1.7.4",
34
34
  "eventemitter2": "^6.4.9",
package/test/utils.js CHANGED
@@ -9,7 +9,10 @@ const Plugin = require('./plugin');
9
9
  const slugify = require('./slugify');
10
10
  const sqldb = require('../models/db');
11
11
 
12
- const { env: { adminKey } } = process;
12
+ const jwt = require('jsonwebtoken');
13
+ const { env: { adminKey, jwtSecret } } = process;
14
+
15
+ const createUserToken = (userId) => jwt.sign({ userId }, jwtSecret);
13
16
 
14
17
  afterAll(async () => {
15
18
  await sqldb.connectionManager.close();
@@ -51,8 +54,14 @@ module.exports = async (appParams = {}) => {
51
54
  } else {
52
55
  resp = await request(app)[verb](url).query(query).send(payload);
53
56
  }
54
- // if (resp.statusCode !== 200) assert.strictEqual(resp, `${url}-${expectStatusCode}`);
55
- assert.strictEqual(`${url}-${resp.statusCode}`, `${url}-${expectStatusCode}`);
57
+ if (resp.statusCode !== expectStatusCode) {
58
+ const error = new Error();
59
+ error.response = {
60
+ status: resp.statusCode,
61
+ data: resp.body
62
+ };
63
+ throw error;
64
+ }
56
65
  if (rawResponse) return resp;
57
66
  return resp.body;
58
67
  };
@@ -62,47 +71,66 @@ module.exports = async (appParams = {}) => {
62
71
  const doApiPut = params => doApi({ ...params, verb: 'put' });
63
72
  const doApiDelete = params => doApi({ ...params, verb: 'delete' });
64
73
 
65
- const appSetup = async ({ userId: userIdParam } = {}) => {
74
+ const appSetup = async ({ userId: userIdParam, appName: appNameParam, token: tokenDataParam, tokenHint: hintParam } = {}) => {
66
75
  const userId = userIdParam || chance.guid();
67
- const appName1 = slugify(
68
- chance.company(),
69
- ).toLowerCase();
70
- const newApp = {
71
- name: appName1,
72
- packageName: `ti2-${appName1}`,
73
- 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
74
82
  };
75
- const apiKey1 = chance.guid();
76
- 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 || {
77
97
  endpoint: chance.url(),
78
- apiKey: apiKey1,
98
+ apiKey: chance.guid(),
79
99
  };
80
- const { body: { value: appKey } } = await request(app)
81
- .post('/app')
82
- .set('Authorization', `Bearer ${adminKey}`)
83
- .send(newApp);
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 }));
104
+
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.
84
107
  await request(app)
85
- .post(`/${newApp.name}/${userId}`)
86
- .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
87
110
  .send({
88
- tokenHint: token.apiKey.split('-')[0],
89
- token,
90
- });
111
+ tokenHint: hint,
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
115
+
91
116
  return {
92
- newApp,
93
- token,
94
- appKey,
117
+ newApp: integrationDetails, // Information about the integration that was ensured/created
118
+ token: userAppKeyTokenContent, // The plaintext content that was intended for UserAppKey.appKey
95
119
  userId,
120
+ hint,
96
121
  };
97
122
  };
98
123
 
99
124
  return {
125
+ app,
100
126
  appSetup,
101
127
  doApiDelete,
102
128
  doApiGet,
103
129
  doApiPost,
104
130
  doApiPut,
131
+ createUserToken,
105
132
  slugify,
106
133
  plugins: app.plugins,
134
+ sqldb, // Expose sqldb for direct DB interaction if needed in tests (e.g., cleanup)
107
135
  };
108
136
  };