Files
flyer-crawler.projectium.com/src/routes/admin.jobs.routes.test.ts
Torben Sorensen 2a5cc5bb51
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m17s
unit test repairs
2026-01-12 08:10:37 -08:00

320 lines
13 KiB
TypeScript

// src/routes/admin.jobs.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { Job } from 'bullmq';
import type { UserProfile } from '../types';
import { createTestApp } from '../tests/utils/createTestApp';
// Mock the background job service to control its methods.
vi.mock('../services/backgroundJobService', () => ({
backgroundJobService: {
runDailyDealCheck: vi.fn(),
triggerAnalyticsReport: vi.fn(),
triggerWeeklyAnalyticsReport: vi.fn(),
},
}));
// Mock the queue service and other dependencies of admin.routes.ts
vi.mock('../services/queueService.server', () => ({
flyerQueue: { name: 'flyer-processing', add: vi.fn(), getJob: vi.fn() },
emailQueue: { name: 'email-sending', add: vi.fn(), getJob: vi.fn() },
analyticsQueue: { name: 'analytics-reporting', add: vi.fn(), getJob: vi.fn() },
cleanupQueue: { name: 'file-cleanup', add: vi.fn(), getJob: vi.fn() },
weeklyAnalyticsQueue: { name: 'weekly-analytics-reporting', add: vi.fn(), getJob: vi.fn() },
flyerWorker: {},
emailWorker: {},
analyticsWorker: {},
cleanupWorker: {},
weeklyAnalyticsWorker: {},
}));
// Mock the monitoring service - the routes use this service for job operations
vi.mock('../services/monitoringService.server', () => ({
monitoringService: {
getWorkerStatuses: vi.fn(),
getQueueStatuses: vi.fn(),
retryFailedJob: vi.fn(),
getJobStatus: vi.fn(),
},
}));
vi.mock('../services/db/index.db', () => ({
adminRepo: {},
flyerRepo: {},
recipeRepo: {},
userRepo: {},
personalizationRepo: {},
notificationRepo: {},
}));
vi.mock('../services/geocodingService.server');
vi.mock('node:fs/promises');
// Mock Bull Board UI dependencies
vi.mock('@bull-board/api');
vi.mock('@bull-board/api/bullMQAdapter');
// Fix: Mock ExpressAdapter as a class to allow `new ExpressAdapter()` to work.
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi
.fn()
.mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
// Import the router AFTER all mocks are defined.
import adminRouter from './admin.routes';
// Import the mocked modules to control them
import { backgroundJobService } from '../services/backgroundJobService'; // This is now a mock
import { analyticsQueue, cleanupQueue } from '../services/queueService.server';
import { monitoringService } from '../services/monitoringService.server'; // This is now a mock
import { NotFoundError, ValidationError } from '../services/db/errors.db';
// Mock the logger
vi.mock('../services/logger.server', async () => {
const { mockLogger, createMockLogger } = await import('../tests/utils/mockLogger');
return {
logger: mockLogger,
createScopedLogger: vi.fn(() => createMockLogger()),
};
});
// Mock the passport middleware
// Note: admin.routes.ts imports from '../config/passport', so we mock that path
vi.mock('../config/passport', () => ({
default: {
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
next();
}),
},
isAdmin: (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile | undefined;
if (user && user.role === 'admin') next();
else res.status(403).json({ message: 'Forbidden: Administrator access required.' });
},
}));
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
const adminUser = createMockUserProfile({
role: 'admin',
user: { user_id: 'admin-user-id', email: 'admin@test.com' },
});
// Create a single app instance with an admin user for all tests in this suite.
const app = createTestApp({
router: adminRouter,
basePath: '/api/admin',
authenticatedUser: adminUser,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /trigger/daily-deal-check', () => {
it('should trigger the daily deal check job and return 202 Accepted', async () => {
// Use the instance method mock
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
expect(response.status).toBe(202);
expect(response.body.data.message).toContain('Daily deal check job has been triggered');
expect(backgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
it('should return 500 if triggering the job fails', async () => {
vi.mocked(backgroundJobService.runDailyDealCheck).mockImplementation(() => {
throw new Error('Job runner failed');
});
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
expect(response.status).toBe(500);
expect(response.body.error.message).toContain('Job runner failed');
});
});
describe('POST /trigger/failing-job', () => {
it('should enqueue a job designed to fail and return 202 Accepted', async () => {
const mockJob = { id: 'failing-job-id-456' } as Job;
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
const response = await supertest(app).post('/api/admin/trigger/failing-job');
expect(response.status).toBe(202);
expect(response.body.data.message).toContain('Failing test job has been enqueued');
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', {
reportDate: 'FAIL',
});
});
it('should return 500 if enqueuing the job fails', async () => {
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post('/api/admin/trigger/failing-job');
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Queue is down');
});
});
describe('POST /trigger/analytics-report', () => {
it('should trigger the analytics report job and return 202 Accepted', async () => {
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockResolvedValue(
'manual-report-job-123',
);
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
expect(response.status).toBe(202);
expect(response.body.data.message).toContain(
'Analytics report generation job has been enqueued',
);
expect(backgroundJobService.triggerAnalyticsReport).toHaveBeenCalledTimes(1);
});
it('should return 500 if enqueuing the analytics job fails', async () => {
vi.mocked(backgroundJobService.triggerAnalyticsReport).mockRejectedValue(
new Error('Queue error'),
);
const response = await supertest(app).post('/api/admin/trigger/analytics-report');
expect(response.status).toBe(500);
});
});
describe('POST /trigger/weekly-analytics', () => {
it('should trigger the weekly analytics job and return 202 Accepted', async () => {
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockResolvedValue(
'manual-weekly-report-job-123',
);
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
expect(response.status).toBe(202);
expect(response.body.data.message).toContain('Successfully enqueued weekly analytics job');
expect(backgroundJobService.triggerWeeklyAnalyticsReport).toHaveBeenCalledTimes(1);
});
it('should return 500 if enqueuing the weekly analytics job fails', async () => {
vi.mocked(backgroundJobService.triggerWeeklyAnalyticsReport).mockRejectedValue(
new Error('Queue error'),
);
const response = await supertest(app).post('/api/admin/trigger/weekly-analytics');
expect(response.status).toBe(500);
});
});
describe('POST /flyers/:flyerId/cleanup', () => {
it('should enqueue a cleanup job for a valid flyer ID', async () => {
const flyerId = 789;
const mockJob = { id: `cleanup-job-${flyerId}` } as Job;
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob);
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
expect(response.status).toBe(202);
expect(response.body.data.message).toBe(
`File cleanup job for flyer ID ${flyerId} has been enqueued.`,
);
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
});
it('should return 500 if enqueuing the cleanup job fails', async () => {
const flyerId = 789;
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
expect(response.status).toBe(500);
expect(response.body.error.message).toBe('Queue is down');
});
it('should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).post('/api/admin/flyers/abc/cleanup');
expect(response.status).toBe(400);
expect(response.body.error.details[0].message).toMatch(/Expected number, received nan/i);
});
});
describe('POST /jobs/:queueName/:jobId/retry', () => {
const queueName = 'flyer-processing';
const jobId = 'failed-job-1';
it('should successfully retry a failed job', async () => {
// Arrange - mock the monitoring service to resolve successfully
vi.mocked(monitoringService.retryFailedJob).mockResolvedValue(undefined);
// Act
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
// Assert
expect(response.status).toBe(200);
expect(response.body.data.message).toBe(
`Job ${jobId} has been successfully marked for retry.`,
);
expect(monitoringService.retryFailedJob).toHaveBeenCalledWith(
queueName,
jobId,
'admin-user-id',
);
});
it('should return 400 if the queue name is invalid', async () => {
const response = await supertest(app).post(`/api/admin/jobs/invalid-queue/${jobId}/retry`);
// Zod validation fails because queue name is an enum
expect(response.status).toBe(400);
});
it('should return 404 if the job ID is not found in the weekly-analytics-reporting queue', async () => {
const queueName = 'weekly-analytics-reporting';
const jobId = 'some-job-id';
// Mock monitoringService.retryFailedJob to throw NotFoundError
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`),
);
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Job with ID '${jobId}' not found in queue '${queueName}'.`,
);
});
it('should return 404 if the job ID is not found in the queue', async () => {
// Mock monitoringService.retryFailedJob to throw NotFoundError
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
new NotFoundError("Job with ID 'not-found-job' not found in queue 'flyer-processing'."),
);
const response = await supertest(app).post(
`/api/admin/jobs/${queueName}/not-found-job/retry`,
);
expect(response.status).toBe(404);
expect(response.body.error.message).toContain('not found in queue');
});
it('should return 400 if the job is not in a failed state', async () => {
// Mock monitoringService.retryFailedJob to throw ValidationError
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(
new ValidationError([], "Job is not in a 'failed' state. Current state: completed."),
);
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Job is not in a 'failed' state. Current state: completed.",
); // This is now handled by the errorHandler
});
it('should return 500 if job.retry() throws an error', async () => {
// Mock monitoringService.retryFailedJob to throw a generic error
vi.mocked(monitoringService.retryFailedJob).mockRejectedValue(new Error('Cannot retry job'));
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(500);
expect(response.body.error.message).toContain('Cannot retry job');
});
it('should return 400 for an invalid queueName or jobId', async () => {
// This tests the Zod schema validation for the route params.
const response = await supertest(app).post('/api/admin/jobs/ / /retry');
expect(response.status).toBe(400);
});
});
});