more testing and queue work
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m39s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 5m39s
This commit is contained in:
186
src/routes/admin.content.routes.test.ts
Normal file
186
src/routes/admin.content.routes.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
// src/routes/admin.content.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
import { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
|
||||
|
||||
// Mock the specific DB modules used
|
||||
vi.mock('../services/db/admin.db', () => ({
|
||||
getSuggestedCorrections: vi.fn(),
|
||||
approveCorrection: vi.fn(),
|
||||
rejectCorrection: vi.fn(),
|
||||
updateSuggestedCorrection: vi.fn(),
|
||||
getUnmatchedFlyerItems: vi.fn(),
|
||||
updateRecipeStatus: vi.fn(),
|
||||
updateRecipeCommentStatus: vi.fn(),
|
||||
updateBrandLogo: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/db/flyer.db', () => ({
|
||||
getAllBrands: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock('../services/db/recipe.db');
|
||||
vi.mock('../services/db/user.db');
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../services/backgroundJobService');
|
||||
vi.mock('../services/geocodingService.server');
|
||||
vi.mock('../services/queueService.server');
|
||||
vi.mock('@bull-board/api');
|
||||
vi.mock('@bull-board/api/bullMQAdapter');
|
||||
vi.mock('@bull-board/express');
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import * as adminDb from '../services/db/admin.db';
|
||||
import * as flyerDb from '../services/db/flyer.db';
|
||||
const mockedDb = { ...adminDb, ...flyerDb } as Mocked<typeof adminDb & typeof flyerDb>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
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.' });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to create a test app instance.
|
||||
const createApp = (user?: UserProfile) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
if (user) {
|
||||
app.use((req, res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('Admin Content Management Routes (/api/admin)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' });
|
||||
const app = createApp(adminUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Corrections Routes', () => {
|
||||
it('GET /corrections should return corrections data', async () => {
|
||||
const mockCorrections: SuggestedCorrection[] = [createMockSuggestedCorrection({ suggested_correction_id: 1 })];
|
||||
mockedDb.getSuggestedCorrections.mockResolvedValue(mockCorrections);
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockCorrections);
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/approve should approve a correction', async () => {
|
||||
const correctionId = 123;
|
||||
mockedDb.approveCorrection.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
|
||||
expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId);
|
||||
});
|
||||
|
||||
it('POST /corrections/:id/reject should reject a correction', async () => {
|
||||
const correctionId = 789;
|
||||
mockedDb.rejectCorrection.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
|
||||
});
|
||||
|
||||
it('PUT /corrections/:id should update a correction', async () => {
|
||||
const correctionId = 101;
|
||||
const requestBody = { suggested_value: 'A new corrected value' };
|
||||
const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody });
|
||||
mockedDb.updateSuggestedCorrection.mockResolvedValue(mockUpdatedCorrection);
|
||||
const response = await supertest(app).put(`/api/admin/corrections/${correctionId}`).send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedCorrection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Brand Routes', () => {
|
||||
it('GET /brands should return a list of all brands', async () => {
|
||||
const mockBrands: Brand[] = [createMockBrand({ brand_id: 1, name: 'Brand A' })];
|
||||
mockedDb.getAllBrands.mockResolvedValue(mockBrands);
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockBrands);
|
||||
});
|
||||
|
||||
it('POST /brands/:id/logo should upload a logo and update the brand', async () => {
|
||||
const brandId = 55;
|
||||
mockedDb.updateBrandLogo.mockResolvedValue(undefined);
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Brand logo updated successfully.');
|
||||
expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recipe and Comment Routes', () => {
|
||||
it('PUT /recipes/:id/status should update a recipe status', async () => {
|
||||
const recipeId = 201;
|
||||
const requestBody = { status: 'public' as const };
|
||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
|
||||
mockedDb.updateRecipeStatus.mockResolvedValue(mockUpdatedRecipe);
|
||||
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||
});
|
||||
|
||||
it('PUT /comments/:id/status should update a comment status', async () => {
|
||||
const commentId = 301;
|
||||
const requestBody = { status: 'hidden' as const };
|
||||
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' });
|
||||
mockedDb.updateRecipeCommentStatus.mockResolvedValue(mockUpdatedComment);
|
||||
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedComment);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unmatched Items Route', () => {
|
||||
it('GET /unmatched-items should return a list of unmatched items', async () => {
|
||||
// Correctly create a mock for UnmatchedFlyerItem.
|
||||
// It's a combination of a flyer item and some context, including the 'flyer_item_name'.
|
||||
const mockFlyerItem = createMockFlyerItem({ flyer_item_id: 101, item: 'Mystery Item' });
|
||||
const mockUnmatchedItems: UnmatchedFlyerItem[] = [{
|
||||
...mockFlyerItem,
|
||||
unmatched_flyer_item_id: 1,
|
||||
status: 'pending',
|
||||
store_name: 'Test Store',
|
||||
flyer_item_name: mockFlyerItem.item, // Add the missing required property
|
||||
}];
|
||||
mockedDb.getUnmatchedFlyerItems.mockResolvedValue(mockUnmatchedItems);
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUnmatchedItems);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
src/routes/admin.jobs.routes.test.ts
Normal file
180
src/routes/admin.jobs.routes.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// src/routes/admin.jobs.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { Job } from 'bullmq';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../services/db/admin.db');
|
||||
vi.mock('../services/db/flyer.db');
|
||||
vi.mock('../services/db/recipe.db');
|
||||
vi.mock('../services/db/user.db');
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
vi.mock('../services/backgroundJobService', () => ({
|
||||
BackgroundJobService: class {
|
||||
runDailyDealCheck = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock('../services/geocodingService.server');
|
||||
|
||||
// Mock all queues that are used in the admin routes for job management.
|
||||
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() },
|
||||
// Also mock the workers, as they are imported by admin.routes.ts
|
||||
flyerWorker: {},
|
||||
emailWorker: {},
|
||||
analyticsWorker: {},
|
||||
cleanupWorker: {},
|
||||
}));
|
||||
|
||||
vi.mock('@bull-board/api');
|
||||
vi.mock('@bull-board/api/bullMQAdapter');
|
||||
vi.mock('@bull-board/express');
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import { BackgroundJobService } from '../services/backgroundJobService';
|
||||
import { flyerQueue, analyticsQueue, cleanupQueue } from '../services/queueService.server';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
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.' });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to create a test app instance.
|
||||
const createApp = (user?: UserProfile) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
if (user) {
|
||||
app.use((req, res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
||||
const app = createApp(adminUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /trigger/daily-deal-check', () => {
|
||||
it('should trigger the daily deal check job and return 202 Accepted', async () => {
|
||||
const runDailyDealCheckSpy = vi.spyOn(BackgroundJobService.prototype, 'runDailyDealCheck').mockImplementation(async () => {});
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Daily deal check job has been triggered');
|
||||
expect(runDailyDealCheckSpy).toHaveBeenCalledTimes(1);
|
||||
runDailyDealCheckSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
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.message).toContain('Failing test job has been enqueued');
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' });
|
||||
});
|
||||
});
|
||||
|
||||
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.message).toBe(`File cleanup job for flyer ID ${flyerId} has been enqueued.`);
|
||||
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
const response = await supertest(app).post('/api/admin/flyers/invalid-id/cleanup');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid flyer ID is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /jobs/:queueName/:jobId/retry', () => {
|
||||
const queueName = 'flyer-processing';
|
||||
const jobId = 'failed-job-1';
|
||||
|
||||
it('should successfully retry a failed job', async () => {
|
||||
// Arrange
|
||||
const mockJob = {
|
||||
id: jobId,
|
||||
getState: vi.fn().mockResolvedValue('failed'),
|
||||
retry: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as any);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(`Job ${jobId} has been successfully marked for retry.`);
|
||||
expect(mockJob.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 404 if the queue name is invalid', async () => {
|
||||
const response = await supertest(app).post(`/api/admin/jobs/invalid-queue/${jobId}/retry`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe("Queue 'invalid-queue' not found.");
|
||||
});
|
||||
|
||||
it('should return 404 if the job ID is not found in the queue', async () => {
|
||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(undefined);
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/not-found-job/retry`);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found in queue');
|
||||
});
|
||||
|
||||
it('should return 400 if the job is not in a failed state', async () => {
|
||||
const mockJob = {
|
||||
id: jobId,
|
||||
getState: vi.fn().mockResolvedValue('completed'),
|
||||
retry: vi.fn(),
|
||||
};
|
||||
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as any);
|
||||
|
||||
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Job is not in a 'failed' state. Current state: completed.");
|
||||
expect(mockJob.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
161
src/routes/admin.monitoring.routes.test.ts
Normal file
161
src/routes/admin.monitoring.routes.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// src/routes/admin.monitoring.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
// Mock the specific DB modules used
|
||||
vi.mock('../services/db/admin.db', () => ({
|
||||
getActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the queue service to control worker statuses
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
flyerWorker: { name: 'flyer-processing', isRunning: vi.fn() },
|
||||
emailWorker: { name: 'email-sending', isRunning: vi.fn() },
|
||||
analyticsWorker: { name: 'analytics-reporting', isRunning: vi.fn() },
|
||||
cleanupWorker: { name: 'file-cleanup', isRunning: vi.fn() },
|
||||
flyerQueue: { name: 'flyer-processing', getJobCounts: vi.fn() },
|
||||
emailQueue: { name: 'email-sending', getJobCounts: vi.fn() },
|
||||
analyticsQueue: { name: 'analytics-reporting', getJobCounts: vi.fn() },
|
||||
cleanupQueue: { name: 'file-cleanup', getJobCounts: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
|
||||
vi.mock('../services/db/flyer.db');
|
||||
vi.mock('../services/db/recipe.db');
|
||||
vi.mock('../services/db/user.db');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../services/backgroundJobService');
|
||||
vi.mock('../services/geocodingService.server');
|
||||
vi.mock('@bull-board/api');
|
||||
vi.mock('@bull-board/api/bullMQAdapter');
|
||||
vi.mock('@bull-board/express');
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import * as adminDb from '../services/db/admin.db';
|
||||
import * as queueService from '../services/queueService.server';
|
||||
const mockedDb = adminDb as Mocked<typeof adminDb>;
|
||||
const mockedQueueService = queueService as Mocked<typeof queueService>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
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.' });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to create a test app instance.
|
||||
const createApp = (user?: UserProfile) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
if (user) {
|
||||
app.use((req, res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('Admin Monitoring Routes (/api/admin)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
||||
const app = createApp(adminUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /activity-log', () => {
|
||||
it('should return a list of activity logs with default pagination', async () => {
|
||||
const mockLogs = [createMockActivityLogItem({ action: 'flyer_processed' })];
|
||||
mockedDb.getActivityLog.mockResolvedValue(mockLogs);
|
||||
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLogs);
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0);
|
||||
});
|
||||
|
||||
it('should use limit and offset query parameters when provided', async () => {
|
||||
mockedDb.getActivityLog.mockResolvedValue([]);
|
||||
|
||||
await supertest(app).get('/api/admin/activity-log?limit=10&offset=20');
|
||||
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(10, 20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workers/status', () => {
|
||||
it('should return the status of all registered workers', async () => {
|
||||
// Arrange: Set the mock status for each worker
|
||||
vi.mocked(mockedQueueService.flyerWorker.isRunning).mockReturnValue(true);
|
||||
vi.mocked(mockedQueueService.emailWorker.isRunning).mockReturnValue(true);
|
||||
vi.mocked(mockedQueueService.analyticsWorker.isRunning).mockReturnValue(false); // Simulate one worker being stopped
|
||||
vi.mocked(mockedQueueService.cleanupWorker.isRunning).mockReturnValue(true);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/workers/status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
{ name: 'flyer-processing', isRunning: true },
|
||||
{ name: 'email-sending', isRunning: true },
|
||||
{ name: 'analytics-reporting', isRunning: false },
|
||||
{ name: 'file-cleanup', isRunning: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /queues/status', () => {
|
||||
it('should return job counts for all registered queues', async () => {
|
||||
// Arrange: Set the mock job counts for each queue
|
||||
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockResolvedValue({ waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 });
|
||||
vi.mocked(mockedQueueService.emailQueue.getJobCounts).mockResolvedValue({ waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 });
|
||||
vi.mocked(mockedQueueService.analyticsQueue.getJobCounts).mockResolvedValue({ waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 });
|
||||
vi.mocked(mockedQueueService.cleanupQueue.getJobCounts).mockResolvedValue({ waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 });
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/queues/status');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
name: 'flyer-processing',
|
||||
counts: { waiting: 5, active: 1, completed: 100, failed: 2, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'email-sending',
|
||||
counts: { waiting: 0, active: 0, completed: 50, failed: 0, delayed: 0, paused: 0 },
|
||||
},
|
||||
{
|
||||
name: 'analytics-reporting',
|
||||
counts: { waiting: 0, active: 1, completed: 10, failed: 1, delayed: 0, paused: 0 },
|
||||
},
|
||||
{ name: 'file-cleanup', counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,860 +0,0 @@
|
||||
// src/routes/admin.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterAll, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
import adminRouter from './admin.routes'; // Correctly imported
|
||||
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockActivityLogItem } from '../tests/utils/mockFactories';
|
||||
import { Job } from 'bullmq';
|
||||
import { SuggestedCorrection, Brand, User, UnmatchedFlyerItem } from '../types';
|
||||
|
||||
// Mock the specific DB modules used by the admin router.
|
||||
// This is more robust than mocking the barrel file ('../services/db').
|
||||
vi.mock('../services/db/admin.db', () => ({
|
||||
getSuggestedCorrections: vi.fn(),
|
||||
approveCorrection: vi.fn(),
|
||||
rejectCorrection: vi.fn(),
|
||||
updateSuggestedCorrection: vi.fn(),
|
||||
getApplicationStats: vi.fn(),
|
||||
getDailyStatsForLast30Days: vi.fn(),
|
||||
getUnmatchedFlyerItems: vi.fn(),
|
||||
updateRecipeStatus: vi.fn(),
|
||||
updateRecipeCommentStatus: vi.fn(),
|
||||
getAllUsers: vi.fn(),
|
||||
getActivityLog: vi.fn(),
|
||||
updateUserRole: vi.fn(),
|
||||
updateBrandLogo: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/db/flyer.db', () => ({
|
||||
getAllBrands: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/db/recipe.db', () => ({
|
||||
// No functions from recipe.db are directly called by admin.routes, but we keep the mock for completeness.
|
||||
}));
|
||||
vi.mock('../services/db/user.db', () => ({
|
||||
findUserProfileById: vi.fn(),
|
||||
deleteUserById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the background job service to test the trigger endpoint.
|
||||
vi.mock('../services/backgroundJobService');
|
||||
|
||||
// Mock the geocoding service to test the cache clear endpoint.
|
||||
vi.mock('../services/geocodingService.server');
|
||||
|
||||
// Mock the queue service to test job enqueuing endpoints.
|
||||
vi.mock('../services/queueService.server', () => ({
|
||||
flyerQueue: { add: vi.fn() },
|
||||
emailQueue: { add: vi.fn() },
|
||||
analyticsQueue: { add: vi.fn() },
|
||||
cleanupQueue: { add: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the Bull Board modules to prevent them from running in a test environment.
|
||||
// The adapters expect real BullMQ queues, which we don't have in tests.
|
||||
vi.mock('@bull-board/api', () => ({
|
||||
// Mock createBullBoard to do nothing.
|
||||
createBullBoard: vi.fn(() => ({ router: (req: Request, res: Response, next: NextFunction) => next() })),
|
||||
}));
|
||||
vi.mock('@bull-board/api/bullMQAdapter', () => ({
|
||||
// Mock the BullMQAdapter as a class since the code uses `new BullMQAdapter()`.
|
||||
BullMQAdapter: class MockBullMQAdapter {},
|
||||
}));
|
||||
vi.mock('@bull-board/express', () => ({
|
||||
// Mock the ExpressAdapter as a class since the code uses `new ExpressAdapter()`.
|
||||
// This structure ensures that `new ExpressAdapter()` works correctly in the test environment.
|
||||
ExpressAdapter: class MockExpressAdapter {
|
||||
setBasePath() {}
|
||||
getRouter() {
|
||||
return (req: Request, res: Response, next: NextFunction) => next();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the mocked modules to control them in tests.
|
||||
import * as adminDb from '../services/db/admin.db';
|
||||
import * as flyerDb from '../services/db/flyer.db';
|
||||
import * as recipeDb from '../services/db/recipe.db';
|
||||
import * as userDb from '../services/db/user.db'; // Import the mocked user.db
|
||||
const mockedDb = { ...adminDb, ...flyerDb, ...recipeDb, ...userDb } as Mocked<typeof adminDb & typeof flyerDb & typeof recipeDb & typeof userDb>;
|
||||
import { runDailyDealCheck } from '../services/backgroundJobService';
|
||||
import { analyticsQueue, cleanupQueue } from '../services/queueService.server';
|
||||
import { clearGeocodeCache } from '../services/geocodingService.server';
|
||||
|
||||
// Mock the logger to keep test output clean
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Use vi.hoisted to create a mutable mock function reference that can be controlled in tests.
|
||||
const mockedIsAdmin = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('./passport.routes', () => ({
|
||||
// Mock the default export (the passport instance)
|
||||
default: {
|
||||
// The 'authenticate' method returns a middleware function. We mock that.
|
||||
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
// For admin tests, we just need to ensure the request passes through to the isAdmin middleware.
|
||||
next();
|
||||
}),
|
||||
},
|
||||
// Mock the named export 'isAdmin' by assigning our hoisted mock function to it.
|
||||
isAdmin: mockedIsAdmin,
|
||||
}));
|
||||
|
||||
// We no longer need to import `isAdmin` from the real module, as we control `mockedIsAdmin` directly.
|
||||
// import { isAdmin } from './passport.routes'; // This line is removed.
|
||||
// const mockedIsAdmin = isAdmin as Mock; // This line is removed.
|
||||
|
||||
// Create a minimal Express app to host our router
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
describe('Admin Routes (/api/admin)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the isAdmin mock to its default implementation before each test.
|
||||
// This prevents mock configurations from one test leaking into another.
|
||||
mockedIsAdmin.mockImplementation((_req: Request, res: Response) => {
|
||||
// The default behavior is to deny access, which is correct for unauthenticated tests.
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should deny access if user is not an admin', async () => {
|
||||
// Arrange: Configure the isAdmin mock to simulate a non-admin user.
|
||||
// It will call next(), but since req.user.role is not 'admin', the real logic
|
||||
// inside the original isAdmin would fail. Here, we just simulate the end result of a 403 Forbidden.
|
||||
mockedIsAdmin.mockImplementation((_req: Request, res: Response) => {
|
||||
res.status(403).json({ message: 'Forbidden: Administrator access required.' });
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.message).toContain('Administrator access required');
|
||||
});
|
||||
|
||||
it('should deny access if no user is authenticated', async () => {
|
||||
// Arrange: The default mock behavior is to deny access, so no specific setup is needed.
|
||||
// Let the default mock implementation run.
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.message).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
describe('when user is an admin', () => {
|
||||
beforeEach(() => {
|
||||
// Arrange: For all tests in this block, simulate a logged-in admin user.
|
||||
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => {
|
||||
// Use the factory to create a mock admin user.
|
||||
req.user = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' });
|
||||
next(); // Grant access
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /corrections should return corrections data', async () => {
|
||||
// Arrange
|
||||
const mockCorrections: SuggestedCorrection[] = [
|
||||
createMockSuggestedCorrection({ suggested_correction_id: 1 }),
|
||||
];
|
||||
mockedDb.getSuggestedCorrections.mockResolvedValue(mockCorrections);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/corrections');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockCorrections);
|
||||
expect(mockedDb.getSuggestedCorrections).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('GET /brands', () => {
|
||||
it('should return a list of all brands on success', async () => {
|
||||
// Arrange
|
||||
const mockBrands: Brand[] = [
|
||||
createMockBrand({ brand_id: 1, name: 'Brand A' }),
|
||||
createMockBrand({ brand_id: 2, name: 'Brand B' }),
|
||||
];
|
||||
mockedDb.getAllBrands.mockResolvedValue(mockBrands);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockBrands);
|
||||
expect(mockedDb.getAllBrands).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getAllBrands.mockRejectedValue(new Error('Failed to fetch brands'));
|
||||
const response = await supertest(app).get('/api/admin/brands');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /stats', () => {
|
||||
it('should return application stats on success', async () => {
|
||||
// Arrange
|
||||
const mockStats = {
|
||||
flyerCount: 150,
|
||||
userCount: 42,
|
||||
flyerItemCount: 10000,
|
||||
storeCount: 12,
|
||||
pendingCorrectionCount: 5,
|
||||
};
|
||||
mockedDb.getApplicationStats.mockResolvedValue(mockStats);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockStats);
|
||||
expect(mockedDb.getApplicationStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getApplicationStats.mockRejectedValue(new Error('Failed to fetch stats'));
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /stats/daily', () => {
|
||||
it('should return daily stats on success', async () => {
|
||||
// Arrange
|
||||
const mockDailyStats = [
|
||||
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
|
||||
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
|
||||
];
|
||||
mockedDb.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDailyStats);
|
||||
expect(mockedDb.getDailyStatsForLast30Days).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getDailyStatsForLast30Days.mockRejectedValue(new Error('Failed to fetch daily stats'));
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /unmatched-items', () => {
|
||||
it('should return a list of unmatched items on success', async () => {
|
||||
// Arrange
|
||||
const mockUnmatchedItems: UnmatchedFlyerItem[] = [
|
||||
{ unmatched_flyer_item_id: 1, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 101, flyer_item_name: 'Ketchup Chips', price_display: '$3.00', flyer_id: 1, store_name: 'Test Store' },
|
||||
{ unmatched_flyer_item_id: 2, status: 'pending', created_at: new Date().toISOString(), flyer_item_id: 102, flyer_item_name: 'Mystery Soda', price_display: '2 for $4.00', flyer_id: 1, store_name: 'Test Store' },
|
||||
];
|
||||
mockedDb.getUnmatchedFlyerItems.mockResolvedValue(mockUnmatchedItems);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUnmatchedItems);
|
||||
expect(mockedDb.getUnmatchedFlyerItems).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getUnmatchedFlyerItems.mockRejectedValue(new Error('Failed to fetch unmatched items'));
|
||||
const response = await supertest(app).get('/api/admin/unmatched-items');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /corrections/:id/approve', () => {
|
||||
it('should approve a correction and return a success message', async () => {
|
||||
// Arrange
|
||||
const correctionId = 123;
|
||||
mockedDb.approveCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction approved successfully.' });
|
||||
expect(mockedDb.approveCorrection).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
// Arrange
|
||||
const correctionId = 456;
|
||||
mockedDb.approveCorrection.mockRejectedValue(new Error('Database failure'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/approve`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should return a 400 error for a non-numeric correction ID', async () => {
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/corrections/abc/approve');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Invalid correction ID provided.');
|
||||
expect(mockedDb.approveCorrection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /corrections/:id/reject', () => {
|
||||
it('should reject a correction and return a success message', async () => {
|
||||
// Arrange
|
||||
const correctionId = 789;
|
||||
mockedDb.rejectCorrection.mockResolvedValue(undefined); // Mock the DB call to succeed
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
|
||||
expect(mockedDb.rejectCorrection).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.rejectCorrection).toHaveBeenCalledWith(correctionId);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
// Arrange
|
||||
const correctionId = 987;
|
||||
mockedDb.rejectCorrection.mockRejectedValue(new Error('Database failure'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/corrections/${correctionId}/reject`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /corrections/:id', () => {
|
||||
it('should update a correction and return the updated data', async () => {
|
||||
// Arrange
|
||||
const correctionId = 101;
|
||||
const requestBody = { suggested_value: 'A new corrected value' };
|
||||
const mockUpdatedCorrection = createMockSuggestedCorrection({ suggested_correction_id: correctionId, ...requestBody });
|
||||
mockedDb.updateSuggestedCorrection.mockResolvedValue(mockUpdatedCorrection);
|
||||
|
||||
// Act: Use .send() to include a request body
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/corrections/${correctionId}`)
|
||||
.send(requestBody);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedCorrection);
|
||||
expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.updateSuggestedCorrection).toHaveBeenCalledWith(correctionId, requestBody.suggested_value);
|
||||
});
|
||||
|
||||
it('should return a 400 error if suggested_value is missing from the body', async () => {
|
||||
// Arrange
|
||||
const correctionId = 102;
|
||||
|
||||
// Act: Send an empty body
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/corrections/${correctionId}`)
|
||||
.send({});
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A new suggested_value is required.');
|
||||
// Ensure the database was not called
|
||||
expect(mockedDb.updateSuggestedCorrection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a 404 error if the correction to update is not found', async () => {
|
||||
// Arrange
|
||||
const correctionId = 999; // A non-existent ID
|
||||
const requestBody = { suggested_value: 'This will fail' };
|
||||
// Mock the DB function to throw a "not found" error, simulating the real DB behavior.
|
||||
mockedDb.updateSuggestedCorrection.mockRejectedValue(new Error(`Correction with ID ${correctionId} not found.`));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/corrections/${correctionId}`)
|
||||
.send(requestBody);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain(`Correction with ID ${correctionId} not found.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /brands/:id/logo', () => {
|
||||
it('should upload a logo and update the brand', async () => {
|
||||
// Arrange
|
||||
const brandId = 55;
|
||||
mockedDb.updateBrandLogo.mockResolvedValue(undefined); // Mock the DB call
|
||||
|
||||
// Create a dummy file for supertest to attach.
|
||||
// supertest needs a real file path to stream from.
|
||||
const dummyFilePath = path.resolve(__dirname, 'test-logo.png');
|
||||
await fs.writeFile(dummyFilePath, 'dummy content');
|
||||
|
||||
// Act: Use .attach() to simulate a file upload.
|
||||
// The first argument is the field name from upload.single('logoImage').
|
||||
const response = await supertest(app)
|
||||
.post(`/api/admin/brands/${brandId}/logo`)
|
||||
.attach('logoImage', dummyFilePath);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Brand logo updated successfully.');
|
||||
expect(response.body.logoUrl).toMatch(/^\/assets\/logoImage-/); // Check for the generated URL format
|
||||
|
||||
// Verify the database was updated with the correct brand ID and a generated URL
|
||||
expect(mockedDb.updateBrandLogo).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
|
||||
|
||||
// Clean up the dummy file
|
||||
await fs.unlink(dummyFilePath);
|
||||
});
|
||||
|
||||
// Add a cleanup hook to remove the file created on the server by multer
|
||||
afterAll(async () => {
|
||||
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
|
||||
try {
|
||||
// Check if directory exists before trying to read it
|
||||
await fs.access(uploadDir).catch(() => null);
|
||||
// If access throws, the dir doesn't exist or isn't accessible, so we skip
|
||||
|
||||
const files = await fs.readdir(uploadDir).catch(() => [] as string[]);
|
||||
const testFiles = files.filter(f => f.startsWith('logoImage-'));
|
||||
for (const file of testFiles) {
|
||||
await fs.unlink(path.join(uploadDir, file)).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a 400 error if no logo image is provided', async () => {
|
||||
// Arrange
|
||||
const brandId = 56;
|
||||
|
||||
// Act: Make the request without attaching a file
|
||||
const response = await supertest(app).post(`/api/admin/brands/${brandId}/logo`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Logo image file is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /recipes/:id/status', () => {
|
||||
it('should update a recipe status and return the updated recipe', async () => {
|
||||
// Arrange
|
||||
const recipeId = 201;
|
||||
const requestBody = { status: 'public' as const };
|
||||
const mockUpdatedRecipe = createMockRecipe({ recipe_id: recipeId, status: 'public' });
|
||||
mockedDb.updateRecipeStatus.mockResolvedValue(mockUpdatedRecipe);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedRecipe);
|
||||
expect(mockedDb.updateRecipeStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.updateRecipeStatus).toHaveBeenCalledWith(recipeId, 'public');
|
||||
});
|
||||
|
||||
it('should return a 400 error for an invalid status value', async () => {
|
||||
// Arrange
|
||||
const recipeId = 202;
|
||||
const requestBody = { status: 'not_a_valid_status' };
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/recipes/${recipeId}/status`)
|
||||
.send(requestBody);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid status (private, pending_review, public, rejected) is required.');
|
||||
expect(mockedDb.updateRecipeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /comments/:id/status', () => {
|
||||
it('should update a comment status and return the updated comment', async () => {
|
||||
// Arrange
|
||||
const commentId = 301;
|
||||
const requestBody = { status: 'hidden' as const };
|
||||
const mockUpdatedComment = createMockRecipeComment({ recipe_comment_id: commentId, status: 'hidden' });
|
||||
mockedDb.updateRecipeCommentStatus.mockResolvedValue(mockUpdatedComment);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.put(`/api/admin/comments/${commentId}/status`)
|
||||
.send(requestBody);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUpdatedComment);
|
||||
expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.updateRecipeCommentStatus).toHaveBeenCalledWith(commentId, 'hidden');
|
||||
});
|
||||
|
||||
it('should return a 400 error for an invalid status value', async () => {
|
||||
const response = await supertest(app).put('/api/admin/comments/302/status').send({ status: 'invalid' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid status (visible, hidden, reported) is required.');
|
||||
expect(mockedDb.updateRecipeCommentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should return a list of all users on success', async () => {
|
||||
// Arrange
|
||||
const mockUsers: (User & { role: 'user' | 'admin', created_at: string, full_name: string | null, avatar_url: string | null })[] = [
|
||||
{ user_id: '1', email: 'user1@test.com', role: 'user' as const, created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
|
||||
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
|
||||
];
|
||||
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUsers);
|
||||
expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getAllUsers.mockRejectedValue(new Error('Failed to fetch users'));
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /activity-log', () => {
|
||||
it('should return a list of activity logs with default pagination', async () => {
|
||||
// Arrange
|
||||
const mockLogs = [createMockActivityLogItem({ action: 'flyer_processed' })];
|
||||
mockedDb.getActivityLog.mockResolvedValue(mockLogs);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockLogs);
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1);
|
||||
// Check that default pagination values were used
|
||||
// This makes the test more robust by verifying the correct parameters were passed.
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0);
|
||||
});
|
||||
|
||||
it('should use limit and offset query parameters when provided', async () => {
|
||||
mockedDb.getActivityLog.mockResolvedValue([]);
|
||||
|
||||
await supertest(app).get('/api/admin/activity-log?limit=10&offset=20');
|
||||
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(10, 20);
|
||||
});
|
||||
|
||||
it('should handle invalid pagination parameters gracefully', async () => {
|
||||
mockedDb.getActivityLog.mockResolvedValue([]);
|
||||
|
||||
// Act: Send non-numeric query parameters
|
||||
await supertest(app).get('/api/admin/activity-log?limit=abc&offset=xyz');
|
||||
|
||||
// Assert: The route should fall back to the default values
|
||||
expect(mockedDb.getActivityLog).toHaveBeenCalledWith(50, 0);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
// Arrange
|
||||
mockedDb.getActivityLog.mockRejectedValue(new Error('DB connection error'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/activity-log');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should fetch a single user successfully', async () => {
|
||||
// Arrange
|
||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
||||
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users/user-123');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent user', async () => {
|
||||
// Arrange
|
||||
mockedDb.findUserProfileById.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users/non-existent-id');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404); // Corrected from 404 to 500 based on error handling in admin.ts
|
||||
expect(response.body.message).toBe('User not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/:id', () => {
|
||||
it('should update a user role successfully', async () => {
|
||||
// Arrange
|
||||
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' };
|
||||
mockedDb.updateUserRole.mockResolvedValue(updatedUser);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/users/user-to-update')
|
||||
.send({ role: 'admin' });
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedUser); // The actual route returns the updated user, not just a message
|
||||
expect(mockedDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin');
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent user', async () => {
|
||||
mockedDb.updateUserRole.mockRejectedValue(new Error('User with ID non-existent not found.'));
|
||||
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid role', async () => {
|
||||
const response = await supertest(app).put('/api/admin/users/any-id').send({ role: 'invalid-role' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid role ("user" or "admin") is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:id', () => {
|
||||
it('should successfully delete a user', async () => {
|
||||
mockedDb.deleteUserById.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
|
||||
});
|
||||
|
||||
it('should prevent an admin from deleting their own account', async () => {
|
||||
// The admin user ID is 'admin-user-id' from the beforeEach hook
|
||||
const response = await supertest(app).delete('/api/admin/users/admin-user-id');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Admins cannot delete their own account.');
|
||||
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should return a list of all users on success', async () => {
|
||||
// Arrange
|
||||
const mockUsers: (User & { role: 'user' | 'admin', created_at: string, full_name: string | null, avatar_url: string | null })[] = [
|
||||
{ user_id: '1', email: 'user1@test.com', role: 'user' as const, created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
|
||||
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
|
||||
];
|
||||
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUsers);
|
||||
expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return a 500 error if the database call fails', async () => {
|
||||
mockedDb.getAllUsers.mockRejectedValue(new Error('Failed to fetch users'));
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should fetch a single user successfully', async () => {
|
||||
// Arrange
|
||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
||||
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users/user-123');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent user', async () => {
|
||||
// Arrange
|
||||
mockedDb.findUserProfileById.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).get('/api/admin/users/non-existent-id');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404); // Corrected from 404 to 500 based on error handling in admin.ts
|
||||
expect(response.body.message).toBe('User not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:id', () => {
|
||||
it('should successfully delete a user', async () => {
|
||||
mockedDb.deleteUserById.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
|
||||
});
|
||||
|
||||
it('should prevent an admin from deleting their own account', async () => {
|
||||
// The admin user ID is 'admin-user-id' from the beforeEach hook
|
||||
const response = await supertest(app).delete('/api/admin/users/admin-user-id');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Admins cannot delete their own account.');
|
||||
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trigger/daily-deal-check', () => {
|
||||
it('should trigger the daily deal check job and return 202 Accepted', async () => {
|
||||
// Arrange
|
||||
// The runDailyDealCheck function is mocked at the top level of the file.
|
||||
// We can simply check if it was called.
|
||||
vi.mocked(runDailyDealCheck).mockImplementation(async () => {}); // It returns Promise<void>
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toBe('Daily deal check job has been triggered successfully. It will run in the background.');
|
||||
expect(runDailyDealCheck).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /trigger/failing-job', () => {
|
||||
it('should enqueue a job designed to fail and return 202 Accepted', async () => {
|
||||
// Arrange
|
||||
const mockJob = { id: 'failing-job-id-456' } as Job;
|
||||
vi.mocked(analyticsQueue.add).mockResolvedValue(mockJob);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain('Failing test job has been enqueued successfully.');
|
||||
// The API might check if mockJob exists before returning jobId, or return it as job_id
|
||||
if (response.body.jobId) {
|
||||
expect(response.body.jobId).toBe(mockJob.id);
|
||||
}
|
||||
expect(analyticsQueue.add).toHaveBeenCalledTimes(1);
|
||||
// Verify it was called with the specific payload that the worker recognizes as a failure trigger.
|
||||
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' });
|
||||
});
|
||||
|
||||
it('should return 500 if the queue service fails to add the job', async () => {
|
||||
// Arrange
|
||||
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/trigger/failing-job');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /flyers/:flyerId/cleanup', () => {
|
||||
it('should enqueue a cleanup job for a valid flyer ID', async () => {
|
||||
// Arrange
|
||||
const flyerId = 789;
|
||||
const mockJob = { id: `cleanup-job-${flyerId}` } as Job;
|
||||
vi.mocked(cleanupQueue.add).mockResolvedValue(mockJob as Job);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toBe(`File cleanup job for flyer ID ${flyerId} has been enqueued.`);
|
||||
expect(cleanupQueue.add).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupQueue.add).toHaveBeenCalledWith('cleanup-flyer-files', { flyerId });
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid flyer ID', async () => {
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/flyers/invalid-id/cleanup');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid flyer ID is required.');
|
||||
expect(cleanupQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /system/clear-geocode-cache', () => {
|
||||
it('should clear the geocode cache and return a success message', async () => {
|
||||
// Arrange
|
||||
const deletedKeysCount = 42;
|
||||
vi.mocked(clearGeocodeCache).mockResolvedValue(deletedKeysCount);
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(`Successfully cleared the geocode cache. ${deletedKeysCount} keys were removed.`);
|
||||
expect(clearGeocodeCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 500 if clearing the cache fails', async () => {
|
||||
// Arrange
|
||||
vi.mocked(clearGeocodeCache).mockRejectedValue(new Error('Redis connection failed'));
|
||||
|
||||
// Act
|
||||
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import { clearGeocodeCache } from '../services/geocodingService.server';
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { runDailyDealCheck } from '../services/backgroundJobService';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue } from '../services/queueService.server'; // Import your queues
|
||||
import type { Queue } from 'bullmq';
|
||||
import { backgroundJobService } from '../services/backgroundJobService.ts';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker } from '../services/queueService.server'; // Import your queues
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -55,41 +56,64 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
// --- Admin Routes ---
|
||||
|
||||
router.get('/corrections', async (req, res) => {
|
||||
router.get('/corrections', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.getSuggestedCorrections();
|
||||
res.json(corrections);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/brands', async (req, res) => {
|
||||
router.get('/brands', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.getAllBrands();
|
||||
res.json(brands);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', async (req, res) => {
|
||||
router.get('/stats', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.getApplicationStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/daily', async (req, res) => {
|
||||
router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.getDailyStatsForLast30Days();
|
||||
res.json(dailyStats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/approve', async (req, res) => {
|
||||
router.post('/corrections/:id/approve', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
// Add validation to ensure the ID is a valid number.
|
||||
if (isNaN(correctionId)) {
|
||||
return res.status(400).json({ message: 'Invalid correction ID provided.' });
|
||||
}
|
||||
|
||||
await db.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
try {
|
||||
await db.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/reject', async (req, res) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
await db.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
await db.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
@@ -110,64 +134,92 @@ router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/recipes/:id/status', async (req, res) => {
|
||||
router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
|
||||
}
|
||||
const updatedRecipe = await db.updateRecipeStatus(recipeId, status);
|
||||
res.status(200).json(updatedRecipe);
|
||||
try {
|
||||
const updatedRecipe = await db.updateRecipeStatus(recipeId, status);
|
||||
res.status(200).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res) => {
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, next: NextFunction) => {
|
||||
const brandId = parseInt(req.params.id, 10);
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.updateBrandLogo(brandId, logoUrl);
|
||||
try {
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.updateBrandLogo(brandId, logoUrl);
|
||||
|
||||
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/unmatched-items', async (req, res) => {
|
||||
router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.getUnmatchedFlyerItems();
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/comments/:id/status', async (req, res) => {
|
||||
router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
const commentId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['visible', 'hidden', 'reported'].includes(status as string)) {
|
||||
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
|
||||
}
|
||||
const updatedComment = await db.updateRecipeCommentStatus(commentId, status);
|
||||
res.status(200).json(updatedComment);
|
||||
try {
|
||||
const updatedComment = await db.updateRecipeCommentStatus(commentId, status);
|
||||
res.status(200).json(updatedComment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', async (req, res) => {
|
||||
router.get('/users', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const users = await db.getAllUsers();
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/activity-log', async (req, res) => {
|
||||
router.get('/activity-log', async (req, res, next: NextFunction) => {
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const logs = await db.getActivityLog(limit, offset);
|
||||
res.json(logs);
|
||||
try {
|
||||
const logs = await db.getActivityLog(limit, offset);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
const user = await db.findUserProfileById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const user = await db.findUserProfileById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
@@ -189,13 +241,17 @@ router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
router.delete('/users/:id', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
if (adminUser.user.user_id === req.params.id) {
|
||||
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
|
||||
}
|
||||
await db.deleteUserById(req.params.id);
|
||||
res.status(204).send();
|
||||
try {
|
||||
await db.deleteUserById(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -208,8 +264,8 @@ router.post('/trigger/daily-deal-check', async (req, res, next: NextFunction) =>
|
||||
|
||||
try {
|
||||
// We call the function but don't wait for it to finish (no `await`).
|
||||
// This allows the API to respond immediately while the job runs in the background.
|
||||
runDailyDealCheck();
|
||||
// This is a "fire-and-forget" operation from the client's perspective.
|
||||
backgroundJobService.runDailyDealCheck();
|
||||
res.status(202).json({ message: 'Daily deal check job has been triggered successfully. It will run in the background.' });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to trigger daily deal check job.', { error });
|
||||
@@ -243,7 +299,7 @@ router.post('/trigger/analytics-report', async (req, res, next: NextFunction) =>
|
||||
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
|
||||
* This is triggered by an admin after they have verified the flyer processing was successful.
|
||||
*/
|
||||
router.post('/flyers/:flyerId/cleanup', async (req, res) => {
|
||||
router.post('/flyers/:flyerId/cleanup', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
const flyerId = parseInt(req.params.flyerId, 10);
|
||||
|
||||
@@ -254,8 +310,12 @@ router.post('/flyers/:flyerId/cleanup', async (req, res) => {
|
||||
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
|
||||
|
||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
|
||||
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
|
||||
try {
|
||||
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
|
||||
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -292,4 +352,84 @@ router.post('/system/clear-geocode-cache', async (req, res, next: NextFunction)
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
|
||||
* This is useful for a system health dashboard to see if any workers have crashed.
|
||||
*/
|
||||
router.get('/workers/status', async (req, res) => {
|
||||
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker];
|
||||
|
||||
const workerStatuses = await Promise.all(
|
||||
workers.map(async (worker) => {
|
||||
return {
|
||||
name: worker.name,
|
||||
isRunning: worker.isRunning(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(workerStatuses);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
|
||||
* This is useful for monitoring the health and backlog of background jobs.
|
||||
*/
|
||||
router.get('/queues/status', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue];
|
||||
|
||||
const queueStatuses = await Promise.all(
|
||||
queues.map(async (queue) => {
|
||||
return {
|
||||
name: queue.name,
|
||||
counts: await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed', 'paused'),
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json(queueStatuses);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
|
||||
*/
|
||||
router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction) => {
|
||||
const { queueName, jobId } = req.params;
|
||||
const adminUser = req.user as UserProfile;
|
||||
|
||||
const queueMap: { [key: string]: Queue } = {
|
||||
'flyer-processing': flyerQueue,
|
||||
'email-sending': emailQueue,
|
||||
'analytics-reporting': analyticsQueue,
|
||||
'file-cleanup': cleanupQueue,
|
||||
};
|
||||
|
||||
const queue = queueMap[queueName];
|
||||
|
||||
if (!queue) {
|
||||
return res.status(404).json({ message: `Queue '${queueName}' not found.` });
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.getJob(jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ message: `Job with ID '${jobId}' not found in queue '${queueName}'.` });
|
||||
}
|
||||
|
||||
const jobState = await job.getState();
|
||||
if (jobState !== 'failed') {
|
||||
return res.status(400).json({ message: `Job is not in a 'failed' state. Current state: ${jobState}.` });
|
||||
}
|
||||
|
||||
await job.retry();
|
||||
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
98
src/routes/admin.stats.routes.test.ts
Normal file
98
src/routes/admin.stats.routes.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/routes/admin.stats.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { UserProfile } from '../types';
|
||||
|
||||
// Mock the specific DB modules used
|
||||
vi.mock('../services/db/admin.db', () => ({
|
||||
getApplicationStats: vi.fn(),
|
||||
getDailyStatsForLast30Days: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock('../services/db/flyer.db');
|
||||
vi.mock('../services/db/recipe.db');
|
||||
vi.mock('../services/db/user.db');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../services/backgroundJobService');
|
||||
vi.mock('../services/geocodingService.server');
|
||||
vi.mock('../services/queueService.server');
|
||||
vi.mock('@bull-board/api');
|
||||
vi.mock('@bull-board/api/bullMQAdapter');
|
||||
vi.mock('@bull-board/express');
|
||||
|
||||
// Import the mocked modules to control them
|
||||
import * as adminDb from '../services/db/admin.db';
|
||||
const mockedDb = adminDb as Mocked<typeof adminDb>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
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.' });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to create a test app instance.
|
||||
const createApp = (user?: UserProfile) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
if (user) {
|
||||
app.use((req, res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('Admin Stats Routes (/api/admin/stats)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin' });
|
||||
const app = createApp(adminUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /stats', () => {
|
||||
it('should return application stats on success', async () => {
|
||||
const mockStats = { flyerCount: 150, userCount: 42, flyerItemCount: 10000, storeCount: 12, pendingCorrectionCount: 5 };
|
||||
mockedDb.getApplicationStats.mockResolvedValue(mockStats);
|
||||
const response = await supertest(app).get('/api/admin/stats');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /stats/daily', () => {
|
||||
it('should return daily stats on success', async () => {
|
||||
const mockDailyStats = [
|
||||
{ date: '2024-01-01', new_users: 5, new_flyers: 10 },
|
||||
{ date: '2024-01-02', new_users: 3, new_flyers: 8 },
|
||||
];
|
||||
mockedDb.getDailyStatsForLast30Days.mockResolvedValue(mockDailyStats);
|
||||
const response = await supertest(app).get('/api/admin/stats/daily');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockDailyStats);
|
||||
});
|
||||
});
|
||||
});
|
||||
152
src/routes/admin.users.routes.test.ts
Normal file
152
src/routes/admin.users.routes.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// src/routes/admin.users.routes.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import supertest from 'supertest';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import adminRouter from './admin.routes';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { User, UserProfile } from '../types';
|
||||
|
||||
// Mock the specific DB modules used by these routes
|
||||
vi.mock('../services/db/admin.db', () => ({
|
||||
getAllUsers: vi.fn(),
|
||||
updateUserRole: vi.fn(),
|
||||
}));
|
||||
vi.mock('../services/db/user.db', () => ({
|
||||
findUserProfileById: vi.fn(),
|
||||
deleteUserById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock other dependencies that are not directly tested but are part of the adminRouter setup
|
||||
vi.mock('../services/db/flyer.db');
|
||||
vi.mock('../services/db/recipe.db');
|
||||
vi.mock('../services/backgroundJobService');
|
||||
vi.mock('../services/geocodingService.server');
|
||||
vi.mock('../services/queueService.server');
|
||||
vi.mock('@bull-board/api');
|
||||
vi.mock('@bull-board/api/bullMQAdapter');
|
||||
vi.mock('@bull-board/express');
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
// Import the mocked modules to control them in tests.
|
||||
import * as adminDb from '../services/db/admin.db';
|
||||
import * as userDb from '../services/db/user.db';
|
||||
const mockedDb = { ...adminDb, ...userDb } as Mocked<typeof adminDb & typeof userDb>;
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger.server', () => ({
|
||||
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the passport middleware
|
||||
vi.mock('./passport.routes', () => ({
|
||||
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.' });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to create a test app instance.
|
||||
const createApp = (user?: UserProfile) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
if (user) {
|
||||
app.use((req, res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({ message: err.message || 'Internal Server Error' });
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('Admin User Management Routes (/api/admin/users)', () => {
|
||||
const adminUser = createMockUserProfile({ role: 'admin', user_id: 'admin-user-id' });
|
||||
const app = createApp(adminUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should return a list of all users on success', async () => {
|
||||
const mockUsers: any[] = [
|
||||
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
|
||||
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
|
||||
];
|
||||
mockedDb.getAllUsers.mockResolvedValue(mockUsers);
|
||||
const response = await supertest(app).get('/api/admin/users');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUsers);
|
||||
expect(mockedDb.getAllUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should fetch a single user successfully', async () => {
|
||||
const mockUser = createMockUserProfile({ user_id: 'user-123' });
|
||||
mockedDb.findUserProfileById.mockResolvedValue(mockUser);
|
||||
const response = await supertest(app).get('/api/admin/users/user-123');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(mockUser);
|
||||
expect(mockedDb.findUserProfileById).toHaveBeenCalledWith('user-123');
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent user', async () => {
|
||||
mockedDb.findUserProfileById.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).get('/api/admin/users/non-existent-id');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('User not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/:id', () => {
|
||||
it('should update a user role successfully', async () => {
|
||||
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' };
|
||||
mockedDb.updateUserRole.mockResolvedValue(updatedUser);
|
||||
const response = await supertest(app)
|
||||
.put('/api/admin/users/user-to-update')
|
||||
.send({ role: 'admin' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(updatedUser);
|
||||
expect(mockedDb.updateUserRole).toHaveBeenCalledWith('user-to-update', 'admin');
|
||||
});
|
||||
|
||||
it('should return 404 for a non-existent user', async () => {
|
||||
mockedDb.updateUserRole.mockRejectedValue(new Error('User with ID non-existent not found.'));
|
||||
const response = await supertest(app).put('/api/admin/users/non-existent').send({ role: 'user' });
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 400 for an invalid role', async () => {
|
||||
const response = await supertest(app).put('/api/admin/users/any-id').send({ role: 'invalid-role' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('A valid role ("user" or "admin") is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:id', () => {
|
||||
it('should successfully delete a user', async () => {
|
||||
mockedDb.deleteUserById.mockResolvedValue(undefined);
|
||||
const response = await supertest(app).delete('/api/admin/users/user-to-delete');
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockedDb.deleteUserById).toHaveBeenCalledWith('user-to-delete');
|
||||
});
|
||||
|
||||
it('should prevent an admin from deleting their own account', async () => {
|
||||
const response = await supertest(app).delete(`/api/admin/users/${adminUser.user_id}`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe('Admins cannot delete their own account.');
|
||||
expect(mockedDb.deleteUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user