diff --git a/gatewayservice/src/controllers/history-controller.ts b/gatewayservice/src/controllers/history-controller.ts index 2c04e56a..ae58fa84 100644 --- a/gatewayservice/src/controllers/history-controller.ts +++ b/gatewayservice/src/controllers/history-controller.ts @@ -14,6 +14,18 @@ const getHistory = async (req: Request, res: Response, next: NextFunction) => { } }; +const getLeaderboard = async (req: Request, res: Response, next: NextFunction) => { + try { + const authResponse = await axios.get(HISTORY_SERVICE_URL + '/history/leaderboard', { + params: req.query, + }); + + res.json(authResponse.data); + } catch (error: any) { + next(error); + } +}; + const updateHistory = async ( req: Request, res: Response, @@ -58,4 +70,4 @@ const incrementHistory = async ( } }; -export { getHistory, updateHistory, incrementHistory }; +export { getHistory, updateHistory, incrementHistory, getLeaderboard }; diff --git a/gatewayservice/src/routes/history-routes.ts b/gatewayservice/src/routes/history-routes.ts index 016fb7bb..810ae56a 100644 --- a/gatewayservice/src/routes/history-routes.ts +++ b/gatewayservice/src/routes/history-routes.ts @@ -3,12 +3,14 @@ import { getHistory, updateHistory, incrementHistory, + getLeaderboard } from '../controllers/history-controller'; const router = express.Router(); router.get('/history', getHistory); router.post('/history', updateHistory); +router.get('/history/leaderboard', getLeaderboard); router.post('/history/increment', incrementHistory); export default router; diff --git a/users/userservice/src/controllers/history-controller.ts b/users/userservice/src/controllers/history-controller.ts index cd554969..da03ebc0 100644 --- a/users/userservice/src/controllers/history-controller.ts +++ b/users/userservice/src/controllers/history-controller.ts @@ -34,21 +34,51 @@ const getHistory = async (req: Request, res: Response) => { } }; +const DEFAULT_LEADERBOARD_SIZE = 10; + +const getLeaderboard = async (req: Request, res: Response) => { + try { + const sizeParam = req.query.size; + let size = DEFAULT_LEADERBOARD_SIZE; // Default size if no parameter is received + if (sizeParam) { + size = parseInt(sizeParam as string, 10); + if (size <= 0) { + throw new Error('The size must be a positive value.'); + } + } + + const leaderboard = await User.find({}) + .sort({ 'history.points': -1 }) // Sort in descending order of points + .limit(size) // Only take the first (size) users + .select('username history'); // Select only username and history (no password, date, etc.) + + res.json({ + status: 'success', + data: { + leaderboard + }, + }); + } catch (error: any) { + res.status(400).json({ + status: 'fail', + data: { + error: error.message, + }, + }); + } +}; + const updateHistory = async (req: Request, res: Response) => { try { const user = req.user; - if (!user) { - throw new Error('Unknown error. User does not appear.'); - } - validateRequiredFields(req, ['history']); - validateHistoryBody(req, user); + validateHistoryBody(req, user!); - user.history = { ...user.history, ...req.body.history }; - await user.save(); + user!.history = { ...user!.history, ...req.body.history }; + await user!.save(); - res.json({ status: 'success', data: user.history }); + res.json({ status: 'success', data: user!.history }); } catch (error) { res.status(400).json({ status: 'fail', @@ -63,20 +93,16 @@ const incrementHistory = async (req: Request, res: Response) => { try { const user = req.user; - if (!user) { - throw new Error('Unknown error. User does not appear.'); - } - validateRequiredFields(req, ['history']); - validateHistoryBody(req, user); + validateHistoryBody(req, user!); Object.keys(req.body.history).forEach(key => { - (user.history as any)[key] += req.body.history[key]; + (user!.history as any)[key] += req.body.history[key]; }); - await user.save(); + await user!.save(); - res.json({ status: 'success', data: user.history }); + res.json({ status: 'success', data: user!.history }); } catch (error) { res.status(400).json({ status: 'fail', @@ -87,4 +113,4 @@ const incrementHistory = async (req: Request, res: Response) => { } }; -export { getHistory, updateHistory, incrementHistory }; +export { getHistory, updateHistory, incrementHistory, getLeaderboard }; diff --git a/users/userservice/src/middlewares/protect-middleware.ts b/users/userservice/src/middlewares/protect-middleware.ts index a0e394e7..aa5dc22c 100644 --- a/users/userservice/src/middlewares/protect-middleware.ts +++ b/users/userservice/src/middlewares/protect-middleware.ts @@ -15,11 +15,7 @@ const protect = async (req: Request, res: Response, next: NextFunction) => { const { userId } = decoded; const user = await User.findById(userId); - if (user === null) { - throw new Error("User does not exist. Log in again.'"); - } - - req.user = user; + req.user = user!; next(); } catch (error: any) { res.status(400).json({ diff --git a/users/userservice/src/routes/history-routes.ts b/users/userservice/src/routes/history-routes.ts index 72fe77cf..1bad7fa0 100644 --- a/users/userservice/src/routes/history-routes.ts +++ b/users/userservice/src/routes/history-routes.ts @@ -2,7 +2,7 @@ import express from 'express'; import { getHistory, updateHistory, - incrementHistory, + incrementHistory, getLeaderboard, } from '../controllers/history-controller'; import { protect } from '../middlewares/protect-middleware'; @@ -10,6 +10,7 @@ const router = express.Router(); router.get('/history', getHistory); router.post('/history', protect, updateHistory); +router.get('/history/leaderboard', getLeaderboard); router.post('/history/increment', protect, incrementHistory); export default router; diff --git a/users/userservice/test/user-service.test.ts b/users/userservice/test/user-service.test.ts index 5d14ab39..10706d3d 100644 --- a/users/userservice/test/user-service.test.ts +++ b/users/userservice/test/user-service.test.ts @@ -5,6 +5,10 @@ const request = require('supertest'); import mongoose from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; import app from '../src/app'; +import {validateHistoryBody} from "../src/utils/history-body-validation"; +import { Request } from 'express'; +import {validateNotEmpty, validateRequiredLength} from "../src/utils/field-validations"; +import {verifyJWT} from "../src/utils/async-verification"; let mongoServer: MongoMemoryServer; @@ -194,4 +198,176 @@ describe('User Service', () => { expect(getResponse.statusCode).toBe(200); expect(JSON.stringify(getResponse.body.data.history)).toMatch(JSON.stringify(expectedHistory.history)); }); + + // GET /history/leaderboard with size param + it('should obtain n users with the highest scores', async () => { + const newUserData = { + username: 'highestscoreuser', + password: 'testpassword', + }; + // Add a new user to test leaderboard + await request(app).post('/adduser').send(newUserData); + + // The new user will have 100000 points + const increment = { + history: { + points: 100000, + }, + }; + const newUser = await User.findOne({ username:'highestscoreuser' }); + // Generates a temporary token for this test + const testToken2 = jwt.sign({ userId: newUser!._id }, 'your-secret-key', { + expiresIn: '2m', + }); + + await request(app) + .post('/history/increment') + .send(increment) + .set('Authorization', `Bearer ${testToken2}`); + + // The old user will have 99999 points + const newHistory = { + history: { + points: 99999, + }, + }; + await request(app) + .post('/history') + .send(newHistory) + .set('Authorization', `Bearer ${testToken}`); + + const response = await request(app) + .get('/history/leaderboard?size=2'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.leaderboard.length).toBe(2); + expect(response.body.data.leaderboard[0].username).toEqual('highestscoreuser'); + expect(response.body.data.leaderboard[0].history.points).toBe(100000); + expect(response.body.data.leaderboard[1].username).toEqual('testuser'); + expect(response.body.data.leaderboard[1].history.points).toBe(99999); + }); + + // GET /history/leaderboard without param + it('should obtain users with the highest scores', async () => { + // If a request is made without the parameter it will return an amount of users + // specified by a constant in the controller (DEFAULT_LEADERBOARD_SIZE) + const response = await request(app) + .get('/history/leaderboard'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.leaderboard).not.toBeUndefined(); + }); + + // GET /history/leaderboard negative param + it('should obtain users with the highest scores', async () => { + // If a request is made with a negative size, it will throw an exception + const response = await request(app) + .get('/history/leaderboard?size=-1'); + + expect(response.statusCode).toBe(400); + expect(response.body.data.leaderboard).toBeUndefined(); + }); + + // Body validation util, not part of the user history + it('should get an error when including a parameter that is not in the model', async () => { + const mockRequest = { + body: { + history: { + nonexistent: 1 + } + } + } as Request; + const user = await User.find({ username:'testuser' }); + + try { + validateHistoryBody(mockRequest, user[0]); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); + + // Body validation util, non-numeric + it('should get an error when using non-numerical values', async () => { + const mockRequest = { + body: { + history: { + gamesPlayed: 'test' + } + } + } as Request; + const user = await User.find({ username:'testuser' }); + + try { + validateHistoryBody(mockRequest, user[0]); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); + + // Body validation util, negative + it('should get an error when using negative values', async () => { + const mockRequest = { + body: { + history: { + gamesPlayed: -1 + } + } + } as Request; + const user = await User.find({ username:'testuser' }); + + try { + validateHistoryBody(mockRequest, user[0]); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); + + // Empty field validation + it('should get an error when passing an empty parameter', async () => { + const mockRequest = { + body: {} + } as Request; + mockRequest.body['history'] = ''; + + try { + validateNotEmpty(mockRequest, ['history']); + fail('Should get an error in the previous call'); + } catch (error) { + } + // Should also get an error when the field does not exist + try { + validateNotEmpty(mockRequest, ['nonexistent']); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); + + // Empty field validation + it('should get an error when passing a parameter without the expected length', async () => { + const mockRequest = { + body: {} + } as Request; + mockRequest.body['test'] = '123456789'; + + try { + validateRequiredLength(mockRequest, ['test'], 10); + fail('Should get an error in the previous call'); + } catch (error) { + } + // Should also get an error when the field does not exist + try { + validateRequiredLength(mockRequest, ['nonexistent'], 10); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); + + // Token validator + it('should get an error when invoking the function with an invalid token', async () => { + try { + await verifyJWT('invalidtoken'); + fail('Should get an error in the previous call'); + } catch (error) { + } + }); });