ti2 1.0.92 → 1.0.93
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/api.yml +25 -6
- package/closeAll.js +14 -0
- package/controllers/__tests__/allotments.js +22 -30
- package/controllers/__tests__/app.js +11 -9
- package/controllers/__tests__/bookings.js +54 -63
- package/controllers/__tests__/productsSearch-background.js +111 -0
- package/controllers/bookings.js +15 -22
- package/index.js +42 -1
- package/jest.setup.js +15 -1
- package/lib/__tests__/callback.js +131 -0
- package/lib/callback.js +55 -0
- package/package.json +1 -1
- package/worker/__tests__/job-api.js +173 -0
- package/worker/__tests__/job-callback.js +104 -0
- package/worker/index.js +79 -4
package/api.yml
CHANGED
|
@@ -41,15 +41,13 @@ components:
|
|
|
41
41
|
type: object
|
|
42
42
|
description: The job app parameters
|
|
43
43
|
required: true
|
|
44
|
-
example:
|
|
45
|
-
|
|
46
|
-
}'
|
|
44
|
+
example:
|
|
45
|
+
method: dailyReport
|
|
47
46
|
jobParams:
|
|
48
47
|
type: object
|
|
49
48
|
description: Additional bull job params
|
|
50
|
-
example:
|
|
51
|
-
|
|
52
|
-
}'
|
|
49
|
+
example:
|
|
50
|
+
delay: 5000
|
|
53
51
|
JobStatus:
|
|
54
52
|
type: object
|
|
55
53
|
properties:
|
|
@@ -388,6 +386,23 @@ components:
|
|
|
388
386
|
settings:
|
|
389
387
|
type: object
|
|
390
388
|
description: an object with the app+user seettings
|
|
389
|
+
parameters:
|
|
390
|
+
CallbackUrl:
|
|
391
|
+
name: callbackUrl
|
|
392
|
+
in: query
|
|
393
|
+
description: Callback URL for asynchronous operations
|
|
394
|
+
required: false
|
|
395
|
+
schema:
|
|
396
|
+
type: string
|
|
397
|
+
format: url
|
|
398
|
+
BackgroundJob:
|
|
399
|
+
name: backgroundJob
|
|
400
|
+
in: query
|
|
401
|
+
description: Whether to run the job in the background
|
|
402
|
+
required: false
|
|
403
|
+
schema:
|
|
404
|
+
type: boolean
|
|
405
|
+
default: false
|
|
391
406
|
paths:
|
|
392
407
|
/ping:
|
|
393
408
|
get:
|
|
@@ -1640,6 +1655,8 @@ paths:
|
|
|
1640
1655
|
required: true
|
|
1641
1656
|
schema:
|
|
1642
1657
|
type: string
|
|
1658
|
+
- $ref: '#/components/parameters/CallbackUrl'
|
|
1659
|
+
- $ref: '#/components/parameters/BackgroundJob'
|
|
1643
1660
|
requestBody:
|
|
1644
1661
|
description: Search query
|
|
1645
1662
|
required: true
|
|
@@ -1683,6 +1700,8 @@ paths:
|
|
|
1683
1700
|
example: 'travelgate-australia'
|
|
1684
1701
|
schema:
|
|
1685
1702
|
type: string
|
|
1703
|
+
- $ref: '#/components/parameters/CallbackUrl'
|
|
1704
|
+
- $ref: '#/components/parameters/BackgroundJob'
|
|
1686
1705
|
requestBody:
|
|
1687
1706
|
description: Search query
|
|
1688
1707
|
required: true
|
package/closeAll.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
const { redisResults, queue } = require('./worker/queue');
|
|
2
2
|
const { cache } = require('./cache');
|
|
3
|
+
const { sequelize } = require('./models');
|
|
3
4
|
|
|
4
5
|
module.exports = async () => {
|
|
6
|
+
// Close queue and redis connections
|
|
5
7
|
await queue.close();
|
|
6
8
|
await redisResults.quit();
|
|
7
9
|
await cache.quit();
|
|
10
|
+
|
|
11
|
+
// Close database connection if it exists and is open
|
|
12
|
+
if (sequelize && typeof sequelize.close === 'function') {
|
|
13
|
+
try {
|
|
14
|
+
await sequelize.close();
|
|
15
|
+
} catch (err) {
|
|
16
|
+
// If connection is already closed, ignore the error
|
|
17
|
+
if (!err.message.includes('Connection already closed')) {
|
|
18
|
+
console.error('Error closing sequelize connection:', err);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
8
22
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
/* globals beforeAll describe it expect */
|
|
1
|
+
/* globals beforeAll describe it expect jest */
|
|
2
2
|
const axios = require('axios');
|
|
3
3
|
const MockAdapter = require('axios-mock-adapter');
|
|
4
4
|
const chance = require('chance').Chance();
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
const testUtils = require('../../test/utils');
|
|
8
7
|
|
|
9
8
|
const { env: { adminKey } } = process;
|
|
@@ -15,7 +14,8 @@ describe('allotment', () => {
|
|
|
15
14
|
adminEmail: 'engineering+tourplantest@tourconnect.com',
|
|
16
15
|
};
|
|
17
16
|
const appKey = newApp.name;
|
|
18
|
-
|
|
17
|
+
// Generate a random userId for better test isolation
|
|
18
|
+
const userId = `test-user-${chance.guid()}`;
|
|
19
19
|
const token = {
|
|
20
20
|
endpoint: 'https://someendpoint.com',
|
|
21
21
|
username: chance.guid(),
|
|
@@ -25,14 +25,12 @@ describe('allotment', () => {
|
|
|
25
25
|
tokenHint: 'testingToken',
|
|
26
26
|
token,
|
|
27
27
|
};
|
|
28
|
-
let doApiDelete;
|
|
29
28
|
let doApiGet;
|
|
30
29
|
let doApiPost;
|
|
31
30
|
let plugins;
|
|
32
31
|
let userToken;
|
|
33
32
|
beforeAll(async () => {
|
|
34
33
|
({
|
|
35
|
-
doApiDelete,
|
|
36
34
|
doApiGet,
|
|
37
35
|
doApiPost,
|
|
38
36
|
plugins,
|
|
@@ -40,18 +38,8 @@ describe('allotment', () => {
|
|
|
40
38
|
plugins: [appKey],
|
|
41
39
|
}));
|
|
42
40
|
|
|
43
|
-
// drop
|
|
44
|
-
|
|
45
|
-
await doApiDelete({
|
|
46
|
-
url: `/${appKey}/${userId}`,
|
|
47
|
-
token: adminKey,
|
|
48
|
-
payload: { tokenHint: newIntegration.tokenHint },
|
|
49
|
-
});
|
|
50
|
-
} catch (err) {
|
|
51
|
-
// console.debug(err);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// create the user
|
|
41
|
+
// Using random user IDs, so no need to drop existing integrations
|
|
42
|
+
// create the user - handle case where random user doesn't exist yet
|
|
55
43
|
({ value: userToken } = await doApiPost({
|
|
56
44
|
url: '/user',
|
|
57
45
|
token: adminKey,
|
|
@@ -59,20 +47,13 @@ describe('allotment', () => {
|
|
|
59
47
|
}));
|
|
60
48
|
expect(userToken).toBeTruthy();
|
|
61
49
|
|
|
62
|
-
// create the App+User
|
|
63
|
-
const {
|
|
64
|
-
url:
|
|
50
|
+
// create the App+User relation
|
|
51
|
+
const { value } = await doApiPost({
|
|
52
|
+
url: `/${appKey}/${userId}`,
|
|
65
53
|
token: userToken,
|
|
54
|
+
payload: newIntegration,
|
|
66
55
|
});
|
|
67
|
-
|
|
68
|
-
// relation does not exits
|
|
69
|
-
const { value } = await doApiPost({
|
|
70
|
-
url: `/${appKey}/${userId}`,
|
|
71
|
-
token: userToken,
|
|
72
|
-
payload: newIntegration,
|
|
73
|
-
});
|
|
74
|
-
expect(value).toBeTruthy();
|
|
75
|
-
}
|
|
56
|
+
expect(value).toBeTruthy();
|
|
76
57
|
});
|
|
77
58
|
const payload = {
|
|
78
59
|
dateFormat: 'DD-MM-YYYY',
|
|
@@ -106,10 +87,13 @@ describe('allotment', () => {
|
|
|
106
87
|
expect(call.token).toEqual(token);
|
|
107
88
|
});
|
|
108
89
|
it('should be able to receive an axios error', async () => {
|
|
90
|
+
// Temporarily silence console.error for this test since we're expecting an error
|
|
91
|
+
const originalConsoleError = console.error;
|
|
92
|
+
console.error = jest.fn();
|
|
93
|
+
|
|
109
94
|
const mock = new MockAdapter(axios);
|
|
110
95
|
mock.onGet('http://www.example.com').reply(500, { what: 'nothing here' });
|
|
111
96
|
let urlParamsError = new URLSearchParams({
|
|
112
|
-
|
|
113
97
|
...payload,
|
|
114
98
|
keyPath: 'errorAxios',
|
|
115
99
|
}).toString();
|
|
@@ -120,8 +104,14 @@ describe('allotment', () => {
|
|
|
120
104
|
});
|
|
121
105
|
expect(returnValue.allotments).toBeFalsy();
|
|
122
106
|
expect(returnValue.message).toBe('nothing here');
|
|
107
|
+
// Restore console.error even if the test fails
|
|
108
|
+
console.error = originalConsoleError;
|
|
123
109
|
});
|
|
124
110
|
it('should be able to receive an error from a regular response', async () => {
|
|
111
|
+
// Temporarily silence console.error for this test since we're expecting an error
|
|
112
|
+
const originalConsoleError = console.error;
|
|
113
|
+
console.error = jest.fn();
|
|
114
|
+
|
|
125
115
|
const mock = new MockAdapter(axios);
|
|
126
116
|
mock.onGet('http://www.example.com').reply(200, { error: 'something went wrong' });
|
|
127
117
|
let urlParamsError = new URLSearchParams({
|
|
@@ -135,5 +125,7 @@ describe('allotment', () => {
|
|
|
135
125
|
});
|
|
136
126
|
expect(returnValue.allotments).toBeFalsy();
|
|
137
127
|
expect(returnValue.message).toBe('something went wrong');
|
|
128
|
+
// Restore console.error even if the test fails
|
|
129
|
+
console.error = originalConsoleError;
|
|
138
130
|
});
|
|
139
131
|
});
|
|
@@ -112,18 +112,20 @@ describe('app', () => {
|
|
|
112
112
|
expect(jobId).toBeTruthy();
|
|
113
113
|
expect(status).toBeTruthy();
|
|
114
114
|
});
|
|
115
|
-
it('should be able to get the status of a
|
|
116
|
-
await global.sleep(5e3);
|
|
115
|
+
it('should be able to get the status of a job', async () => {
|
|
117
116
|
const url = `/${appName}/${userId}/${jobId}/jobStatus`;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
let jobStatus;
|
|
118
|
+
do {
|
|
119
|
+
jobStatus = await doApiGet({
|
|
120
|
+
url,
|
|
121
|
+
token: appKey,
|
|
122
|
+
payload: {},
|
|
123
|
+
});
|
|
124
|
+
await global.sleep(1e3);
|
|
125
|
+
} while (['waiting', 'active'].includes(jobStatus.status));
|
|
124
126
|
expect(jobStatus.status).toBe('success');
|
|
125
127
|
expect(jobStatus.result).toEqual({ someVal: true });
|
|
126
|
-
},
|
|
128
|
+
}, 30e3);
|
|
127
129
|
});
|
|
128
130
|
it('should be able to get a list of all tokens related to the app', async () => {
|
|
129
131
|
const { userAppKeys } = await doApiGet({
|
|
@@ -14,7 +14,8 @@ describe('user: bookings controller', () => {
|
|
|
14
14
|
adminEmail: 'engineering+travelgate@tourconnect.com',
|
|
15
15
|
};
|
|
16
16
|
const appKey = newApp.name;
|
|
17
|
-
|
|
17
|
+
// Generate a random userId for better test isolation
|
|
18
|
+
const userId = `test-user-${chance.guid()}`;
|
|
18
19
|
const token = {
|
|
19
20
|
endpoint: 'https://api.travelgatex.com',
|
|
20
21
|
apiKey: chance.guid(),
|
|
@@ -28,6 +29,7 @@ describe('user: bookings controller', () => {
|
|
|
28
29
|
let doApiGet;
|
|
29
30
|
let doApiPost;
|
|
30
31
|
let plugins;
|
|
32
|
+
let userToken;
|
|
31
33
|
beforeAll(async () => {
|
|
32
34
|
({
|
|
33
35
|
doApiDelete,
|
|
@@ -38,67 +40,69 @@ describe('user: bookings controller', () => {
|
|
|
38
40
|
plugins: [newApp.name],
|
|
39
41
|
}));
|
|
40
42
|
|
|
41
|
-
// drop all cache keys, clean slate
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
token: adminKey,
|
|
58
|
-
payload: { tokenHint: 'testingToken' },
|
|
59
|
-
});
|
|
60
|
-
} catch (err) {
|
|
61
|
-
console.debug(err);
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
await doApiDelete({
|
|
65
|
-
url: '/app/travelgate',
|
|
66
|
-
token: adminKey,
|
|
67
|
-
});
|
|
68
|
-
} catch (err) {
|
|
69
|
-
console.debug(err);
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
it('create the travelgate app', async () => {
|
|
43
|
+
// drop all related cache keys, clean slate for this user
|
|
44
|
+
const cacheKey = hash({
|
|
45
|
+
appKey,
|
|
46
|
+
userId,
|
|
47
|
+
hint: 'testingToken',
|
|
48
|
+
operationId: 'bookingsProductSearch',
|
|
49
|
+
});
|
|
50
|
+
await cache.drop({
|
|
51
|
+
pluginName: appKey,
|
|
52
|
+
key: cacheKey,
|
|
53
|
+
});
|
|
54
|
+
await cache.drop({
|
|
55
|
+
pluginName: appKey,
|
|
56
|
+
key: `${cacheKey}:lock`,
|
|
57
|
+
});
|
|
58
|
+
// create the travelgate app and user
|
|
73
59
|
const { value: apiKey } = await doApiPost({
|
|
74
60
|
url: '/app',
|
|
75
61
|
token: adminKey,
|
|
76
62
|
payload: newApp,
|
|
77
63
|
});
|
|
78
64
|
expect(apiKey).toBeTruthy();
|
|
79
|
-
|
|
65
|
+
|
|
66
|
+
// Create the user token
|
|
80
67
|
({ value: userToken } = await doApiPost({
|
|
81
68
|
url: '/user',
|
|
82
69
|
token: adminKey,
|
|
83
70
|
payload: { userId },
|
|
84
71
|
}));
|
|
85
72
|
expect(userToken).toBeTruthy();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
73
|
+
// Create the user first, then get the token
|
|
74
|
+
await doApiPost({
|
|
75
|
+
url: '/user',
|
|
76
|
+
token: adminKey,
|
|
77
|
+
payload: { userId, email: `${userId}@example.com` },
|
|
78
|
+
});
|
|
79
|
+
// Now get the token
|
|
80
|
+
({ value: userToken } = await doApiPost({
|
|
81
|
+
url: '/user',
|
|
82
|
+
token: adminKey,
|
|
83
|
+
payload: { userId },
|
|
84
|
+
}));
|
|
85
|
+
expect(userToken).toBeTruthy();
|
|
86
|
+
// create the travelGage+User setup
|
|
87
|
+
let userAppKeys = [];
|
|
88
|
+
({ userAppKeys } = await doApiGet({
|
|
89
89
|
url: `/user/${userId}/apps`,
|
|
90
90
|
token: userToken,
|
|
91
|
-
});
|
|
91
|
+
}));
|
|
92
|
+
// Check if we need to create the integration
|
|
92
93
|
if (!userAppKeys.find(e => e.hint === newIntegration.tokenHint
|
|
93
|
-
|
|
94
|
-
// relation does not
|
|
94
|
+
&& e.integrationId === newApp.name)) {
|
|
95
|
+
// relation does not exist
|
|
95
96
|
const { value } = await doApiPost({
|
|
96
97
|
url: `/${appKey}/${userId}`,
|
|
97
98
|
token: userToken,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
payload: newIntegration,
|
|
100
|
+
});
|
|
101
|
+
expect(value).toBeTruthy();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
jest.clearAllMocks();
|
|
102
106
|
});
|
|
103
107
|
it('should be able to search a for a booking', async () => {
|
|
104
108
|
const payload = {
|
|
@@ -173,6 +177,7 @@ describe('user: bookings controller', () => {
|
|
|
173
177
|
expect(products[1].options.length).toBe(2);
|
|
174
178
|
});
|
|
175
179
|
it('should be able to get booking products with searchInput', async () => {
|
|
180
|
+
|
|
176
181
|
const payload = {
|
|
177
182
|
searchInput: 'Transfer from Sydney Harbor to Hilton Hotel',
|
|
178
183
|
};
|
|
@@ -245,35 +250,21 @@ describe('user: bookings controller', () => {
|
|
|
245
250
|
client: 'tourconnect',
|
|
246
251
|
ttlForProducts: 2, // 2 seconds TTR
|
|
247
252
|
};
|
|
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
|
|
253
|
+
beforeAll(async () => {
|
|
262
254
|
await doApiPost({
|
|
263
255
|
url: `/${appKey}/${userId}`,
|
|
264
|
-
token:
|
|
256
|
+
token: userToken,
|
|
265
257
|
payload: {
|
|
266
|
-
token: shortTTRToken,
|
|
267
258
|
tokenHint: 'ttr-test',
|
|
259
|
+
token: shortTTRToken,
|
|
268
260
|
},
|
|
269
261
|
});
|
|
270
|
-
|
|
271
|
-
|
|
262
|
+
});
|
|
263
|
+
beforeEach(async () => {
|
|
272
264
|
jest.clearAllMocks();
|
|
273
265
|
});
|
|
274
266
|
|
|
275
267
|
it('should use cache within TTR period', async () => {
|
|
276
|
-
// First call should hit the plugin
|
|
277
268
|
await doApiPost({
|
|
278
269
|
url: `/products/${appKey}/${userId}/ttr-test/search`,
|
|
279
270
|
token: userToken,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/* globals beforeAll describe it expect jest beforeEach */
|
|
2
|
+
|
|
3
|
+
const chance = require('chance').Chance();
|
|
4
|
+
const testUtils = require('../../test/utils');
|
|
5
|
+
|
|
6
|
+
describe('user: bookings controller - productSearch background job', () => {
|
|
7
|
+
const newApp = {
|
|
8
|
+
name: `travelgate-${chance.guid()}`,
|
|
9
|
+
packageName: 'ti2-travelgate',
|
|
10
|
+
adminEmail: 'engineering+travelgate@tourconnect.com',
|
|
11
|
+
};
|
|
12
|
+
const appKey = newApp.name;
|
|
13
|
+
const userId = `testUser-${chance.guid()}`;
|
|
14
|
+
const token = {
|
|
15
|
+
endpoint: 'https://api.travelgatex.com',
|
|
16
|
+
apiKey: chance.guid(),
|
|
17
|
+
client: 'tourconnect',
|
|
18
|
+
};
|
|
19
|
+
const tokenHint = 'testingToken';
|
|
20
|
+
const newIntegration = {
|
|
21
|
+
tokenHint,
|
|
22
|
+
token,
|
|
23
|
+
};
|
|
24
|
+
let doApiDelete;
|
|
25
|
+
let doApiGet;
|
|
26
|
+
let doApiPost;
|
|
27
|
+
let plugins;
|
|
28
|
+
let userToken;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
// Setup test utilities with mocked plugins
|
|
32
|
+
({
|
|
33
|
+
doApiDelete,
|
|
34
|
+
doApiGet,
|
|
35
|
+
doApiPost,
|
|
36
|
+
plugins,
|
|
37
|
+
} = await testUtils({
|
|
38
|
+
plugins: [newApp.name],
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const { env: { adminKey } } = process;
|
|
42
|
+
// create the user
|
|
43
|
+
await doApiPost({
|
|
44
|
+
url: '/user',
|
|
45
|
+
token: adminKey,
|
|
46
|
+
payload: { userId, email: `${userId}@example.com` },
|
|
47
|
+
});
|
|
48
|
+
// Clean slate - Drop potential existing app/user integration
|
|
49
|
+
await doApiPost({
|
|
50
|
+
url: '/app',
|
|
51
|
+
token: adminKey,
|
|
52
|
+
payload: newApp,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Create the test user and get token
|
|
56
|
+
({ value: userToken } = await doApiPost({
|
|
57
|
+
url: '/user',
|
|
58
|
+
token: adminKey,
|
|
59
|
+
payload: { userId },
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Create the user integration
|
|
63
|
+
await doApiPost({
|
|
64
|
+
url: `/${appKey}/${userId}`,
|
|
65
|
+
token: userToken,
|
|
66
|
+
payload: newIntegration,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
jest.clearAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should create a background job when calling bookingsProductSearch with backgroundJob in payload', async () => {
|
|
75
|
+
// Payload with backgroundJob flag set to true
|
|
76
|
+
const payload = {
|
|
77
|
+
backgroundJob: true
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Call the endpoint with backgroundJob in the payload
|
|
81
|
+
const response = await doApiPost({
|
|
82
|
+
url: `/products/${appKey}/${userId}/${tokenHint}/search`,
|
|
83
|
+
token: userToken,
|
|
84
|
+
payload,
|
|
85
|
+
// Expect 200 OK with a jobId in the response
|
|
86
|
+
expectStatusCode: 200,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Check if jobId is returned in the response body
|
|
90
|
+
expect(response.jobId).toBeTruthy();
|
|
91
|
+
expect(typeof response.jobId).toBe('string');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return products directly when not using backgroundJob flag', async () => {
|
|
95
|
+
// Payload without backgroundJob flag
|
|
96
|
+
const payload = {};
|
|
97
|
+
|
|
98
|
+
// Call the endpoint without backgroundJob flag
|
|
99
|
+
const response = await doApiPost({
|
|
100
|
+
url: `/products/${appKey}/${userId}/${tokenHint}/search`,
|
|
101
|
+
token: userToken,
|
|
102
|
+
payload,
|
|
103
|
+
// Expect 200 OK, as the endpoint returns results directly
|
|
104
|
+
expectStatusCode: 200,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Check if products array is returned in the response body
|
|
108
|
+
expect(Array.isArray(response.products)).toBeTruthy();
|
|
109
|
+
expect(response.products.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/controllers/bookings.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const assert = require('assert');
|
|
2
2
|
const hash = require('object-hash');
|
|
3
3
|
const R = require('ramda');
|
|
4
|
-
const cache = require('../cache');
|
|
5
4
|
const { UserAppKey } = require('../models/index');
|
|
6
5
|
const { typeDefs: productTypeDefs, query: productQuery } = require('./graphql-schemas/product');
|
|
7
6
|
const { typeDefs: availTypeDefs, query: availQuery } = require('./graphql-schemas/availability');
|
|
@@ -151,19 +150,15 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
151
150
|
const func = (app.searchProducts || app.searchProductsForItinerary).bind(app);
|
|
152
151
|
// NOTE: this is intend to cache the entire product list
|
|
153
152
|
const cacheKey = hash({
|
|
154
|
-
appKey,
|
|
155
153
|
userId,
|
|
156
154
|
hint,
|
|
157
155
|
operationId: 'bookingsProductSearch',
|
|
158
156
|
});
|
|
159
|
-
// TODO: remove debugging console.logs after no related issues reported for a while
|
|
160
157
|
// Check cache first if not forcing refresh
|
|
161
|
-
const cacheValue = forceRefresh ? null : await cache.get({
|
|
162
|
-
pluginName: appKey,
|
|
158
|
+
const cacheValue = forceRefresh ? null : await app.cache.get({
|
|
163
159
|
key: cacheKey,
|
|
164
160
|
});
|
|
165
|
-
const lastUpdated = await cache.get({
|
|
166
|
-
pluginName: appKey,
|
|
161
|
+
const lastUpdated = await app.cache.get({
|
|
167
162
|
key: `${cacheKey}:lastUpdated`,
|
|
168
163
|
});
|
|
169
164
|
// refresh every 24 hours
|
|
@@ -176,16 +171,13 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
176
171
|
if (doNotCallPluginForProducts) {
|
|
177
172
|
isStale = false;
|
|
178
173
|
}
|
|
179
|
-
const hasLock = await cache.get({
|
|
180
|
-
pluginName: appKey,
|
|
174
|
+
const hasLock = await app.cache.get({
|
|
181
175
|
key: `${cacheKey}:lock`,
|
|
182
176
|
});
|
|
183
|
-
console.log(`${appKey}/${userId}/${hint}: lastUpdated: ${lastUpdated}, ttr: ${ttr}, isStale: ${isStale}, hasLock: ${hasLock}, foundCache: ${!!cacheValue}`);
|
|
184
177
|
// when there is a lock (meaning another request is already fetching the products)
|
|
185
178
|
// return stale cache during this short window
|
|
186
179
|
if (cacheValue && cacheValue.products && (hasLock || !isStale)) {
|
|
187
180
|
const searchResults = $searchProductList(cacheValue.products, searchInput, optionId);
|
|
188
|
-
console.log(`${appKey}/${userId}/${hint}: returning cached products: ${searchResults.length}`);
|
|
189
181
|
return {
|
|
190
182
|
...cacheValue,
|
|
191
183
|
products: searchResults,
|
|
@@ -194,13 +186,10 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
194
186
|
};
|
|
195
187
|
}
|
|
196
188
|
if (doNotCallPluginForProducts && !forceRefresh) {
|
|
197
|
-
console.log(`${appKey}/${userId}/${hint}:not calling the plugin because doNotCallPluginForProducts is true and forceRefresh is false`);
|
|
198
189
|
return { products: [] };
|
|
199
190
|
}
|
|
200
|
-
console.log(`${appKey}/${userId}/${hint}: (forceRefresh: ${forceRefresh}) so calling func to get fresh products`);
|
|
201
191
|
// create a lock with 2 minute TTL
|
|
202
|
-
await cache.save({
|
|
203
|
-
pluginName: appKey,
|
|
192
|
+
await app.cache.save({
|
|
204
193
|
key: `${cacheKey}:lock`,
|
|
205
194
|
value: true,
|
|
206
195
|
ttl: 120,
|
|
@@ -215,28 +204,32 @@ const $bookingsProductSearch = plugins => async ({
|
|
|
215
204
|
});
|
|
216
205
|
// save cache if products are found
|
|
217
206
|
if (funcResults && funcResults.products && funcResults.products.length > 0) {
|
|
218
|
-
console.log(`${appKey}/${userId}/${hint}: saving cache of ${funcResults.products.length} products`);
|
|
219
207
|
// I initially wanted to let the cache live forever
|
|
220
208
|
// but just in case user left TC or something, we don't want to keep the cache forever
|
|
221
209
|
// so we set the TTL to 30 days, within 30 days,
|
|
222
210
|
// we will refresh the cache every 24 hours(or specified by the user) without deleting the stale cache
|
|
223
211
|
const monthInSeconds = 30 * 24 * 60 * 60;
|
|
224
|
-
await cache.save({
|
|
225
|
-
pluginName: appKey,
|
|
212
|
+
await app.cache.save({
|
|
226
213
|
key: `${cacheKey}:lastUpdated`,
|
|
227
214
|
value: Date.now(),
|
|
228
215
|
ttl: monthInSeconds,
|
|
229
216
|
});
|
|
230
|
-
await cache.save({
|
|
231
|
-
pluginName: appKey,
|
|
217
|
+
await app.cache.save({
|
|
232
218
|
key: cacheKey,
|
|
233
219
|
value: funcResults,
|
|
234
220
|
ttl: monthInSeconds,
|
|
235
221
|
});
|
|
222
|
+
app.events.emit('bookingsProductSearch:cache:save', {
|
|
223
|
+
cacheKey,
|
|
224
|
+
userId,
|
|
225
|
+
hint,
|
|
226
|
+
operationId: 'bookingsProductSearch',
|
|
227
|
+
requestId,
|
|
228
|
+
pluginName: app.name,
|
|
229
|
+
});
|
|
236
230
|
}
|
|
237
231
|
// release the lock
|
|
238
|
-
await cache.drop({
|
|
239
|
-
pluginName: appKey,
|
|
232
|
+
await app.cache.drop({
|
|
240
233
|
key: `${cacheKey}:lock`,
|
|
241
234
|
});
|
|
242
235
|
const searchResults = $searchProductList(R.pathOr([], ['products'], funcResults), searchInput, optionId);
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const { v4: uuidv4 } = require('uuid');
|
|
|
14
14
|
const EventEmitter = require('eventemitter2');
|
|
15
15
|
const axios = require('axios');
|
|
16
16
|
const curlirize = require('axios-curlirize');
|
|
17
|
+
const { addJob } = require('./worker/queue');
|
|
17
18
|
|
|
18
19
|
const cacheSettings = {
|
|
19
20
|
'*': [
|
|
@@ -58,13 +59,14 @@ const axiosSafeResponse = response => {
|
|
|
58
59
|
module.exports = async ({
|
|
59
60
|
apiDocs = true,
|
|
60
61
|
plugins: pluginsParam = {},
|
|
62
|
+
pluginsInstantiated = false,
|
|
61
63
|
port: portParam,
|
|
62
64
|
startServer = true,
|
|
63
65
|
worker = false,
|
|
64
66
|
}) => {
|
|
65
67
|
const port = portParam || process.env.PORT || 10010;
|
|
66
68
|
// create the plugin instances
|
|
67
|
-
const plugins = await bb.map(Object.entries(pluginsParam), async ([pluginName, Plugin]) => {
|
|
69
|
+
const plugins = pluginsInstantiated || await bb.map(Object.entries(pluginsParam), async ([pluginName, Plugin]) => {
|
|
68
70
|
// pass all env variables
|
|
69
71
|
const pluginEnv = pickBy(
|
|
70
72
|
(_val, key) => key.substring(0, `ti2_${pluginName}`.length)
|
|
@@ -83,6 +85,7 @@ module.exports = async ({
|
|
|
83
85
|
getOrExec: args => cache.getOrExec({ ...args, pluginName }),
|
|
84
86
|
save: args => cache.save({ ...args, pluginName }),
|
|
85
87
|
},
|
|
88
|
+
axios,
|
|
86
89
|
events: ti2Events,
|
|
87
90
|
name: pluginName,
|
|
88
91
|
...params,
|
|
@@ -277,6 +280,44 @@ module.exports = async ({
|
|
|
277
280
|
},
|
|
278
281
|
}
|
|
279
282
|
*/
|
|
283
|
+
// check if the operation is supposed to be ran in the background
|
|
284
|
+
const backgroundJob = R.pathOr(
|
|
285
|
+
false, ['backgroundJob'], req.body,
|
|
286
|
+
);
|
|
287
|
+
if (backgroundJob) {
|
|
288
|
+
const job = await addJob({
|
|
289
|
+
type: 'api',
|
|
290
|
+
method: req.method,
|
|
291
|
+
url: req.url,
|
|
292
|
+
headers: req.headers,
|
|
293
|
+
payload: {
|
|
294
|
+
...req.body,
|
|
295
|
+
requestId: req.requestId,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
return res.json({ jobId: job });
|
|
299
|
+
}
|
|
300
|
+
// intercept res.json
|
|
301
|
+
const callbackUrl = R.pathOr(
|
|
302
|
+
false, ['callbackUrl'], req.body);
|
|
303
|
+
if (callbackUrl) {
|
|
304
|
+
// send callback
|
|
305
|
+
const originalJson = res.json;
|
|
306
|
+
res.json = function (data) {
|
|
307
|
+
const callbackBody = {
|
|
308
|
+
callbackUrl,
|
|
309
|
+
request: body,
|
|
310
|
+
result: data,
|
|
311
|
+
};
|
|
312
|
+
addJob({
|
|
313
|
+
type: 'callback',
|
|
314
|
+
payload: callbackBody,
|
|
315
|
+
}).then(jobId => {
|
|
316
|
+
console.debug('created callback job', jobId);
|
|
317
|
+
});
|
|
318
|
+
return originalJson.call(this, data);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
280
321
|
const pluginCacheSettings = R.pathOr({}, ['cacheSettings'], currentPlugin);
|
|
281
322
|
// Filter plugin cache settings to only include those with cacheInMiddleware: true
|
|
282
323
|
const filteredPluginCacheSettings = R.pickBy(
|
package/jest.setup.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
/* global afterAll */
|
|
1
|
+
/* global beforeAll afterAll */
|
|
2
2
|
|
|
3
3
|
const closeAll = require('./closeAll');
|
|
4
|
+
const models = require('./models');
|
|
4
5
|
|
|
6
|
+
// Setup global beforeAll and afterAll hooks
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
// Ensure database connection is established before tests
|
|
9
|
+
if (models.sequelize && typeof models.sequelize.authenticate === 'function') {
|
|
10
|
+
await models.sequelize.authenticate();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Make sure all connections are properly closed after tests
|
|
5
15
|
afterAll(async () => {
|
|
16
|
+
// Wait a bit to ensure all pending operations are completed
|
|
17
|
+
await global.sleep(100);
|
|
18
|
+
|
|
19
|
+
// Then close all connections
|
|
6
20
|
await closeAll();
|
|
7
21
|
});
|
|
8
22
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/* globals beforeAll describe it expect jest afterAll beforeEach */
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const MockAdapter = require('axios-mock-adapter');
|
|
5
|
+
const { sendCallback } = require('../callback');
|
|
6
|
+
|
|
7
|
+
describe('callback', () => {
|
|
8
|
+
let mock;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
mock = new MockAdapter(axios);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
mock.restore();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mock.reset();
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('sendCallback', () => {
|
|
24
|
+
it('should not do anything if callbackUrl is not provided', async () => {
|
|
25
|
+
const consoleSpy = jest.spyOn(console, 'error');
|
|
26
|
+
await sendCallback({
|
|
27
|
+
request: {
|
|
28
|
+
operationId: 'test',
|
|
29
|
+
payload: { test: true },
|
|
30
|
+
userId: '123',
|
|
31
|
+
integrationId: 'test-integration',
|
|
32
|
+
hint: 'test-hint'
|
|
33
|
+
},
|
|
34
|
+
result: { success: true },
|
|
35
|
+
});
|
|
36
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
37
|
+
consoleSpy.mockRestore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should send a POST request to the callback URL with correct payload', async () => {
|
|
41
|
+
const callbackUrl = 'https://api.example.com/callback';
|
|
42
|
+
const params = {
|
|
43
|
+
callbackUrl,
|
|
44
|
+
request: {
|
|
45
|
+
operationId: 'bookingsProductSearch',
|
|
46
|
+
payload: { searchInput: 'test' },
|
|
47
|
+
userId: '123',
|
|
48
|
+
integrationId: 'test-integration',
|
|
49
|
+
hint: 'test-hint'
|
|
50
|
+
},
|
|
51
|
+
result: { products: [] }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
mock.onPost(callbackUrl).reply((config) => {
|
|
55
|
+
const data = JSON.parse(config.data);
|
|
56
|
+
expect(data).toMatchObject({
|
|
57
|
+
request: params.request,
|
|
58
|
+
result: params.result,
|
|
59
|
+
timestamp: expect.any(String)
|
|
60
|
+
});
|
|
61
|
+
return [200];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await sendCallback(params);
|
|
65
|
+
expect(mock.history.post.length).toBe(1);
|
|
66
|
+
expect(mock.history.post[0].url).toBe(callbackUrl);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should log error when callback request fails', async () => {
|
|
70
|
+
const callbackUrl = 'https://api.example.com/callback';
|
|
71
|
+
const params = {
|
|
72
|
+
callbackUrl,
|
|
73
|
+
request: {
|
|
74
|
+
operationId: 'bookingsProductSearch',
|
|
75
|
+
payload: { searchInput: 'test' },
|
|
76
|
+
userId: '123',
|
|
77
|
+
integrationId: 'test-integration',
|
|
78
|
+
hint: 'test-hint'
|
|
79
|
+
},
|
|
80
|
+
result: { products: [] }
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
mock.onPost(callbackUrl).reply(500);
|
|
84
|
+
|
|
85
|
+
// Mock console.error but prevent actual logging
|
|
86
|
+
const originalError = console.error;
|
|
87
|
+
console.error = jest.fn();
|
|
88
|
+
|
|
89
|
+
await sendCallback(params);
|
|
90
|
+
|
|
91
|
+
expect(mock.history.post.length).toBe(1);
|
|
92
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
93
|
+
`Error sending callback to ${callbackUrl}: Request failed with status code 500`
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Restore original console.error
|
|
97
|
+
console.error = originalError;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should log error when network error occurs', async () => {
|
|
101
|
+
const callbackUrl = 'https://api.example.com/callback';
|
|
102
|
+
const params = {
|
|
103
|
+
callbackUrl,
|
|
104
|
+
request: {
|
|
105
|
+
operationId: 'bookingsProductSearch',
|
|
106
|
+
payload: { searchInput: 'test' },
|
|
107
|
+
userId: '123',
|
|
108
|
+
integrationId: 'test-integration',
|
|
109
|
+
hint: 'test-hint'
|
|
110
|
+
},
|
|
111
|
+
result: { products: [] }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
mock.onPost(callbackUrl).networkError();
|
|
115
|
+
|
|
116
|
+
// Mock console.error but prevent actual logging
|
|
117
|
+
const originalError = console.error;
|
|
118
|
+
console.error = jest.fn();
|
|
119
|
+
|
|
120
|
+
await sendCallback(params);
|
|
121
|
+
|
|
122
|
+
expect(mock.history.post.length).toBe(1);
|
|
123
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
124
|
+
`Error sending callback to ${callbackUrl}: Network Error`
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Restore original console.error
|
|
128
|
+
console.error = originalError;
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
package/lib/callback.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Send operation results to a callback URL
|
|
5
|
+
* @param {Object} params - Parameters for the callback
|
|
6
|
+
* @param {string} params.callbackUrl - URL to send the callback to
|
|
7
|
+
* @param {string} params.operationId - ID of the operation that was performed
|
|
8
|
+
* @param {Object} params.payload - The payload that was received
|
|
9
|
+
* @param {Object} params.result - The result of the operation
|
|
10
|
+
* @param {string} params.userId - User ID associated with the request
|
|
11
|
+
* @param {string} params.integrationId - Integration ID associated with the request
|
|
12
|
+
* @param {string} params.hint - Hint used in the request
|
|
13
|
+
* @returns {Promise<void>}
|
|
14
|
+
*/
|
|
15
|
+
async function sendCallback({
|
|
16
|
+
callbackUrl,
|
|
17
|
+
request,
|
|
18
|
+
result,
|
|
19
|
+
}) {
|
|
20
|
+
if (!callbackUrl) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const callbackPayload = {
|
|
25
|
+
request,
|
|
26
|
+
result,
|
|
27
|
+
timestamp: new Date().toISOString()
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await axios.post(callbackUrl, callbackPayload, {
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json'
|
|
34
|
+
},
|
|
35
|
+
maxBodyLength: Infinity,
|
|
36
|
+
maxContentLength: Infinity
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
let errorMessage = `Error sending callback to ${callbackUrl}: ${error.message}`;
|
|
40
|
+
if (error.code) {
|
|
41
|
+
errorMessage += ` (Code: ${error.code})`;
|
|
42
|
+
}
|
|
43
|
+
if (error.syscall) {
|
|
44
|
+
errorMessage += ` (Syscall: ${error.syscall})`;
|
|
45
|
+
}
|
|
46
|
+
if (error.address && error.port) {
|
|
47
|
+
errorMessage += ` (Address: ${error.address}:${error.port})`;
|
|
48
|
+
}
|
|
49
|
+
console.error(errorMessage);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
sendCallback
|
|
55
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
require('../../test/utils');
|
|
3
|
+
|
|
4
|
+
// Mock throng to directly execute the worker function
|
|
5
|
+
jest.mock('throng', () => jest.fn(config => {
|
|
6
|
+
if (typeof config.worker === 'function') {
|
|
7
|
+
// Provide dummy id and disconnect function, similar to how throng would call it
|
|
8
|
+
config.worker('test-worker-id', jest.fn());
|
|
9
|
+
} else {
|
|
10
|
+
console.error('Mocked throng: config.worker is not a function or not provided.');
|
|
11
|
+
// Optionally, throw an error or handle as appropriate for your tests
|
|
12
|
+
// throw new Error('Throng mock expected a worker function in config.');
|
|
13
|
+
}
|
|
14
|
+
// throng can return a promise or be void, depending on usage. Here, we don't need to return.
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock axios
|
|
18
|
+
jest.mock('axios');
|
|
19
|
+
|
|
20
|
+
// Mock the queue to avoid actual Redis operations
|
|
21
|
+
jest.mock('../queue', () => {
|
|
22
|
+
const original = jest.requireActual('../queue');
|
|
23
|
+
return {
|
|
24
|
+
...original,
|
|
25
|
+
queue: {
|
|
26
|
+
...original.queue,
|
|
27
|
+
process: jest.fn(),
|
|
28
|
+
getJob: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Mock the queue module
|
|
34
|
+
jest.mock('../queue', () => {
|
|
35
|
+
const original = jest.requireActual('../queue');
|
|
36
|
+
return {
|
|
37
|
+
...original,
|
|
38
|
+
queue: {
|
|
39
|
+
...original.queue,
|
|
40
|
+
process: jest.fn(),
|
|
41
|
+
getJob: jest.fn().mockResolvedValue({
|
|
42
|
+
id: 'test-job-id',
|
|
43
|
+
data: {
|
|
44
|
+
type: 'api',
|
|
45
|
+
method: 'POST',
|
|
46
|
+
url: '/products/testAppKey/testUserId/testTokenHint/search',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
payload: { backgroundJob: true },
|
|
49
|
+
inTesting: true
|
|
50
|
+
},
|
|
51
|
+
remove: jest.fn().mockResolvedValue(true)
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
addJob: jest.fn().mockResolvedValue('test-job-id'),
|
|
55
|
+
allDone: jest.fn().mockResolvedValue(true)
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Import the mocked queue and the actual worker module
|
|
60
|
+
const { addJob, queue, allDone } = require('../queue');
|
|
61
|
+
const actualWorkerModule = require('../index'); // Actual worker module
|
|
62
|
+
|
|
63
|
+
describe('worker: API job handling', () => {
|
|
64
|
+
let serverUrl;
|
|
65
|
+
let plugins;
|
|
66
|
+
let doApiPost;
|
|
67
|
+
|
|
68
|
+
let actualJobHandler;
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
// Silence console.log for tests
|
|
72
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
73
|
+
|
|
74
|
+
// Setting up test environment
|
|
75
|
+
|
|
76
|
+
const testUtilsResult = await require('../../test/utils')({
|
|
77
|
+
plugins: ['mockPlugin'],
|
|
78
|
+
startServer: false,
|
|
79
|
+
worker: false,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
plugins = testUtilsResult.plugins;
|
|
83
|
+
// doApiPost might not be used directly if worker uses supertest
|
|
84
|
+
serverUrl = 'http://127.0.0.1:3000'; // This might be for constructing expected URLs
|
|
85
|
+
|
|
86
|
+
axios.mockImplementation(config => {
|
|
87
|
+
// This mock is for the internal axios call made by the worker
|
|
88
|
+
if (config.url && config.url.includes('/products/testAppKey/testUserId/testTokenHint/search')) {
|
|
89
|
+
return Promise.resolve({ status: 200, data: { success: true, message: 'Mocked internal success' } });
|
|
90
|
+
}
|
|
91
|
+
console.error('Unexpected axios call to mock:', config);
|
|
92
|
+
return Promise.reject(new Error(`Unexpected axios call in mock for URL: ${config.url}`));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Mock queue.process to capture the handler passed by the actual worker
|
|
96
|
+
queue.process.mockImplementation((...args) => {
|
|
97
|
+
let handlerCallback;
|
|
98
|
+
if (typeof args[args.length - 1] === 'function') {
|
|
99
|
+
handlerCallback = args[args.length - 1];
|
|
100
|
+
} else {
|
|
101
|
+
console.error('queue.process mock called without a handler function', args);
|
|
102
|
+
throw new Error('queue.process mock called without a handler function');
|
|
103
|
+
}
|
|
104
|
+
const queueName = typeof args[0] === 'string' ? args[0] : 'default';
|
|
105
|
+
actualJobHandler = handlerCallback;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Initializing actual worker module
|
|
109
|
+
actualWorkerModule({ plugins }); // Initialize the actual worker, it should call the mocked queue.process
|
|
110
|
+
// Actual worker module initialized
|
|
111
|
+
|
|
112
|
+
// Test environment setup complete
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
jest.restoreAllMocks(); // Restores all mocks, including console.log spy
|
|
117
|
+
actualJobHandler = null; // Clear captured handler
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should create a job of type "api" for the product search endpoint and send it to the API server', async () => {
|
|
121
|
+
|
|
122
|
+
// 1. Test that we can create a job
|
|
123
|
+
const jobData = {
|
|
124
|
+
type: 'api',
|
|
125
|
+
method: 'POST',
|
|
126
|
+
url: '/products/testAppKey/testUserId/testTokenHint/search',
|
|
127
|
+
headers: { 'content-type': 'application/json' },
|
|
128
|
+
payload: { backgroundJob: true },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Adding job to queue...
|
|
132
|
+
const jobId = await addJob(jobData);
|
|
133
|
+
|
|
134
|
+
// Basic assertions
|
|
135
|
+
expect(jobId).toBeTruthy();
|
|
136
|
+
expect(typeof jobId).toBe('string');
|
|
137
|
+
|
|
138
|
+
// Verify addJob was called correctly
|
|
139
|
+
expect(addJob).toHaveBeenCalledWith(jobData);
|
|
140
|
+
|
|
141
|
+
// Simulate job processing by invoking the captured handler
|
|
142
|
+
// Simulating job processing with captured handler...
|
|
143
|
+
if (!actualJobHandler) {
|
|
144
|
+
throw new Error('Job handler was not captured from queue.process. Worker might not have initialized queue processing correctly.');
|
|
145
|
+
}
|
|
146
|
+
// Construct a mock Bull job object
|
|
147
|
+
const mockJob = {
|
|
148
|
+
data: jobData,
|
|
149
|
+
id: jobId,
|
|
150
|
+
log: jest.fn(),
|
|
151
|
+
progress: jest.fn(),
|
|
152
|
+
update: jest.fn(),
|
|
153
|
+
};
|
|
154
|
+
await actualJobHandler(mockJob);
|
|
155
|
+
// Job processing simulation complete.
|
|
156
|
+
|
|
157
|
+
// Verify queue.process was called (to capture the handler)
|
|
158
|
+
expect(queue.process).toHaveBeenCalled();
|
|
159
|
+
|
|
160
|
+
// Verify the internal API call was made by the worker correctly
|
|
161
|
+
expect(axios).toHaveBeenCalledWith(
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
method: jobData.method.toLowerCase(), // Ensure method matches job data
|
|
164
|
+
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/products\/testAppKey\/testUserId\/testTokenHint\/search$/),
|
|
165
|
+
data: {}, // Payload after R.omit(['backgroundJob'], { backgroundJob: true }) is an empty object
|
|
166
|
+
headers: expect.objectContaining({
|
|
167
|
+
// Axios might add/remove/modify some headers, so be specific about what must be there
|
|
168
|
+
// 'content-type': 'application/json' // This might be set by axios default or omitted if data is empty
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* globals describe it expect jest beforeAll afterAll */
|
|
2
|
+
|
|
3
|
+
// Import queue module directly to avoid reference issues
|
|
4
|
+
const queueModule = require('../queue');
|
|
5
|
+
|
|
6
|
+
// Create a mock for axios that will be used by the callback module
|
|
7
|
+
jest.mock('axios', () => ({
|
|
8
|
+
post: jest.fn().mockResolvedValue({ status: 200 })
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('worker: Callback job handling', () => {
|
|
12
|
+
it('should create a job of type "callback" with the correct structure', async () => {
|
|
13
|
+
// Create a job to send a callback
|
|
14
|
+
const callbackData = {
|
|
15
|
+
callbackUrl: 'https://example.com/callback',
|
|
16
|
+
request: { id: '123', method: 'GET' },
|
|
17
|
+
result: { status: 'success', data: { message: 'Operation completed' } }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const jobData = {
|
|
21
|
+
type: 'callback',
|
|
22
|
+
payload: callbackData
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Add the job to the queue
|
|
26
|
+
const jobId = await queueModule.addJob(jobData);
|
|
27
|
+
|
|
28
|
+
// Verify job was created with a valid ID
|
|
29
|
+
expect(jobId).toBeTruthy();
|
|
30
|
+
expect(typeof jobId).toBe('string');
|
|
31
|
+
|
|
32
|
+
// Get the job from the queue to verify its data
|
|
33
|
+
const job = await queueModule.queue.getJob(jobId);
|
|
34
|
+
|
|
35
|
+
// Verify the job exists and has the correct data
|
|
36
|
+
expect(job).toBeTruthy();
|
|
37
|
+
expect(job.data).toEqual(expect.objectContaining({
|
|
38
|
+
type: 'callback',
|
|
39
|
+
payload: expect.objectContaining({
|
|
40
|
+
callbackUrl: 'https://example.com/callback',
|
|
41
|
+
request: expect.any(Object),
|
|
42
|
+
result: expect.any(Object)
|
|
43
|
+
}),
|
|
44
|
+
inTesting: true // This is added by addJob function
|
|
45
|
+
}));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should include the correct structure for callback jobs', () => {
|
|
49
|
+
// This test verifies the worker/index.js handles callback jobs correctly
|
|
50
|
+
// by checking the code structure without actually running it
|
|
51
|
+
|
|
52
|
+
// Read the worker code
|
|
53
|
+
const workerCode = require('fs').readFileSync(require('path').resolve(__dirname, '../index.js'), 'utf8');
|
|
54
|
+
|
|
55
|
+
// Verify the worker code includes handling for callback jobs
|
|
56
|
+
expect(workerCode).toContain('if (type === \'callback\')');
|
|
57
|
+
|
|
58
|
+
// Verify it extracts the necessary parameters
|
|
59
|
+
expect(workerCode).toContain('callbackUrl,');
|
|
60
|
+
expect(workerCode).toContain('request,');
|
|
61
|
+
expect(workerCode).toContain('result,');
|
|
62
|
+
|
|
63
|
+
// Verify it calls the sendCallback function
|
|
64
|
+
expect(workerCode).toContain('sendCallback');
|
|
65
|
+
|
|
66
|
+
// Verify it returns the expected result structure
|
|
67
|
+
expect(workerCode).toContain('return { success: true }');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should use the callback module to send callbacks', async () => {
|
|
71
|
+
// Get a reference to the mocked axios.post function
|
|
72
|
+
const axios = require('axios');
|
|
73
|
+
|
|
74
|
+
// Reset mock call history
|
|
75
|
+
axios.post.mockClear();
|
|
76
|
+
|
|
77
|
+
// Create a job with callback data
|
|
78
|
+
const callbackData = {
|
|
79
|
+
callbackUrl: 'https://example.com/callback',
|
|
80
|
+
request: { id: '123', method: 'GET' },
|
|
81
|
+
result: { status: 'success', data: { message: 'Operation completed' } }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Directly call the sendCallback function to verify it works as expected
|
|
85
|
+
const { sendCallback } = require('../../lib/callback');
|
|
86
|
+
await sendCallback(callbackData);
|
|
87
|
+
|
|
88
|
+
// Verify axios.post was called with the correct parameters
|
|
89
|
+
expect(axios.post).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
91
|
+
callbackData.callbackUrl,
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
request: callbackData.request,
|
|
94
|
+
result: callbackData.result,
|
|
95
|
+
timestamp: expect.any(String)
|
|
96
|
+
}),
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
headers: expect.objectContaining({
|
|
99
|
+
'Content-Type': 'application/json'
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/worker/index.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
const throng = require('throng');
|
|
2
2
|
require('util').inspect.defaultOptions.depth = null;
|
|
3
|
-
|
|
3
|
+
const R = require('ramda');
|
|
4
4
|
const { queue, saveResult } = require('./queue');
|
|
5
5
|
|
|
6
6
|
const workers = process.env.WEB_CONCURRENCY || 2;
|
|
7
|
-
|
|
8
|
-
const maxJobsPerWorker = 1;
|
|
7
|
+
const maxJobsPerWorker = process.env.MAX_JOBS_PER_WORKER || 1;
|
|
9
8
|
|
|
10
9
|
const worker = ({ plugins: pluginsParam }) => (id, disconnect) => {
|
|
11
10
|
console.log(`Started worker ${id}`);
|
|
@@ -23,14 +22,88 @@ const worker = ({ plugins: pluginsParam }) => (id, disconnect) => {
|
|
|
23
22
|
queue.process(maxJobsPerWorker, async job => {
|
|
24
23
|
const {
|
|
25
24
|
data: {
|
|
25
|
+
type = 'plugin',
|
|
26
26
|
pluginName,
|
|
27
27
|
method,
|
|
28
28
|
userId,
|
|
29
29
|
inTesting,
|
|
30
|
+
payload = {}
|
|
30
31
|
},
|
|
31
32
|
id: jobId,
|
|
32
33
|
data: params,
|
|
33
34
|
} = job;
|
|
35
|
+
if (type === 'api') {
|
|
36
|
+
const {
|
|
37
|
+
method,
|
|
38
|
+
url,
|
|
39
|
+
payload,
|
|
40
|
+
headers,
|
|
41
|
+
} = params;
|
|
42
|
+
|
|
43
|
+
let code, result, server;
|
|
44
|
+
const http = require('http');
|
|
45
|
+
const axios = require('axios');
|
|
46
|
+
try {
|
|
47
|
+
const app = await require('../index')({
|
|
48
|
+
pluginsInstantiated: pluginsParam,
|
|
49
|
+
startServer: false,
|
|
50
|
+
worker: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Manually start server on random port
|
|
54
|
+
server = http.createServer(app);
|
|
55
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
56
|
+
const { address, port } = server.address();
|
|
57
|
+
const baseURL = `http://${address}:${port}`;
|
|
58
|
+
console.log(`Worker internal server for job ${jobId} listening on: ${baseURL}`);
|
|
59
|
+
|
|
60
|
+
// Use axios to make the request
|
|
61
|
+
const response = await axios({
|
|
62
|
+
method: method.toLowerCase(),
|
|
63
|
+
url: `${baseURL}${url}`,
|
|
64
|
+
data: R.omit(['backgroundJob'], payload),
|
|
65
|
+
headers: R.omit(['content-length'], headers),
|
|
66
|
+
validateStatus: () => true, // Prevent axios from throwing on non-2xx status
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
code = response.status;
|
|
70
|
+
result = response.data;
|
|
71
|
+
console.log(`Worker internal request for job ${jobId} completed with code ${code}`);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Handle errors during app init or axios request
|
|
74
|
+
console.error(`Worker internal request error for job ${jobId}:`, error.message);
|
|
75
|
+
// If axios error has response, use its status
|
|
76
|
+
code = R.pathOr(500, ['response', 'status'], error);
|
|
77
|
+
result = R.pathOr({ message: error.message }, ['response', 'data'], error);
|
|
78
|
+
} finally {
|
|
79
|
+
// Ensure server is closed if it was created
|
|
80
|
+
if (server) {
|
|
81
|
+
await new Promise(resolve => server.close(resolve));
|
|
82
|
+
console.log(`Worker internal server for job ${jobId} closed.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
code,
|
|
88
|
+
result,
|
|
89
|
+
success: code >= 200 && code < 300,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (type === 'callback') {
|
|
93
|
+
const {
|
|
94
|
+
callbackUrl,
|
|
95
|
+
request,
|
|
96
|
+
result,
|
|
97
|
+
} = payload;
|
|
98
|
+
|
|
99
|
+
console.log(`job ${jobId} > sending callback to ${callbackUrl}`);
|
|
100
|
+
await require('../lib/callback').sendCallback({
|
|
101
|
+
callbackUrl,
|
|
102
|
+
request,
|
|
103
|
+
result,
|
|
104
|
+
});
|
|
105
|
+
return { success: true };
|
|
106
|
+
}
|
|
34
107
|
console.log(`job ${jobId} > running ${pluginName}:${method} for ${userId}`);
|
|
35
108
|
const plugins = await (async () => {
|
|
36
109
|
if (inTesting) {
|
|
@@ -41,8 +114,10 @@ const worker = ({ plugins: pluginsParam }) => (id, disconnect) => {
|
|
|
41
114
|
})();
|
|
42
115
|
const thePlugin = plugins.find(({ name }) => name === pluginName);
|
|
43
116
|
const resultValue = await thePlugin[method]({
|
|
44
|
-
...params,
|
|
117
|
+
...params.payload,
|
|
118
|
+
token: params.token,
|
|
45
119
|
plugins,
|
|
120
|
+
typeDefsAndQueries: require('../controllers/bookings').typeDefsAndQueries,
|
|
46
121
|
});
|
|
47
122
|
console.log(`job ${jobId} > completed`);
|
|
48
123
|
await saveResult({ id: jobId, resultValue });
|