340 lines
13 KiB
TypeScript
340 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', 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('./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);
|
|
});
|
|
});
|
|
});
|