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.
- package/.github/workflows/tests.yml +7 -3
- package/api.yml +159 -0
- package/cli.js +106 -0
- package/controllers/README.md +70 -1
- package/controllers/__tests__/admin.js +1 -1
- package/controllers/__tests__/allotments.js +1 -2
- package/controllers/__tests__/app.js +1 -1
- package/controllers/__tests__/bookings-searchProducts.js +533 -0
- package/controllers/__tests__/bookings.js +29 -267
- package/controllers/__tests__/cronjobs.js +394 -0
- package/controllers/__tests__/ping.js +1 -1
- package/controllers/__tests__/productsSearch-background.js +1 -1
- package/controllers/__tests__/user.js +1 -1
- package/controllers/app.js +280 -14
- package/controllers/bookings.js +188 -108
- package/index.js +2 -3
- package/migrations/20240130000000-add-token-to-cronjobs.js +12 -0
- package/migrations/20250528_create_apiCronjobs.js +61 -0
- package/models/apiCronJobs.js +96 -0
- package/package.json +2 -2
- package/test/utils.js +54 -26
- package/worker/__tests__/job-callback.js +0 -22
- package/worker/__tests__/job-plugin.js +207 -0
- package/worker/index.js +191 -108
- package/worker/queue.js +70 -5
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/* globals beforeAll describe it expect */
|
|
2
|
+
const yaml = require('js-yaml');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// Load environment variables and API schema
|
|
6
|
+
const { env: { adminKey } } = process;
|
|
7
|
+
|
|
8
|
+
describe('cronjobs', () => {
|
|
9
|
+
const testUtils = require('../../test/utils');
|
|
10
|
+
// API helper functions
|
|
11
|
+
let doApiPost;
|
|
12
|
+
let doApiGet;
|
|
13
|
+
let doApiDelete;
|
|
14
|
+
|
|
15
|
+
// Test data
|
|
16
|
+
let appName;
|
|
17
|
+
let appKey;
|
|
18
|
+
let userId;
|
|
19
|
+
let userToken;
|
|
20
|
+
let userSetup;
|
|
21
|
+
let otherUserId;
|
|
22
|
+
let otherUserToken;
|
|
23
|
+
let otherUserSetup;
|
|
24
|
+
let createdAdminJobId; // To store the ID of the job created by admin
|
|
25
|
+
let createdUserJobId; // To store the ID of the job created by user
|
|
26
|
+
|
|
27
|
+
// Constants
|
|
28
|
+
const cron = '0 0 * * *';
|
|
29
|
+
const testApiPayload = ({ appKey, userId, hint }, bodyParam = {}) => ({
|
|
30
|
+
url: `/products/${appKey}/${userId}/${hint}/search`,
|
|
31
|
+
method: 'post',
|
|
32
|
+
body: { ...bodyParam },
|
|
33
|
+
cron,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
const {
|
|
38
|
+
doApiPost: post,
|
|
39
|
+
doApiGet: get,
|
|
40
|
+
doApiDelete: del,
|
|
41
|
+
createUserToken,
|
|
42
|
+
appSetup
|
|
43
|
+
} = await testUtils();
|
|
44
|
+
|
|
45
|
+
// Use appSetup to create app and user
|
|
46
|
+
userSetup = await appSetup();
|
|
47
|
+
appName = userSetup.newApp.name;
|
|
48
|
+
appKey = userSetup.appKey;
|
|
49
|
+
userId = userSetup.userId;
|
|
50
|
+
|
|
51
|
+
// Create user token
|
|
52
|
+
userToken = createUserToken(userId);
|
|
53
|
+
|
|
54
|
+
// Create another user for cross-user permission tests under the same app
|
|
55
|
+
otherUserSetup = await appSetup({ appName: appName });
|
|
56
|
+
otherUserId = otherUserSetup.userId;
|
|
57
|
+
|
|
58
|
+
// Create token for other user
|
|
59
|
+
otherUserToken = createUserToken(otherUserId);
|
|
60
|
+
|
|
61
|
+
doApiPost = post;
|
|
62
|
+
doApiGet = get;
|
|
63
|
+
doApiDelete = del;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should create a new cronjob as admin', async () => {
|
|
67
|
+
const newJobPayload = testApiPayload(userSetup);
|
|
68
|
+
const response = await doApiPost({
|
|
69
|
+
url: `/cronjobs/${userId}`,
|
|
70
|
+
token: adminKey,
|
|
71
|
+
payload: {...newJobPayload},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(response).toEqual(
|
|
75
|
+
expect.objectContaining({...newJobPayload}),
|
|
76
|
+
);
|
|
77
|
+
createdAdminJobId = response.id; // Changed from bullJobId to id
|
|
78
|
+
expect(createdAdminJobId).toBeTruthy(); // Check the new id variable
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should create a new cronjob as user', async () => {
|
|
82
|
+
const response = await doApiPost({
|
|
83
|
+
url: `/cronjobs/${userId}`,
|
|
84
|
+
token: userToken,
|
|
85
|
+
payload: {...testApiPayload(userSetup)},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(response).toEqual(
|
|
89
|
+
expect.objectContaining({...testApiPayload(userSetup)}),
|
|
90
|
+
);
|
|
91
|
+
createdUserJobId = response.id; // Changed from bullJobId to id
|
|
92
|
+
expect(createdUserJobId).toBeTruthy(); // Check the new id variable
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should list cronjobs as admin', async () => {
|
|
96
|
+
const { jobs } = await doApiGet({
|
|
97
|
+
url: `/cronjobs/${userId}`,
|
|
98
|
+
token: adminKey,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(Array.isArray(jobs)).toBe(true);
|
|
102
|
+
// Check for admin job
|
|
103
|
+
expect(jobs).toEqual(
|
|
104
|
+
expect.arrayContaining([
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
...testApiPayload(userSetup),
|
|
107
|
+
userId,
|
|
108
|
+
id: createdAdminJobId,
|
|
109
|
+
}),
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
// Check for user job
|
|
113
|
+
expect(jobs).toEqual(
|
|
114
|
+
expect.arrayContaining([
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
...testApiPayload(userSetup),
|
|
117
|
+
userId,
|
|
118
|
+
id: createdUserJobId,
|
|
119
|
+
}),
|
|
120
|
+
]),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should list cronjobs as user', async () => {
|
|
125
|
+
const { jobs } = await doApiGet({
|
|
126
|
+
url: `/cronjobs/${userId}`,
|
|
127
|
+
token: userToken,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(Array.isArray(jobs)).toBe(true);
|
|
131
|
+
// Check for admin job
|
|
132
|
+
expect(jobs).toEqual(
|
|
133
|
+
expect.arrayContaining([
|
|
134
|
+
expect.objectContaining({
|
|
135
|
+
...testApiPayload(userSetup),
|
|
136
|
+
userId,
|
|
137
|
+
id: createdAdminJobId,
|
|
138
|
+
}),
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
// Check for user job
|
|
142
|
+
expect(jobs).toEqual(
|
|
143
|
+
expect.arrayContaining([
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
...testApiPayload(userSetup),
|
|
146
|
+
userId,
|
|
147
|
+
id: createdUserJobId,
|
|
148
|
+
}),
|
|
149
|
+
]),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should delete a cronjob as admin', async () => {
|
|
154
|
+
const response = await doApiDelete({
|
|
155
|
+
url: `/cronjobs/${userId}/${createdAdminJobId}`, // Use createdAdminJobId
|
|
156
|
+
token: adminKey,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(response).toEqual({ success: true });
|
|
160
|
+
|
|
161
|
+
// Wait for the database to refresh
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 2e3));
|
|
163
|
+
|
|
164
|
+
// Verify it's deleted
|
|
165
|
+
const { jobs } = await doApiGet({
|
|
166
|
+
url: `/cronjobs/${userId}`,
|
|
167
|
+
token: adminKey,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Verify the job is no longer in the list
|
|
171
|
+
const deletedJob = jobs.find(job => job.id === createdAdminJobId); // Check against id and createdAdminJobId
|
|
172
|
+
expect(deletedJob).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should delete a cronjob as user', async () => {
|
|
176
|
+
// First create a new cronjob ( new one since we used prev for other test)
|
|
177
|
+
const { id: newJobId } = await doApiPost({
|
|
178
|
+
url: `/cronjobs/${userId}`,
|
|
179
|
+
token: userToken,
|
|
180
|
+
payload: {...testApiPayload(userSetup)},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const response = await doApiDelete({
|
|
184
|
+
url: `/cronjobs/${userId}/${newJobId}`,
|
|
185
|
+
token: userToken,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(response).toEqual({ success: true });
|
|
189
|
+
|
|
190
|
+
// Wait for the database to refresh
|
|
191
|
+
await new Promise(resolve => setTimeout(resolve, 2e3));
|
|
192
|
+
|
|
193
|
+
// Verify it's deleted
|
|
194
|
+
const { jobs } = await doApiGet({
|
|
195
|
+
url: `/cronjobs/${userId}`,
|
|
196
|
+
token: adminKey,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Check if the job is still in the list
|
|
200
|
+
const deletedJob = jobs.find(job => job.bullJobId === newJobId);
|
|
201
|
+
expect(deletedJob).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should fail when user tries to delete another users cronjob', async () => {
|
|
205
|
+
// Use the properly seeded otherUserId and otherUserToken
|
|
206
|
+
let otherJobId;
|
|
207
|
+
|
|
208
|
+
// First create a cronjob for the other user as an admin
|
|
209
|
+
const createResponse = await doApiPost({
|
|
210
|
+
url: `/cronjobs/${otherUserId}`,
|
|
211
|
+
token: adminKey,
|
|
212
|
+
payload: {...testApiPayload(otherUserSetup)},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
otherJobId = createResponse.id;
|
|
216
|
+
expect(otherJobId).toBeTruthy();
|
|
217
|
+
|
|
218
|
+
// Now try to delete the other user's job with the first user's token
|
|
219
|
+
// This should fail with an error
|
|
220
|
+
try {
|
|
221
|
+
await doApiDelete({
|
|
222
|
+
url: `/cronjobs/${otherUserId}/${otherJobId}`,
|
|
223
|
+
token: userToken,
|
|
224
|
+
});
|
|
225
|
+
// If we get here, the test should fail
|
|
226
|
+
throw new Error('Should have failed with permission error');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// Verify we got an error response
|
|
229
|
+
expect(error.response).toBeDefined();
|
|
230
|
+
// The exact status code might be 401 or 403 depending on implementation
|
|
231
|
+
expect([401, 403]).toContain(error.response.status);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Verify the job still exists (using admin token for consistency)
|
|
235
|
+
const { jobs } = await doApiGet({
|
|
236
|
+
url: `/cronjobs/${otherUserId}`,
|
|
237
|
+
token: adminKey,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const otherJob = jobs.find(job => job.id === otherJobId);
|
|
241
|
+
expect(otherJob).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should fail to create cronjob with invalid url', async () => {
|
|
245
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await doApiPost({
|
|
249
|
+
url: `/cronjobs/${userId}`,
|
|
250
|
+
token: adminKey,
|
|
251
|
+
payload: {
|
|
252
|
+
method: 'GET',
|
|
253
|
+
url: '/invalid/path',
|
|
254
|
+
cron,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
throw new Error('Should have failed');
|
|
258
|
+
} catch (error) {
|
|
259
|
+
expect(error.response.status).toBe(400);
|
|
260
|
+
expect(error.response.data.message).toContain('Invalid URL');
|
|
261
|
+
} finally {
|
|
262
|
+
consoleErrorSpy.mockRestore();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should fail to delete non-existent cronjob', async () => {
|
|
267
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
268
|
+
try {
|
|
269
|
+
await doApiDelete({
|
|
270
|
+
url: `/cronjobs/${userId}/nonexistent`,
|
|
271
|
+
token: adminKey,
|
|
272
|
+
});
|
|
273
|
+
throw new Error('Should have failed');
|
|
274
|
+
} catch (error) {
|
|
275
|
+
expect(error.response.status).toBe(404);
|
|
276
|
+
expect(error.response.data.message).toBe('Cronjob not found');
|
|
277
|
+
} finally {
|
|
278
|
+
consoleErrorSpy.mockRestore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('scheduled cronjob execution', () => {
|
|
283
|
+
let bullJobId;
|
|
284
|
+
describe('create and remove job (via api)', () => {
|
|
285
|
+
let cronJobIdToCleanup; // To store the ID of the job for cleanup in afterAll
|
|
286
|
+
let server; // To hold the HTTP server instance for manual cleanup if needed
|
|
287
|
+
|
|
288
|
+
// Define port here to be accessible in afterAll for server closing
|
|
289
|
+
const testPort = 44294;
|
|
290
|
+
|
|
291
|
+
afterAll(async () => {
|
|
292
|
+
if (cronJobIdToCleanup) {
|
|
293
|
+
try {
|
|
294
|
+
await doApiDelete({
|
|
295
|
+
url: `/cronjobs/${userId}/${cronJobIdToCleanup}`,
|
|
296
|
+
token: adminKey,
|
|
297
|
+
});
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error(`Failed to cleanup cronjob ${cronJobIdToCleanup} in afterAll: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Ensure server is closed
|
|
303
|
+
if (server && server.listening) {
|
|
304
|
+
await new Promise(resolve => server.close(resolve));
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should create, execute, and verify a scheduled cronjob', async () => {
|
|
309
|
+
const cronExpression = '* * * * *'; // Every minute
|
|
310
|
+
const uniqueId = `${(new Date()).getTime()}`;
|
|
311
|
+
let response; // To store the API response for creating the cronjob
|
|
312
|
+
|
|
313
|
+
const callBackServerPromise = new Promise((resolve, reject) => {
|
|
314
|
+
const http = require('http');
|
|
315
|
+
const url = require('url');
|
|
316
|
+
|
|
317
|
+
server = http.createServer((req, res) => {
|
|
318
|
+
let body = '';
|
|
319
|
+
req.on('data', chunk => body += chunk);
|
|
320
|
+
req.on('end', () => {
|
|
321
|
+
const requestUrl = url.parse(req.url, true);
|
|
322
|
+
const receivedUniqueId = requestUrl.query.date;
|
|
323
|
+
|
|
324
|
+
if (receivedUniqueId === uniqueId) {
|
|
325
|
+
// Check if response and response.bullJobId are available
|
|
326
|
+
if (response && response.bullJobId) {
|
|
327
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(JSON.stringify({ success: true }));
|
|
329
|
+
// server.close() will be handled in afterAll or if explicitly resolved/rejected
|
|
330
|
+
resolve({ id: response.bullJobId });
|
|
331
|
+
} else {
|
|
332
|
+
// This case handles if callback arrives but job creation might have failed or response not processed
|
|
333
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
334
|
+
res.end(JSON.stringify({ success: false, message: 'Callback received but job details unavailable.' }));
|
|
335
|
+
reject(new Error('Callback processed but original job details (response.bullJobId) were missing.'));
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
339
|
+
res.end(JSON.stringify({ success: false, message: 'Callback ID mismatch' }));
|
|
340
|
+
// Do not resolve or reject here, let the test timeout if correct callback never comes.
|
|
341
|
+
// Or, optionally, reject:
|
|
342
|
+
// reject(new Error('Callback ID mismatch.'));
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
server.on('error', (err) => {
|
|
348
|
+
reject(err); // Reject promise if server fails to start (e.g. port in use)
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
server.listen(testPort, '0.0.0.0', () => {
|
|
352
|
+
// console.log(`Test callback server listening on port ${testPort}`);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
response = await doApiPost({
|
|
356
|
+
url: `/cronjobs/${userId}`,
|
|
357
|
+
token: adminKey,
|
|
358
|
+
payload: {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
url: `/products/${appName}/${userId}/search`,
|
|
361
|
+
cron: cronExpression,
|
|
362
|
+
payload: {},
|
|
363
|
+
callbackUrl: `http://localhost:${testPort}/callback?date=${uniqueId}`,
|
|
364
|
+
removeOnComplete: true,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(response.bullJobId).toBeTruthy();
|
|
369
|
+
expect(response.id).toBeTruthy();
|
|
370
|
+
cronJobIdToCleanup = response.id;
|
|
371
|
+
|
|
372
|
+
const callBackInstance = await callBackServerPromise;
|
|
373
|
+
expect(callBackInstance.id).toBe(response.bullJobId);
|
|
374
|
+
bullJobId = response.bullJobId;
|
|
375
|
+
|
|
376
|
+
}, 120e3); // Timeout for the cronjob to be executed
|
|
377
|
+
|
|
378
|
+
})
|
|
379
|
+
it ('wait for queue job to be automatically removed', async() => {
|
|
380
|
+
expect(bullJobId).toBeTruthy();
|
|
381
|
+
const { queue } = require('../../worker/queue');
|
|
382
|
+
let jobInQueue;
|
|
383
|
+
const maxRetries = 60;
|
|
384
|
+
const retryInterval = 1e3;
|
|
385
|
+
|
|
386
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
387
|
+
jobInQueue = await queue.getJob(bullJobId);
|
|
388
|
+
if (!jobInQueue) break;
|
|
389
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, retryInterval));
|
|
390
|
+
}
|
|
391
|
+
expect(jobInQueue).toBeNull();
|
|
392
|
+
}, 60e3) // timeout for bull to remove the queue Job once executed (removeOnComplete)
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* globals beforeAll describe it expect jest beforeEach */
|
|
2
2
|
|
|
3
3
|
const chance = require('chance').Chance();
|
|
4
|
-
const testUtils = require('../../test/utils');
|
|
5
4
|
|
|
6
5
|
describe('user: bookings controller - productSearch background job', () => {
|
|
6
|
+
const testUtils = require('../../test/utils');
|
|
7
7
|
const newApp = {
|
|
8
8
|
name: `travelgate-${chance.guid()}`,
|
|
9
9
|
packageName: 'ti2-travelgate',
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const R = require('ramda');
|
|
4
4
|
const chance = require('chance').Chance();
|
|
5
|
-
const testUtils = require('../../test/utils');
|
|
6
5
|
const slugify = require('../../test/slugify');
|
|
7
6
|
|
|
8
7
|
const { env: { adminKey } } = process;
|
|
9
8
|
|
|
10
9
|
describe('user', () => {
|
|
10
|
+
const testUtils = require('../../test/utils');
|
|
11
11
|
const appName = slugify(
|
|
12
12
|
chance.company(),
|
|
13
13
|
).toLowerCase();
|