// 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 '../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', async () => ({ // Use async import to avoid hoisting issues with mockLogger logger: (await import('../tests/utils/mockLogger')).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('../config/passport', () => ({ 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.data).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.error.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.error.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.data).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.error.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.data.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.error.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.error.details).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.error.details[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.error.details[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.error.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.data).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.data).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.error.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.error.details).toBeDefined(); expect(response.body.error.details[0].message).toMatch(/less than or equal to 50|Too big/i); }); }); describe('Rate Limiting', () => { it('should apply publicReadLimiter to GET /', async () => { vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue([]); const response = await supertest(unauthenticatedApp) .get('/api/achievements') .set('X-Test-Rate-Limit-Enable', 'true'); expect(response.status).toBe(200); expect(response.headers).toHaveProperty('ratelimit-limit'); expect(parseInt(response.headers['ratelimit-limit'])).toBe(100); }); it('should apply userReadLimiter to GET /me', async () => { mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockUserProfile; next(); }); vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue([]); const response = await supertest(authenticatedApp) .get('/api/achievements/me') .set('X-Test-Rate-Limit-Enable', 'true'); expect(response.status).toBe(200); expect(response.headers).toHaveProperty('ratelimit-limit'); expect(parseInt(response.headers['ratelimit-limit'])).toBe(100); }); it('should apply adminTriggerLimiter to POST /award', 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).mockResolvedValue(undefined); const response = await supertest(adminApp) .post('/api/achievements/award') .set('X-Test-Rate-Limit-Enable', 'true') .send({ userId: 'some-user', achievementName: 'some-achievement' }); expect(response.status).toBe(200); expect(response.headers).toHaveProperty('ratelimit-limit'); expect(parseInt(response.headers['ratelimit-limit'])).toBe(30); }); }); });