Compare commits
2 Commits
2b36ef4e23
...
1c89181d87
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c89181d87 | |||
| daa234428e |
@ -18,9 +18,10 @@ jest.mock('../../services/scraperLogger', () => ({
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock uuid
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid-1234')
|
||||
// Mock crypto.randomUUID
|
||||
jest.mock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid-1234')
|
||||
}));
|
||||
|
||||
// Mock scraperService
|
||||
@ -256,8 +257,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -293,8 +295,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -325,8 +328,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -369,8 +373,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -410,8 +415,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -446,8 +452,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -481,8 +488,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit } = require('../../jobs/scraperJob');
|
||||
@ -518,8 +526,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const freshCron = require('node-cron');
|
||||
@ -564,8 +573,9 @@ describe('scraperJob - scheduler', () => {
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn()
|
||||
}))
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'scheduled-job-id')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'scheduled-job-id')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, isScraperRunning: freshIsRunning } = require('../../jobs/scraperJob');
|
||||
@ -609,8 +619,9 @@ describe('scraperJob - scheduler', () => {
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn()
|
||||
}))
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'job-release-test')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'job-release-test')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, isScraperRunning: freshIsRunning } = require('../../jobs/scraperJob');
|
||||
@ -649,8 +660,9 @@ describe('scraperJob - scheduler', () => {
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn()
|
||||
}))
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'job-error-test')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'job-error-test')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, isScraperRunning: freshIsRunning } = require('../../jobs/scraperJob');
|
||||
@ -688,8 +700,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperLogger', () => ({
|
||||
createLogger: jest.fn(() => freshLogger)
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'job-skip-test')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'job-skip-test')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, acquireLock: freshAcquire } = require('../../jobs/scraperJob');
|
||||
@ -731,8 +744,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, stopScheduler: freshStop } = require('../../jobs/scraperJob');
|
||||
@ -770,8 +784,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { initializeScheduler: freshInit, gracefulShutdown: freshShutdown } = require('../../jobs/scraperJob');
|
||||
@ -802,8 +817,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const {
|
||||
@ -855,8 +871,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperLogger', () => ({
|
||||
createLogger: jest.fn(() => freshLogger)
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'running-job-id')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'running-job-id')
|
||||
}));
|
||||
|
||||
const {
|
||||
@ -925,8 +942,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperLogger', () => ({
|
||||
createLogger: jest.fn(() => freshLogger)
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'timeout-job-id')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'timeout-job-id')
|
||||
}));
|
||||
|
||||
const {
|
||||
@ -975,8 +993,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { gracefulShutdown: freshShutdown } = require('../../jobs/scraperJob');
|
||||
@ -1015,8 +1034,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { gracefulShutdown: freshShutdown } = require('../../jobs/scraperJob');
|
||||
@ -1047,8 +1067,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const {
|
||||
@ -1089,8 +1110,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { registerSignalHandlers: freshRegister } = require('../../jobs/scraperJob');
|
||||
@ -1118,8 +1140,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { registerSignalHandlers: freshRegister } = require('../../jobs/scraperJob');
|
||||
@ -1166,8 +1189,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { isShuttingDown: freshIsShuttingDown } = require('../../jobs/scraperJob');
|
||||
@ -1195,8 +1219,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { isShuttingDown: freshIsShuttingDown, gracefulShutdown: freshShutdown } = require('../../jobs/scraperJob');
|
||||
@ -1228,8 +1253,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { gracefulShutdown } = require('../../jobs/scraperJob');
|
||||
@ -1256,8 +1282,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { isShuttingDown } = require('../../jobs/scraperJob');
|
||||
@ -1284,8 +1311,9 @@ describe('scraperJob - scheduler', () => {
|
||||
jest.doMock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn()
|
||||
}));
|
||||
jest.doMock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid')
|
||||
jest.doMock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomUUID: jest.fn(() => 'mock-uuid')
|
||||
}));
|
||||
|
||||
const { registerSignalHandlers } = require('../../jobs/scraperJob');
|
||||
@ -1307,7 +1335,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { getScheduleExpression: freshGetExpr } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1327,7 +1355,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { getScheduleExpression: freshGetExpr } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1349,7 +1377,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1369,7 +1397,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1393,7 +1421,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: freshInit, getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1421,7 +1449,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: freshInit, getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1451,7 +1479,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: freshInit, getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1476,7 +1504,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: utcInit, getNextScheduledRun: utcGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1507,7 +1535,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: freshInit, getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
@ -1539,7 +1567,7 @@ describe('scraperJob - scheduler', () => {
|
||||
createLogger: jest.fn(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }))
|
||||
}));
|
||||
jest.doMock('../../services/scraperService', () => ({ runScrape: jest.fn() }));
|
||||
jest.doMock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
jest.doMock('crypto', () => ({ ...jest.requireActual('crypto'), randomUUID: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
const { initializeScheduler: freshInit, getNextScheduledRun: freshGetNext } = require('../../jobs/scraperJob');
|
||||
|
||||
|
||||
846
__tests__/scraper/scraperRoutes.test.js
Normal file
846
__tests__/scraper/scraperRoutes.test.js
Normal file
@ -0,0 +1,846 @@
|
||||
/**
|
||||
* Tests for scraper admin route endpoints
|
||||
*
|
||||
* Covers:
|
||||
* - POST /api/admin/scraper/run
|
||||
* - Authentication and authorization (401, 403)
|
||||
* - Successful scrape trigger (202 with jobId)
|
||||
* - Conflict when scraper already running (409)
|
||||
* - Mutex lock acquisition and release
|
||||
* - Activity logging for admin trigger
|
||||
* - dryRun option support
|
||||
* - htmlContent option support
|
||||
* - Response structure validation
|
||||
* - GET /api/admin/scraper/status
|
||||
* - Authentication and authorization (401/403)
|
||||
* - Correct status response structure
|
||||
* - Idle vs running state
|
||||
* - lastRun details from database
|
||||
* - nextScheduledRun and schedule fields
|
||||
* - 503 on database errors
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const { MongoClient, ObjectId } = require('mongodb');
|
||||
const {
|
||||
createTestApp,
|
||||
generateTestToken,
|
||||
createTestAdmin,
|
||||
createTestUser,
|
||||
insertTestUser,
|
||||
cleanupTestData
|
||||
} = require('../helpers/testHelpers');
|
||||
|
||||
// Mock the scraperJob module to control mutex state and schedule functions
|
||||
jest.mock('../../jobs/scraperJob', () => {
|
||||
let _isRunning = false;
|
||||
let _currentJobId = null;
|
||||
let _scheduleExpression = '0 6 * * *';
|
||||
let _nextScheduledRun = '2026-02-07T06:00:00.000Z';
|
||||
|
||||
return {
|
||||
isScraperRunning: jest.fn(() => _isRunning),
|
||||
acquireLock: jest.fn((jobId) => {
|
||||
if (_isRunning) return false;
|
||||
_isRunning = true;
|
||||
_currentJobId = jobId;
|
||||
return true;
|
||||
}),
|
||||
releaseLock: jest.fn(() => {
|
||||
_isRunning = false;
|
||||
_currentJobId = null;
|
||||
}),
|
||||
getCurrentJobId: jest.fn(() => _currentJobId),
|
||||
getScheduleExpression: jest.fn(() => _scheduleExpression),
|
||||
getNextScheduledRun: jest.fn(() => _nextScheduledRun),
|
||||
initializeScheduler: jest.fn(),
|
||||
stopScheduler: jest.fn(),
|
||||
isShuttingDown: jest.fn(() => false),
|
||||
gracefulShutdown: jest.fn(),
|
||||
registerSignalHandlers: jest.fn(),
|
||||
// Test helpers to control mock state
|
||||
_setState: (running, jobId) => {
|
||||
_isRunning = running;
|
||||
_currentJobId = jobId || null;
|
||||
},
|
||||
__setRunning: (running, jobId) => {
|
||||
_isRunning = running;
|
||||
_currentJobId = jobId || null;
|
||||
},
|
||||
__setSchedule: (expression, nextRun) => {
|
||||
_scheduleExpression = expression;
|
||||
_nextScheduledRun = nextRun;
|
||||
},
|
||||
__reset: () => {
|
||||
_isRunning = false;
|
||||
_currentJobId = null;
|
||||
_scheduleExpression = '0 6 * * *';
|
||||
_nextScheduledRun = '2026-02-07T06:00:00.000Z';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock scraperService to prevent actual scraping
|
||||
jest.mock('../../services/scraperService', () => ({
|
||||
runScrape: jest.fn(() => Promise.resolve({ status: 'success', jobId: 'test-job-id' }))
|
||||
}));
|
||||
|
||||
// Mock activityLogger to track calls
|
||||
jest.mock('../../services/activityLogger', () => {
|
||||
const originalModule = jest.requireActual('../../services/activityLogger');
|
||||
return {
|
||||
...originalModule,
|
||||
logActivity: jest.fn(() => Promise.resolve())
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the scraper config for collection names
|
||||
jest.mock('../../config/scraper', () => ({
|
||||
TARGET_URL: 'https://example.com/test',
|
||||
SCRAPER_SCHEDULE: '0 6 * * *',
|
||||
SCRAPER_TIMEZONE: 'UTC',
|
||||
SCRAPER_ENABLED: true,
|
||||
SCRAPER_TIMEOUT: 30000,
|
||||
USER_AGENT: 'TestBot/1.0',
|
||||
RETRY_CONFIG: { maxRetries: 3, baseDelay: 1000, timeout: 30000 },
|
||||
SHUTDOWN_TIMEOUT: 30000,
|
||||
COLLECTIONS: {
|
||||
UNITS: 'units_migration_test',
|
||||
PRICES: 'unit_prices_migration_test',
|
||||
DAILY_SUMMARIES: 'daily_summaries',
|
||||
SCRAPER_RUNS: 'scraper_runs'
|
||||
}
|
||||
}));
|
||||
|
||||
const scraperJob = require('../../jobs/scraperJob');
|
||||
const { runScrape } = require('../../services/scraperService');
|
||||
const { logActivity } = require('../../services/activityLogger');
|
||||
|
||||
describe('Scraper Routes', () => {
|
||||
let connection;
|
||||
let db;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
const uri = process.env.MONGO_URI;
|
||||
connection = await MongoClient.connect(uri);
|
||||
db = connection.db('apartments_scraper_routes_test');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Allow pending async operations (e.g., .finally() callbacks) to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
if (connection) {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestData(db);
|
||||
// Also clean scraper_runs collection
|
||||
await db.collection('scraper_runs').deleteMany({});
|
||||
app = await createTestApp(db);
|
||||
|
||||
// Reset scraper state
|
||||
scraperJob.__reset();
|
||||
|
||||
// Clear mock call history (but keep implementations intact)
|
||||
scraperJob.isScraperRunning.mockClear();
|
||||
scraperJob.acquireLock.mockClear();
|
||||
scraperJob.releaseLock.mockClear();
|
||||
runScrape.mockClear();
|
||||
logActivity.mockClear();
|
||||
|
||||
// Restore default mock implementations
|
||||
runScrape.mockImplementation(() => Promise.resolve({ status: 'success', jobId: 'test-job-id' }));
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /api/admin/scraper/run
|
||||
// ============================================================
|
||||
describe('POST /api/admin/scraper/run', () => {
|
||||
|
||||
// ============================================================
|
||||
// Authentication Tests
|
||||
// ============================================================
|
||||
describe('authentication and authorization', () => {
|
||||
it('should return 401 without auth token', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 403 for non-admin user', async () => {
|
||||
const regularUser = createTestUser({ role: 'user' });
|
||||
await insertTestUser(db, regularUser);
|
||||
const token = generateTestToken(regularUser._id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.error).toMatch(/admin/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Successful Trigger Tests
|
||||
// ============================================================
|
||||
describe('successful scrape trigger', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should return 202 with jobId for admin user', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body).toHaveProperty('data');
|
||||
expect(res.body.data).toHaveProperty('jobId');
|
||||
expect(res.body.data).toHaveProperty('status', 'started');
|
||||
expect(res.body.data).toHaveProperty('message');
|
||||
});
|
||||
|
||||
it('should return response with correct structure (jobId, status, message, dryRun)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
const { data } = res.body;
|
||||
expect(data.jobId).toBeDefined();
|
||||
expect(typeof data.jobId).toBe('string');
|
||||
expect(data.jobId.length).toBeGreaterThan(0);
|
||||
expect(data.status).toBe('started');
|
||||
expect(typeof data.message).toBe('string');
|
||||
expect(data.dryRun).toBe(false);
|
||||
});
|
||||
|
||||
it('should return a UUID-formatted jobId', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
expect(res.body.data.jobId).toMatch(uuidRegex);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Conflict Tests (409)
|
||||
// ============================================================
|
||||
describe('scraper already running', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should return 409 when scraper is already running', async () => {
|
||||
// Set scraper as running
|
||||
scraperJob._setState(true, 'existing-job-id');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.error).toMatch(/already in progress/i);
|
||||
});
|
||||
|
||||
it('should check isScraperRunning before starting', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(scraperJob.isScraperRunning).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Lock Acquisition Tests
|
||||
// ============================================================
|
||||
describe('mutex lock behavior', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should acquire lock before starting scrape', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(scraperJob.acquireLock).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
|
||||
it('should release lock after scrape completes', async () => {
|
||||
// Make runScrape resolve quickly
|
||||
runScrape.mockImplementation(() => Promise.resolve({ status: 'success' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
// Wait for the async scrape to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// The lock should be released via the .finally() callback
|
||||
expect(scraperJob.releaseLock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should release lock even when scrape fails', async () => {
|
||||
// Make runScrape reject
|
||||
runScrape.mockImplementation(() => Promise.reject(new Error('Scrape failed')));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
|
||||
// Wait for the async scrape to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Lock should still be released in finally block
|
||||
expect(scraperJob.releaseLock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Activity Logging Tests
|
||||
// ============================================================
|
||||
describe('activity logging', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should log ADMIN_TRIGGER_SCRAPE activity', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(), // db
|
||||
expect.objectContaining({
|
||||
userId: adminUser._id.toString(),
|
||||
action: 'ADMIN_TRIGGER_SCRAPE',
|
||||
metadata: expect.objectContaining({
|
||||
jobId: expect.any(String),
|
||||
dryRun: false
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log dryRun flag in activity metadata', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({ dryRun: true });
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: 'ADMIN_TRIGGER_SCRAPE',
|
||||
metadata: expect.objectContaining({
|
||||
dryRun: true
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// dryRun Option Tests
|
||||
// ============================================================
|
||||
describe('dryRun option', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should handle dryRun option in request body', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({ dryRun: true });
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.data.dryRun).toBe(true);
|
||||
expect(res.body.data.message).toMatch(/dry run/i);
|
||||
});
|
||||
|
||||
it('should pass dryRun option to runScrape', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({ dryRun: true });
|
||||
|
||||
// Wait for async execution
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(runScrape).toHaveBeenCalledWith(
|
||||
expect.anything(), // db
|
||||
expect.objectContaining({
|
||||
dryRun: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should default dryRun to false when not provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.data.dryRun).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// htmlContent Option Tests
|
||||
// ============================================================
|
||||
describe('htmlContent option', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should pass htmlContent option to runScrape', async () => {
|
||||
const testHtml = '<html><body>Test content</body></html>';
|
||||
|
||||
await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({ htmlContent: testHtml });
|
||||
|
||||
// Wait for async execution
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(runScrape).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
htmlContent: testHtml
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log usingProvidedHtml in activity metadata when htmlContent provided', async () => {
|
||||
const testHtml = '<html><body>Test</body></html>';
|
||||
|
||||
await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({ htmlContent: testHtml });
|
||||
|
||||
expect(logActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
usingProvidedHtml: true
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// runScrape Integration Tests
|
||||
// ============================================================
|
||||
describe('scrape execution', () => {
|
||||
let adminUser;
|
||||
let adminToken;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminUser = createTestAdmin();
|
||||
await insertTestUser(db, adminUser);
|
||||
adminToken = generateTestToken(adminUser._id);
|
||||
});
|
||||
|
||||
it('should call runScrape with trigger "manual"', async () => {
|
||||
await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
// Wait for async execution
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(runScrape).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
trigger: 'manual'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call runScrape with the generated jobId', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
const { jobId } = res.body.data;
|
||||
|
||||
// Wait for async execution
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(runScrape).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
jobId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 202 immediately without waiting for scrape to finish', async () => {
|
||||
// Make runScrape take a "long time" (500ms is enough to prove async behavior)
|
||||
let scrapeResolve;
|
||||
runScrape.mockImplementation(() => new Promise(resolve => {
|
||||
scrapeResolve = resolve;
|
||||
setTimeout(() => resolve({ status: 'success' }), 500);
|
||||
}));
|
||||
|
||||
const startTime = Date.now();
|
||||
const res = await request(app)
|
||||
.post('/api/admin/scraper/run')
|
||||
.set('Cookie', [`auth_token=${adminToken}`])
|
||||
.send({});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Should respond in well under 200ms despite scrape taking 500ms
|
||||
expect(res.status).toBe(202);
|
||||
expect(elapsed).toBeLessThan(200);
|
||||
|
||||
// Resolve the scrape to clean up
|
||||
if (scrapeResolve) scrapeResolve({ status: 'success' });
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/admin/scraper/status
|
||||
// ============================================================
|
||||
describe('GET /api/admin/scraper/status', () => {
|
||||
|
||||
describe('Authentication and Authorization', () => {
|
||||
it('should return 401 without authentication', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.expect(401);
|
||||
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 403 for non-admin user', async () => {
|
||||
const regularUser = createTestUser({ role: 'user' });
|
||||
await insertTestUser(db, regularUser);
|
||||
const token = generateTestToken(regularUser._id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(403);
|
||||
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.error).toBe('Admin access required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful status response (admin)', () => {
|
||||
let admin;
|
||||
let token;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = createTestAdmin();
|
||||
await insertTestUser(db, admin);
|
||||
token = generateTestToken(admin._id);
|
||||
});
|
||||
|
||||
it('should return 200 with status for admin', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body).toHaveProperty('data');
|
||||
expect(res.body.data).toHaveProperty('currentStatus');
|
||||
expect(res.body.data).toHaveProperty('runningJobId');
|
||||
expect(res.body.data).toHaveProperty('lastRun');
|
||||
expect(res.body.data).toHaveProperty('nextScheduledRun');
|
||||
expect(res.body.data).toHaveProperty('schedule');
|
||||
});
|
||||
|
||||
it('should return currentStatus "idle" when scraper is not running', async () => {
|
||||
scraperJob.__setRunning(false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.currentStatus).toBe('idle');
|
||||
expect(res.body.data.runningJobId).toBeNull();
|
||||
});
|
||||
|
||||
it('should return currentStatus "running" with runningJobId when scraper is running', async () => {
|
||||
const jobId = 'test-job-id-abc-123';
|
||||
scraperJob.__setRunning(true, jobId);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.currentStatus).toBe('running');
|
||||
expect(res.body.data.runningJobId).toBe(jobId);
|
||||
});
|
||||
|
||||
it('should return lastRun as null when no runs exist', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.lastRun).toBeNull();
|
||||
});
|
||||
|
||||
it('should return lastRun object with correct fields when a run exists', async () => {
|
||||
// Insert a scraper run record
|
||||
await db.collection('scraper_runs').insertOne({
|
||||
jobId: 'previous-job-001',
|
||||
trigger: 'scheduled',
|
||||
status: 'success',
|
||||
startedAt: '2026-02-05T06:00:00.000Z',
|
||||
completedAt: '2026-02-05T06:00:12.345Z',
|
||||
duration: 12345,
|
||||
unitsProcessed: 50,
|
||||
pricesInserted: 48,
|
||||
newUnitsCount: 2,
|
||||
rentedUnitsCount: 1,
|
||||
staleUnitsCount: 0,
|
||||
errors: [],
|
||||
recordedAt: new Date('2026-02-05T06:00:12.345Z')
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
const lastRun = res.body.data.lastRun;
|
||||
expect(lastRun).not.toBeNull();
|
||||
expect(lastRun.jobId).toBe('previous-job-001');
|
||||
expect(lastRun.timestamp).toBe('2026-02-05T06:00:00.000Z');
|
||||
expect(lastRun.status).toBe('success');
|
||||
expect(lastRun.duration).toBe(12345);
|
||||
expect(lastRun.trigger).toBe('scheduled');
|
||||
expect(lastRun.unitsProcessed).toBe(50);
|
||||
expect(lastRun.pricesInserted).toBe(48);
|
||||
expect(lastRun.errors).toBeNull();
|
||||
});
|
||||
|
||||
it('should return lastRun with errors array when last run had errors', async () => {
|
||||
await db.collection('scraper_runs').insertOne({
|
||||
jobId: 'failed-job-002',
|
||||
trigger: 'manual',
|
||||
status: 'failed',
|
||||
startedAt: '2026-02-05T10:00:00.000Z',
|
||||
completedAt: '2026-02-05T10:00:32.000Z',
|
||||
duration: 32000,
|
||||
unitsProcessed: 0,
|
||||
pricesInserted: 0,
|
||||
newUnitsCount: 0,
|
||||
rentedUnitsCount: 0,
|
||||
staleUnitsCount: 0,
|
||||
errors: ['HTTP request failed after 3 retries'],
|
||||
recordedAt: new Date('2026-02-05T10:00:32.000Z')
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
const lastRun = res.body.data.lastRun;
|
||||
expect(lastRun.status).toBe('failed');
|
||||
expect(lastRun.errors).toEqual(['HTTP request failed after 3 retries']);
|
||||
});
|
||||
|
||||
it('should return the most recent run when multiple runs exist', async () => {
|
||||
// Insert older run
|
||||
await db.collection('scraper_runs').insertOne({
|
||||
jobId: 'older-job-001',
|
||||
trigger: 'scheduled',
|
||||
status: 'success',
|
||||
startedAt: '2026-02-04T06:00:00.000Z',
|
||||
completedAt: '2026-02-04T06:00:10.000Z',
|
||||
duration: 10000,
|
||||
unitsProcessed: 45,
|
||||
pricesInserted: 45,
|
||||
errors: [],
|
||||
recordedAt: new Date('2026-02-04T06:00:10.000Z')
|
||||
});
|
||||
|
||||
// Insert newer run
|
||||
await db.collection('scraper_runs').insertOne({
|
||||
jobId: 'newer-job-002',
|
||||
trigger: 'manual',
|
||||
status: 'success',
|
||||
startedAt: '2026-02-05T14:00:00.000Z',
|
||||
completedAt: '2026-02-05T14:00:08.000Z',
|
||||
duration: 8000,
|
||||
unitsProcessed: 52,
|
||||
pricesInserted: 50,
|
||||
errors: [],
|
||||
recordedAt: new Date('2026-02-05T14:00:08.000Z')
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.lastRun.jobId).toBe('newer-job-002');
|
||||
expect(res.body.data.lastRun.trigger).toBe('manual');
|
||||
});
|
||||
|
||||
it('should return nextScheduledRun as ISO timestamp', async () => {
|
||||
scraperJob.__setSchedule('0 6 * * *', '2026-02-07T06:00:00.000Z');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.nextScheduledRun).toBe('2026-02-07T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should return nextScheduledRun as null when scheduler is disabled', async () => {
|
||||
scraperJob.__setSchedule('disabled', null);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.nextScheduledRun).toBeNull();
|
||||
expect(res.body.data.schedule).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should return schedule cron expression', async () => {
|
||||
scraperJob.__setSchedule('0 6 * * *', '2026-02-07T06:00:00.000Z');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.data.schedule).toBe('0 6 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
let admin;
|
||||
let token;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = createTestAdmin();
|
||||
await insertTestUser(db, admin);
|
||||
token = generateTestToken(admin._id);
|
||||
});
|
||||
|
||||
it('should return 503 on database error', async () => {
|
||||
// Create a proxy db that works for auth (users collection)
|
||||
// but throws errors for scraper_runs collection
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
const brokenApp = express();
|
||||
brokenApp.use(express.json());
|
||||
brokenApp.use(cookieParser());
|
||||
|
||||
// Proxy db: real db for users, broken for scraper_runs
|
||||
const proxyDb = {
|
||||
collection: jest.fn((name) => {
|
||||
if (name === 'scraper_runs') {
|
||||
return {
|
||||
findOne: jest.fn().mockRejectedValue(new Error('Database connection lost'))
|
||||
};
|
||||
}
|
||||
// Delegate to real db for all other collections (users, etc.)
|
||||
return db.collection(name);
|
||||
})
|
||||
};
|
||||
brokenApp.locals.db = proxyDb;
|
||||
|
||||
const adminRoutes = require('../../routes/admin');
|
||||
brokenApp.use('/api/admin', adminRoutes);
|
||||
|
||||
const res = await request(brokenApp)
|
||||
.get('/api/admin/scraper/status')
|
||||
.set('Cookie', [`auth_token=${token}`])
|
||||
.expect(503);
|
||||
|
||||
expect(res.body).toHaveProperty('error');
|
||||
expect(res.body.error).toBe('Service temporarily unavailable');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,8 @@
|
||||
const cron = require('node-cron');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config/scraper');
|
||||
const { runScrape } = require('../services/scraperService');
|
||||
const { createLogger } = require('../services/scraperLogger');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// In-process mutex state
|
||||
let isRunning = false;
|
||||
@ -140,7 +140,7 @@ function initializeScheduler(db) {
|
||||
|
||||
// Create the scheduled job
|
||||
scheduledJob = cron.schedule(config.SCRAPER_SCHEDULE, async () => {
|
||||
const jobId = uuidv4();
|
||||
const jobId = crypto.randomUUID();
|
||||
const jobLogger = createLogger(jobId);
|
||||
|
||||
jobLogger.info('Scheduled scrape triggered');
|
||||
|
||||
108
routes/admin.js
108
routes/admin.js
@ -1142,5 +1142,113 @@ router.patch('/settings', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Scraper Endpoints
|
||||
// ============================================================
|
||||
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
isScraperRunning,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
getCurrentJobId,
|
||||
getScheduleExpression,
|
||||
getNextScheduledRun
|
||||
} = require('../jobs/scraperJob');
|
||||
const { runScrape } = require('../services/scraperService');
|
||||
const scraperConfig = require('../config/scraper');
|
||||
|
||||
/**
|
||||
* POST /api/admin/scraper/run
|
||||
* Trigger a manual scrape
|
||||
*
|
||||
* Request body (optional):
|
||||
* - dryRun: boolean - Skip database writes for safe testing
|
||||
* - htmlContent: string - Use provided HTML instead of fetching (for testing/debugging)
|
||||
*/
|
||||
router.post('/scraper/run', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
const { dryRun = false, htmlContent = null } = req.body || {};
|
||||
|
||||
// Check if scraper is already running
|
||||
if (isScraperRunning()) {
|
||||
return res.status(409).json({ error: 'Scrape already in progress' });
|
||||
}
|
||||
|
||||
// Generate job ID and acquire lock
|
||||
const jobId = crypto.randomUUID();
|
||||
acquireLock(jobId);
|
||||
|
||||
// Log admin action
|
||||
await logActivity(db, {
|
||||
userId: req.user._id.toString(),
|
||||
action: 'ADMIN_TRIGGER_SCRAPE',
|
||||
metadata: { jobId, dryRun, usingProvidedHtml: !!htmlContent }
|
||||
});
|
||||
|
||||
// Start scrape asynchronously (do not await - return 202 immediately)
|
||||
runScrape(db, { trigger: 'manual', jobId, dryRun, htmlContent })
|
||||
.catch((error) => {
|
||||
console.error('Async scrape failed:', error.message);
|
||||
})
|
||||
.finally(() => releaseLock());
|
||||
|
||||
// Return immediately with job ID
|
||||
res.status(202).json({
|
||||
data: {
|
||||
jobId,
|
||||
status: 'started',
|
||||
dryRun,
|
||||
message: dryRun ? 'Scrape job initiated (dry run - no DB writes)' : 'Scrape job initiated'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error triggering scrape:', error);
|
||||
res.status(500).json({ error: 'Failed to start scrape job' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/scraper/status
|
||||
* Get current scraper status including running state, last run details,
|
||||
* next scheduled run, and schedule expression.
|
||||
*/
|
||||
router.get('/scraper/status', async (req, res) => {
|
||||
try {
|
||||
const db = req.app.locals.db;
|
||||
|
||||
// Get last run from history
|
||||
const lastRunDoc = await db.collection(scraperConfig.COLLECTIONS.SCRAPER_RUNS)
|
||||
.findOne({}, { sort: { startedAt: -1 } });
|
||||
|
||||
const lastRun = lastRunDoc ? {
|
||||
jobId: lastRunDoc.jobId,
|
||||
timestamp: lastRunDoc.startedAt,
|
||||
status: lastRunDoc.status,
|
||||
duration: lastRunDoc.duration,
|
||||
trigger: lastRunDoc.trigger,
|
||||
unitsProcessed: lastRunDoc.unitsProcessed,
|
||||
pricesInserted: lastRunDoc.pricesInserted,
|
||||
errors: lastRunDoc.errors?.length > 0 ? lastRunDoc.errors : null
|
||||
} : null;
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
currentStatus: isScraperRunning() ? 'running' : 'idle',
|
||||
runningJobId: isScraperRunning() ? getCurrentJobId() : null,
|
||||
lastRun,
|
||||
nextScheduledRun: getNextScheduledRun(),
|
||||
schedule: getScheduleExpression()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching scraper status:', error);
|
||||
res.status(503).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.clearStatsCache = clearStatsCache;
|
||||
|
||||
Reference in New Issue
Block a user