Files
flyer-crawler.projectium.com/src/routes/admin.routes.test.ts
Torben Sorensen cea3586984
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 4m24s
more db unit tests - best o luck !
2025-12-06 22:58:37 -08:00

860 lines
35 KiB
TypeScript

// 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);
});
});
});
});