Files
flyer-crawler.projectium.com/src/routes/gamification.routes.test.ts

339 lines
13 KiB
TypeScript

// src/routes/gamification.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import {
createMockUserProfile,
createMockAchievement,
createMockUserAchievement,
createMockLeaderboardUser,
} from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
// Mock the entire db service
vi.mock('../services/db/index.db', () => ({
gamificationRepo: {
getAllAchievements: vi.fn(),
getUserAchievements: vi.fn(),
awardAchievement: vi.fn(),
getLeaderboard: vi.fn(),
},
}));
// Import the router and mocked DB AFTER all mocks are defined.
import gamificationRouter from './gamification.routes';
import * as db from '../services/db/index.db';
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: mockLogger,
}));
// Use vi.hoisted to create mutable mock function references.
const mockedAuthMiddleware = vi.hoisted(() =>
vi.fn((req: Request, res: Response, next: NextFunction) => next()),
);
const mockedIsAdmin = vi.hoisted(() => vi.fn());
vi.mock('./passport.routes', () => ({
default: {
// The authenticate method will now call our hoisted mock middleware.
authenticate: vi.fn(() => mockedAuthMiddleware),
},
// Mock the named export 'isAdmin'
isAdmin: mockedIsAdmin,
}));
// Define a reusable matcher for the logger object.
const expectLogger = expect.objectContaining({
info: expect.any(Function),
error: expect.any(Function),
});
describe('Gamification Routes (/api/achievements)', () => {
const mockUserProfile = createMockUserProfile({
user: { user_id: 'user-123', email: 'user@test.com' },
points: 100,
});
const mockAdminProfile = createMockUserProfile({
user: { user_id: 'admin-456', email: 'admin@test.com' },
role: 'admin',
points: 999,
});
beforeEach(() => {
vi.clearAllMocks();
// Default mock for authentication to simulate an unauthenticated user.
mockedAuthMiddleware.mockImplementation((req: Request, res: Response) => {
res.status(401).json({ message: 'Unauthorized' });
});
mockedIsAdmin.mockImplementation((req: Request, res: Response) => {
res.status(403).json({ message: 'Forbidden' });
});
});
const basePath = '/api/achievements';
const unauthenticatedApp = createTestApp({ router: gamificationRouter, basePath });
const authenticatedApp = createTestApp({
router: gamificationRouter,
basePath,
authenticatedUser: mockUserProfile,
});
const adminApp = createTestApp({
router: gamificationRouter,
basePath,
authenticatedUser: mockAdminProfile,
});
describe('GET /', () => {
it('should return a list of all achievements (public endpoint)', async () => {
const mockAchievements = [
createMockAchievement({ achievement_id: 1 }),
createMockAchievement({ achievement_id: 2 }),
];
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue(mockAchievements);
const response = await supertest(unauthenticatedApp).get('/api/achievements');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAchievements);
expect(db.gamificationRepo.getAllAchievements).toHaveBeenCalledWith(expectLogger);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Connection Failed');
vi.mocked(db.gamificationRepo.getAllAchievements).mockRejectedValue(dbError);
const response = await supertest(unauthenticatedApp).get('/api/achievements');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Connection Failed');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
});
describe('GET /me', () => {
it('should return 401 Unauthorized when user is not authenticated', async () => {
const response = await supertest(unauthenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(401);
});
it('should return achievements for the authenticated user', async () => {
// Mock authentication to simulate a logged-in user.
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
const mockUserAchievements = [
createMockUserAchievement({ achievement_id: 1, user_id: 'user-123' }),
];
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue(mockUserAchievements);
const response = await supertest(authenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUserAchievements);
expect(db.gamificationRepo.getUserAchievements).toHaveBeenCalledWith(
'user-123',
expectLogger,
);
});
it('should return 500 if the database call fails', async () => {
// Mock an authenticated user
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
const dbError = new Error('DB Error');
vi.mocked(db.gamificationRepo.getUserAchievements).mockRejectedValue(dbError);
const response = await supertest(authenticatedApp).get('/api/achievements/me');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
});
describe('POST /award', () => {
const awardPayload = { userId: 'user-789', achievementName: 'Test Award' };
it('should return 401 Unauthorized if user is not authenticated', async () => {
const response = await supertest(unauthenticatedApp)
.post('/api/achievements/award')
.send(awardPayload);
expect(response.status).toBe(401);
});
it('should return 403 Forbidden if authenticated user is not an admin', async () => {
// Mock a regular authenticated user
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
// Let the default isAdmin mock (set in beforeEach) run, which denies access
const response = await supertest(authenticatedApp)
.post('/api/achievements/award')
.send(awardPayload);
expect(response.status).toBe(403);
});
it('should successfully award an achievement when user is an admin', async () => {
// Mock an authenticated admin user
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next()); // Grant admin access
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(200);
expect(response.body.message).toContain('Successfully awarded');
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledTimes(1);
expect(db.gamificationRepo.awardAchievement).toHaveBeenCalledWith(
awardPayload.userId,
awardPayload.achievementName,
expectLogger,
);
});
it('should return 500 if the database call fails', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(new Error('DB Error'));
const response = await supertest(adminApp).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid userId or achievementName', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: '', achievementName: '' });
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
});
it('should return 400 if userId or achievementName are missing', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response1 = await supertest(adminApp)
.post('/api/achievements/award')
.send({ achievementName: 'Test Award' });
expect(response1.status).toBe(400);
expect(response1.body.errors[0].message).toBe('userId is required.');
const response2 = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'user-789' });
expect(response2.status).toBe(400);
expect(response2.body.errors[0].message).toBe('achievementName is required.');
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockRejectedValue(
new ForeignKeyConstraintError('User not found'),
);
const response = await supertest(adminApp)
.post('/api/achievements/award')
.send({ userId: 'non-existent', achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('User not found');
});
});
describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [
createMockLeaderboardUser({
user_id: 'user-1',
full_name: 'Leader',
points: 1000,
rank: '1',
}),
];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get(
'/api/achievements/leaderboard?limit=5',
);
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(5, expect.anything());
});
it('should use the default limit of 10 when no limit is provided', async () => {
const mockLeaderboard = [
createMockLeaderboardUser({
user_id: 'user-1',
full_name: 'Leader',
points: 1000,
rank: '1',
}),
];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(db.gamificationRepo.getLeaderboard).toHaveBeenCalledWith(10, expect.anything());
});
it('should return 500 if the database call fails', async () => {
vi.mocked(db.gamificationRepo.getLeaderboard).mockRejectedValue(new Error('DB Error'));
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard');
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid limit parameter', async () => {
const response = await supertest(unauthenticatedApp).get(
'/api/achievements/leaderboard?limit=100',
);
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
});
});
});