diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74f4a99e..cef2a143 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,8 @@ jobs: - uses: actions/checkout@v4 - name: Publish to Registry uses: elgohr/Publish-Docker-Github-Action@v5 + env: + JWT_KEY: ${{secrets.JWT_KEY}} with: name: arquisoft/wiq_en1b/authservice username: ${{ github.actor }} diff --git a/README.md b/README.md index 5575e13d..3d262ac9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ docker compose --profile dev up --build ``` ### Deployed in Cloud -In order to view the application deploy in the cloud click [here](http://172.203.216.60:3000) +In order to view the application deploy in the cloud click [here](http://wiqen1b.serveminecraft.net:3000) ### Members - Lucía Ruiz Núñez uo289267@uniovi.es diff --git a/docker-compose.yml b/docker-compose.yml index 26949792..89ffbe46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,7 +113,7 @@ prometheus: image: prom/prometheus container_name: prometheus-${teamname:-defaultASW} - profiles: ["dev"] + profiles: ["dev", "prod"] networks: - mynetwork volumes: @@ -127,7 +127,7 @@ grafana: image: grafana/grafana container_name: grafana-${teamname:-defaultASW} - profiles: ["dev"] + profiles: ["dev", "prod"] networks: - mynetwork volumes: diff --git a/gatewayservice/gateway-service.js b/gatewayservice/gateway-service.js index 009db707..c08d0e37 100644 --- a/gatewayservice/gateway-service.js +++ b/gatewayservice/gateway-service.js @@ -6,7 +6,7 @@ const promBundle = require('express-prom-bundle'); const swaggerUi = require('swagger-ui-express'); const fs = require("fs") const YAML = require('yaml') - +const jwt = require('jsonwebtoken'); const app = express(); const port = 8000; @@ -33,7 +33,7 @@ app.post('/login', async (req, res) => { const authResponse = await axios.post(authServiceUrl+'/login', req.body); res.json(authResponse.data); } catch (error) { - res.status(error.response.status).json({ error: error.response.data.error }); + manageError(error) } }); @@ -43,52 +43,130 @@ app.post('/adduser', async (req, res) => { const userResponse = await axios.post(userServiceUrl+'/adduser', req.body); res.json(userResponse.data); } catch (error) { - res.status(error.response.status).json({ error: error.response.data.error }); + manageError(error); + } }); -app.get('/questions', async (req, res) => { +app.get('/questions', verifyToken, async (req, res) => { try { // Forward the question request to the quetion service const questionResponse = await axios.get(questionServiceUrl+'/questions'); res.json(questionResponse.data); } catch (error) { - res.status(error.response.status).json({ error: error.response.data.error }); + manageError(error) } }); -app.get('/questions/:lang', async (req, res) => { + + +app.get('/questions/:lang/:amount/:type', verifyToken, async (req, res) => { try { - const lang = req.params.lang; - // Forward the question request to the quetion service - const questionResponse = await axios.get(questionServiceUrl+'/questions/' + lang); + if(!validateLang(req.params.lang.toString()) || + !validateAmount(req.params.amount.toString()) || + !validateType(req.params.type.toString())) + res.status(400).json({ error: 'Wrong values given' }); + else { + const lang = encodeURIComponent(req.params.lang.toString()); + const amount = encodeURIComponent(req.params.amount.toString()); + const type = encodeURIComponent(req.params.type.toString()); + // Forward the question request to the quetion service + const questionResponse = await axios.get(questionServiceUrl+'/questions/' + lang + '/' + amount + '/' + type); + + res.json(questionResponse.data); + } + } catch (error) { - res.json(questionResponse.data); + manageError(error) + } +}); + + +app.get('/questions/:lang/:amount', verifyToken, async (req, res) => { + try { + if(!validateLang(req.params.lang.toString()) || + !validateAmount(req.params.amount.toString())) + res.status(400).json({ error: 'Wrong values given' }); + else{ + const lang = encodeURIComponent(req.params.lang.toString()); + const amount = encodeURIComponent(req.params.amount.toString()); + // Forward the question request to the quetion service + const questionResponse = await axios.get(questionServiceUrl+'/questions/' + lang + '/' + amount); + + res.json(questionResponse.data); + } } catch (error) { + manageError(error) + } +}); - res.status(error.response.status).json({ error: error.response.data.error }); +app.get('/questions/:lang', verifyToken, async (req, res) => { + try { + if(!validateLang(req.params.lang.toString())) + res.status(400).json({ error: 'Wrong values given' }); + else{ + const lang = encodeURIComponent(req.params.lang.toString()); + // Forward the question request to the quetion service + const questionResponse = await axios.get(questionServiceUrl+'/questions/' + lang.toString()); + + res.json(questionResponse.data); + } + + } catch (error) { + + manageError(error) } }); -app.post('/record', async(req, res) => { +app.post('/record', verifyToken, async(req, res) => { + try { // Forward the record request to the record service const recordResponse = await axios.post(recordServiceUrl+'/record', req.body); res.json(recordResponse.data); } catch (error) { - res.send(error); + manageError(error) } }); -app.get('/record/:user', async(req, res)=>{ +app.get('/record/ranking/top10', verifyToken, async(req, res)=>{ try { - const user = req.params.user; // Forward the record request to the record service - const recordResponse = await axios.get(recordServiceUrl + '/record/' + user); + const recordResponse = await axios.get(recordServiceUrl + '/record/ranking/top10'); res.json(recordResponse.data); } catch (error) { - res.send(error); + manageError(error) + } +}); + +app.get('/record/ranking/:user', verifyToken, async(req, res)=>{ + try { + if(!validateUser(req.params.user.toString())) + res.status(400).json({ error: 'Wrong values given' }); + else{ + const user = encodeURIComponent(req.params.user.toString()); + // Forward the record request to the record service + const recordResponse = await axios.get(recordServiceUrl + '/record/ranking/' + user); + res.json(recordResponse.data); + } + } catch (error) { + manageError(error) + } +}); + +app.get('/record/:user', verifyToken, async(req, res)=>{ + try { + if(!validateUser(req.params.user.toString())) + res.status(400).json({ error: 'Wrong values given' }); + else{ + const user = encodeURIComponent(req.params.user.toString()); + // Forward the record request to the record service + const recordResponse = await axios.get(recordServiceUrl + '/record/' + user); + res.json(recordResponse.data); + } + } catch (error) { + manageError(error) } }); @@ -108,4 +186,48 @@ const server = app.listen(port, () => { console.log(`Gateway Service listening at http://localhost:${port}`); }); +function verifyToken(req, res, next) { + // Get the token from the request headers + const token = req.headers['token'] || req.body.token || req.query.token; + + // Verify if the token is valid + jwt.verify(token, (process.env.JWT_KEY??'my-key'), (err, decoded) => { + if (err) { + // Token is not valid + res.status(403).json({authorized: false, + error: 'Invalid token or outdated'}); + } else { + // Token is valid + req.decodedToken = decoded; + // Call next() to proceed to the next middleware or route handler + next(); + } + }); +} + +function validateLang(lang){ + return ['en', 'es', 'tk'].includes(lang); +} + +function validateAmount(amount) { + const parsed = parseInt(amount, 10); + // We only accept integers and positive ones + return !isNaN(parsed) && parsed > 0; +} + +function validateType(type){ + return ['POPULATION', 'CAPITAL', 'LANGUAGE', 'SIZE'].includes(type); +} + +function validateUser(user){ + return !(/\s/.test(user)) //True if there are no spaces +} + +function manageError(error){ + if(error.response) //Some microservice responded with an error + res.status(error.response.status).json({ error: error.response.data.error }); + else //Some other error + res.status(500).json({error : "Interanl server error"}) +} + module.exports = server diff --git a/gatewayservice/gateway-service.test.js b/gatewayservice/gateway-service.test.js index bfed664f..550c4024 100644 --- a/gatewayservice/gateway-service.test.js +++ b/gatewayservice/gateway-service.test.js @@ -1,14 +1,21 @@ const request = require('supertest'); const axios = require('axios'); +const jwt = require('jsonwebtoken'); const app = require('./gateway-service'); afterAll(async () => { app.close(); }); + +jest.mock('jsonwebtoken'); + jest.mock('axios'); -describe('Gateway Service', () => { + + +describe('Gateway Service with token mock', () => { + // Mock responses from external services axios.post.mockImplementation((url, data) => { if (url.endsWith('/login')) { @@ -20,19 +27,39 @@ describe('Gateway Service', () => { } }); + const question = { data: [{question: "¿Cuál es la población de Oviedo?", + answers: ["225089","272357","267855","231841"]}] }; + + //Dont need to check a good record just that it redirects the call + const record = {data : {record:'undefined'}}; + axios.get.mockImplementation((url, data) => { if (url.endsWith('/questions')){ - return Promise.resolve({ data: [{question: "¿Cuál es la población de Oviedo?", - answers: ["225089","272357","267855","231841"]}] }); + return Promise.resolve(question); + } else if (url.endsWith('/questions/es/1/CAPITAL')){ + return Promise.resolve(question); + } else if (url.endsWith('/questions/es/1')){ + return Promise.resolve(question); } else if (url.endsWith('/questions/es')){ - return Promise.resolve({ data: [{question: "¿Cuál es la población de Oviedo?", - answers: ["225089","272357","267855","231841"]}] }); + return Promise.resolve(question); + } else if(url.endsWith('/record/testuser')){ - //Dont need to check a good record just that it redirects the call - return Promise.resolve({data : {record:'undefined'}}) + return Promise.resolve(record) + } else if(url.endsWith('/record/ranking/top10')){ + return Promise.resolve(record) + } else if(url.endsWith('/record/ranking/testuser')){ + return Promise.resolve(record) } }); + + + // Mock the `verify` function of JWT + jwt.verify.mockImplementation((token, secretOrPublicKey, callback) => { + // Assume the token is valid and return the payload + callback(null, "decoded"); + }); + // Test /login endpoint it('should forward login request to auth service', async () => { const response = await request(app) @@ -57,25 +84,39 @@ describe('Gateway Service', () => { // Test /questions endpoint it('should forward questions request to question service', async () => { const response = await request(app) - .get('/questions'); + .get('/questions').set('token', 'valorDelToken'); - expect(response.statusCode).toBe(200); - expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + checkQuestion(response); }); // Test /questions/:lang endpoint it('should forward questions request to question service', async () => { const response = await request(app) - .get('/questions/es'); + .get('/questions/es').set('token', 'valorDelToken'); - expect(response.statusCode).toBe(200); - expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + checkQuestion(response); + }); + + // Test /questions/:lang/:amount endpoint + it('should forward questions request to question service', async () => { + const response = await request(app) + .get('/questions/es/1').set('token', 'valorDelToken'); + + checkQuestion(response); + }); + + // Test /questions/:lang/:amount/:type endpoint + it('should forward questions request to question service', async () => { + const response = await request(app) + .get('/questions/es/1/CAPITAL').set('token', 'valorDelToken'); + + checkQuestion(response); }); // Test /record endpoint it('should forward record request to record service', async () => { const response = await request(app) - .post('/record'); + .post('/record').set('token', 'valorDelToken'); expect(response.statusCode).toBe(200); expect(response.body.user).toBe('testuser'); @@ -84,9 +125,35 @@ describe('Gateway Service', () => { // Test /record/:user endpoint it('should forward record request to record service', async () => { const response = await request(app) - .get('/record/testuser'); + .get('/record/testuser').set('token', 'valorDelToken'); - expect(response.statusCode).toBe(200); - expect(response.body).toHaveProperty('record', "undefined"); + checkRecord(response); + }); + + // Test /record/ranking/:user endpoint + it('should forward record request to record service', async () => { + const response = await request(app) + .get('/record/ranking/testuser').set('token', 'valorDelToken'); + + checkRecord(response); }); -}); \ No newline at end of file + + // Test /record/ranking/top10 endpoint + it('should forward record request to record service', async () => { + const response = await request(app) + .get('/record/ranking/top10').set('token', 'valorDelToken'); + checkRecord(response); + + }); + +}); + +function checkRecord(response){ + expect(response.statusCode).toBe(200); + expect(response.body).toHaveProperty('record', "undefined"); +} + +function checkQuestion(response){ + expect(response.statusCode).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); +} \ No newline at end of file diff --git a/gatewayservice/openapi.yaml b/gatewayservice/openapi.yaml index 7129380e..47615d2a 100644 --- a/gatewayservice/openapi.yaml +++ b/gatewayservice/openapi.yaml @@ -9,7 +9,7 @@ info: servers: - url: http://localhost:8000 description: Development server - - url: http://172.203.216.60:8000 + - url: http://wiqen1b.serveminecraft.net:8000 description: Production server paths: /adduser: diff --git a/gatewayservice/package-lock.json b/gatewayservice/package-lock.json index ca71293c..48729129 100644 --- a/gatewayservice/package-lock.json +++ b/gatewayservice/package-lock.json @@ -14,6 +14,7 @@ "express": "^4.18.2", "express-openapi": "^12.1.3", "express-prom-bundle": "^7.0.0", + "jsonwebtoken": "^9.0.2", "swagger-ui-express": "^5.0.0", "yaml": "^2.4.1" }, @@ -1600,6 +1601,11 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1980,6 +1986,14 @@ "esprima": "^4.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3438,6 +3452,81 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jsonwebtoken/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3474,11 +3563,46 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/gatewayservice/package.json b/gatewayservice/package.json index 93a9914e..0757ca8f 100644 --- a/gatewayservice/package.json +++ b/gatewayservice/package.json @@ -22,6 +22,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "express-openapi": "^12.1.3", + "jsonwebtoken": "^9.0.2", "express-prom-bundle": "^7.0.0", "swagger-ui-express": "^5.0.0", "yaml": "^2.4.1" diff --git a/questionservice/question-service.js b/questionservice/question-service.js index 026feb5c..77377c74 100644 --- a/questionservice/question-service.js +++ b/questionservice/question-service.js @@ -30,12 +30,66 @@ app.get('/questions', async (req, res) => { } }); -app.get('/questions/:lang', async (req, res) => { +app.get('/questions/:lang/:amount/:type', async (req, res) => { + try { + const lang = req.params.lang.toString(); + let amount = checkAmount(parseInt(req.params.amount)); + const type = req.params.type.toString(); + + if(amount > 20 || amount < 1) + amount = 5; + + const questions = await Question.aggregate([ + {$match: {language : lang, type: type}}, //Condition + {$sample: {size:amount}} + ]); + + let jsonResult = {}; + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + jsonResult[i] = { + question : question.question, + answers : question.answers + } + } + res.json(jsonResult); + } catch (error) { + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +app.get('/questions/:lang/:amount', async (req, res) => { try { const lang = req.params.lang; + let amount = checkAmount(parseInt(req.params.amount)); + + const questions = await Question.aggregate([ {$match: {language : lang}}, //Condition + {$sample: {size:amount}} + ]); + + let jsonResult = {}; + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + jsonResult[i] = { + question : question.question, + answers : question.answers + } + } + res.json(jsonResult); + } catch (error) { + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +app.get('/questions/:lang', async (req, res) => { + try { + const lang = req.params.lang; + + const questions = await Question.aggregate([ + {$match: {language : lang.toString()}}, //Condition {$sample: {size:5}} //5 random from the ones that fullfil the condition ]); @@ -53,6 +107,11 @@ app.get('/questions/:lang', async (req, res) => { } }); +function checkAmount(amount){ + if(amount > 20 || amount < 1) + return 5; + return amount; +} const server = app.listen(port, () => { console.log(`Question Service listening at http://localhost:${port}`); diff --git a/questionservice/question-service.test.js b/questionservice/question-service.test.js index 67b5ca66..35b70eae 100644 --- a/questionservice/question-service.test.js +++ b/questionservice/question-service.test.js @@ -12,7 +12,7 @@ beforeAll(async () => { app = require('./question-service'); //Populate db - for(let i = 0; i < 6 ; i++){ + for(let i = 0; i < 21 ; i++){ const question = new Question( { question: "¿Cuál es la población de Oviedo?", answers: [ @@ -60,4 +60,52 @@ describe('Question Service', () => { expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); expect(Object.keys(response.body).length).toBe(5); }); + + + it('Should give 20 questions /questions/es/20', async () => { + + let response = await request(app).get('/questions/es/20'); + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + expect(Object.keys(response.body).length).toBe(20); + }); + + it('Should give 1 questions /questions/es/1', async () => { + + let response = await request(app).get('/questions/es/20'); + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + expect(Object.keys(response.body).length).toBe(20); + }); + + it('Should give 5 questions as the max is 20 /questions/es/21', async () => { + + let response = await request(app).get('/questions/es/21'); + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + expect(Object.keys(response.body).length).toBe(5); + }); + + it('Should give 5 questions as the min is 1 /questions/es/0', async () => { + + let response = await request(app).get('/questions/es/0'); + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + expect(Object.keys(response.body).length).toBe(5); + }); + + it('Should give 10 questions /questions/es/10/POPULATION', async () => { + + let response = await request(app).get('/questions/es/10/POPULATION'); + expect(response.status).toBe(200); + expect(response.body[0]).toHaveProperty('question', "¿Cuál es la población de Oviedo?"); + expect(Object.keys(response.body).length).toBe(10); + }); + + it('Should give 0 questions /questions/es/10/CAPITAL', async () => { + + let response = await request(app).get('/questions/es/10/CAPITAL'); + expect(response.status).toBe(200); + expect(Object.keys(response.body).length).toBe(0); + }); }); \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index 6db6137a..fad83281 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -14,4 +14,5 @@ sonar.coverage.exclusions=**/*.test.js sonar.sources=webapp/src/components,users/authservice,users/userservice,gatewayservice sonar.sourceEncoding=UTF-8 sonar.exclusions=node_modules/** -sonar.javascript.lcov.reportPaths=**/coverage/lcov.info \ No newline at end of file +sonar.javascript.lcov.reportPaths=**/coverage/lcov.info +sonar.cpd.exclusions=**/*.test.js,**/*steps.js,**/*Tests.java diff --git a/users/authservice/auth-model.js b/users/authservice/auth-model.js index 7763b51e..5cdbaf69 100644 --- a/users/authservice/auth-model.js +++ b/users/authservice/auth-model.js @@ -1,6 +1,7 @@ const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ + email: String, username: String, password: String, createdAt: Date, diff --git a/users/authservice/auth-service.js b/users/authservice/auth-service.js index 98779572..924dc512 100644 --- a/users/authservice/auth-service.js +++ b/users/authservice/auth-service.js @@ -35,17 +35,20 @@ app.post('/login', async (req, res) => { return } - const { username, password } = req.body; + const email = req.body.username.toString(); + const username = req.body.username.toString(); + const password = req.body.password.toString(); - // Find the user by username in the database - const user = await User.findOne({ username }); + let user = await User.findOne({ username }) + if(!user) //There is no user by that username we may have received an email + user = await User.findOne({ email }) // Check if the user exists and verify the password if (user && await bcrypt.compare(password, user.password)) { // Generate a JWT token - const token = jwt.sign({ userId: user._id }, 'your-secret-key', { expiresIn: '1h' }); + const token = jwt.sign({ userId: user._id }, (process.env.JWT_KEY??'my-key'), { expiresIn: '1h' }); // Respond with the token and user information - res.json({ token: token, username: username}); + res.json({ token: token, username: user.username, email: user.email}); } else { res.status(400).json({ error: 'Invalid credentials' }); } diff --git a/users/authservice/auth-service.test.js b/users/authservice/auth-service.test.js index f4a5a82e..7f258f5c 100644 --- a/users/authservice/auth-service.test.js +++ b/users/authservice/auth-service.test.js @@ -7,7 +7,7 @@ let mongoServer; let app; //test user -const user = { +let user = { username: 'testuser', password: 'testpassword', }; @@ -15,6 +15,7 @@ const user = { async function addUser(user){ const hashedPassword = await bcrypt.hash(user.password, 10); const newUser = new User({ + email: "user@gmail.com", username: user.username, password: hashedPassword, createdAt: new Date() @@ -44,7 +45,14 @@ describe('Auth Service', () => { expect(response.body).toHaveProperty('username', 'testuser'); }); - it('Should show missing field user /login', async () => { + it('Should perform a login operation with email /login', async () => { + user.username = "user@gmail.com"; + const response = await request(app).post('/login').send(user); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('username', 'testuser'); + }); + + it('Should show missing field username /login', async () => { const response = await request(app).post('/login').send(); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error', 'Missing required field: username'); diff --git a/users/recordservice/record-model.js b/users/recordservice/record-model.js index a7a30d76..a302df52 100644 --- a/users/recordservice/record-model.js +++ b/users/recordservice/record-model.js @@ -1,16 +1,18 @@ const mongoose = require('mongoose'); +const { Schema } = mongoose; -const recordSchema = new mongoose.Schema({ - user: String, +const recordSchema = new Schema({ + user: { type: String, required: true }, games: [{ questions: [{ - question: String, - answers: [String], - answerGiven: String, - correctAnswer: String + question: { type: String, required: true }, + answers: { type: [String], required: true }, + answerGiven: { type: String, required: true }, + correctAnswer: { type: String, required: true } }], - points: Number, - date: String + points: { type: Number, required: true }, + date: { type: String, required: true }, + competitive: { type: Boolean, required: true } }] }); const Record = mongoose.model('Record', recordSchema); diff --git a/users/recordservice/record-service.js b/users/recordservice/record-service.js index dd383984..3bca1fa8 100644 --- a/users/recordservice/record-service.js +++ b/users/recordservice/record-service.js @@ -1,8 +1,7 @@ const express = require('express'); const mongoose = require('mongoose'); -const bcrypt = require('bcrypt'); const bodyParser = require('body-parser'); -const Record = require('./record-model') +const Record = require('./record-model'); const app = express(); const port = 8004; @@ -10,6 +9,10 @@ const port = 8004; // Middleware to parse JSON in request body app.use(bodyParser.json()); +var ranking = []; +var lastTime = new Date(); +const minTimeDifferenceInMiliseconds = 120_000; + // Connect to MongoDB const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/userdb'; mongoose.connect(mongoUri); @@ -18,14 +21,12 @@ mongoose.connect(mongoUri); app.post('/record', async (req, res) => { const user = req.body.user; const game = req.body.game; - console.log(user) - console.log(game) if(user && game){ - let record = await Record.findOne({ user : user }); + let record = await Record.findOne({ user : user.toString() }); if(record){ //If it exits record.games.push(game); } - else{ //Lo creamos + else{ //We make it record = new Record({ user:user, games:[game] @@ -45,9 +46,34 @@ app.post('/record', async (req, res) => { }); +app.get('/record/ranking/top10', async (req, res) => { + try { + let usersRanking = await getRanking(); + let usersCompetitiveStats = usersRanking.slice(0, 10); + + res.json({usersCompetitiveStats: usersCompetitiveStats }); + } catch (err) { + res.status(500).send(); + } +}); + +app.get('/record/ranking/:user', async (req, res) => { + try { + const user = req.params.user.toString(); + + let usersRanking = await getRanking(); + let userCompetitiveStats = usersRanking.filter(userData => userData._id === user); + + res.json({userCompetitiveStats: userCompetitiveStats[0] }); + } catch (err) { + console.log(err) + res.status(500).send(); + } +}); + app.get('/record/:user', async (req, res) => { try { - const recordFound = await Record.findOne({ user: req.params.user }, 'games'); + const recordFound = await Record.findOne({ user: req.params.user.toString() }, 'games'); if (!recordFound) { res.json({record: "undefined" }); } else { @@ -58,6 +84,37 @@ app.get('/record/:user', async (req, res) => { } }); + +async function getRanking(){ + const nowTime = new Date(); + let timeDifferenceInMiliseconds = nowTime - lastTime; + if(ranking.length == 0 || timeDifferenceInMiliseconds > minTimeDifferenceInMiliseconds){ + ranking = await Record.aggregate([ + // Unwind the games array to work with each game separately + { $unwind: "$games" }, + // Match only competitive games + { $match: { "games.competitive": true } }, + // Group by user and calculate total points and total competitive games per user + { + $group: { + _id: "$user", + totalPoints: { $sum: "$games.points" }, + totalCompetitiveGames: { $sum: 1 } // Count the number of competitive games + } + }, + // Sort by total points in descending order (top 1 will have the highest points) + { $sort: { totalPoints: -1 } } + ]); + + //The operator ... dumps the user json making it {_id , totalPoints, totalCompetitiveGames, position} + ranking = ranking.map((user, index) => ({ ...user, position: index + 1 })); + lastTime = new Date(); + } + + return ranking; + +} + const server = app.listen(port, () => { console.log(`Record Service listening at http://localhost:${port}`); }); diff --git a/users/recordservice/record-service.test.js b/users/recordservice/record-service.test.js index d2c2ca6e..5594f781 100644 --- a/users/recordservice/record-service.test.js +++ b/users/recordservice/record-service.test.js @@ -1,5 +1,6 @@ const request = require('supertest'); const { MongoMemoryServer } = require('mongodb-memory-server'); +const Record = require('./record-model') let mongoServer; let app; @@ -9,6 +10,8 @@ beforeAll(async () => { const mongoUri = mongoServer.getUri(); process.env.MONGODB_URI = mongoUri; app = require('./record-service'); + + await populateDatabase(); }); afterAll(async () => { @@ -99,6 +102,33 @@ describe('Record Service', () => { response = await request(app).post('/record').send(newUser); expect(response.status).toBe(400); + //Data lacks competitive field + newUser = { + user:"testuser", + game: + { + "questions": [ + { + "question": "¿Cuál es el río más largo del mundo?", + "answers": ["Nilo", "Amazonas", "Yangtsé", "Misisipi"], + "answerGiven": "Amazonas", + "correctAnswer": "Amazonas" + }, + { + "question": "¿Cuál es el elemento más abundante en la corteza terrestre?", + "answers": ["Hierro", "Oxígeno", "Silicio", "Aluminio"], + "answerGiven": "Oxígeno", + "correctAnswer": "Oxígeno" + } + ], + "points": 2500, + "date": "02/02/24" + } + }; + + response = await request(app).post('/record').send(newUser); + expect(response.status).toBe(500); + }); it('should add a new record on POST /record', async () => { const newUser = { @@ -120,7 +150,8 @@ describe('Record Service', () => { } ], "points": 2500, - "date": "02/02/24" + "date": "02/02/24", + "competitive": false } }; @@ -161,7 +192,8 @@ describe('Record Service', () => { } ], "points": 3000, - "date": "03/03/24" + "date": "03/03/24", + "competitive": false } }; @@ -174,4 +206,78 @@ describe('Record Service', () => { expect(responseGet.body.record.games[1]).toHaveProperty('date', '03/03/24'); }); -}); \ No newline at end of file + + it('should get back on GET /record/testuser', async () => { + const responseGet = await request(app).get('/record/testuser'); + expect(responseGet.status).toBe(200); + expect(responseGet.body.record.games[0]).toHaveProperty('date', '02/02/24'); + }); + + it('should get back on GET /record/testuser', async () => { + const responseGet = await request(app).get('/record/testuser'); + expect(responseGet.status).toBe(200); + expect(responseGet.body.record.games[0]).toHaveProperty('date', '02/02/24'); + }); + + it('should get back on GET /record/ranking/top10', async () => { + const responseGet = await request(app).get('/record/ranking/top10'); + expect(responseGet.status).toBe(200); + const usersStats = responseGet.body.usersCompetitiveStats; + expect(usersStats.length).toBe(10); //Only top 10 + + //Ordered by points + expect(usersStats[0]).toHaveProperty('_id', 'user10'); + expect(usersStats[9]).toHaveProperty('_id', 'user1'); + + expect(usersStats[0]).toHaveProperty('totalCompetitiveGames', 2); + expect(usersStats[0]).toHaveProperty('totalPoints', 200); + }); + + it('should get back on GET /record/ranking/user1', async () => { + const responseGet = await request(app).get('/record/ranking/user1'); + expect(responseGet.status).toBe(200); + const userStats = responseGet.body.userCompetitiveStats; + + expect(userStats).toHaveProperty('_id', 'user1'); + expect(userStats).toHaveProperty('position', 10); + expect(userStats).toHaveProperty('totalCompetitiveGames', 2); + expect(userStats).toHaveProperty('totalPoints', 20); //i * 10 * totalCompetitiveGames , i = 2 + }); +}); + + + +async function populateDatabase() { + try { + // Generate 10 users + for (let i = 1; i <= 10; i++) { + const user = `user${i}`; + const games = []; + + // Generate 3 games for each user + for (let j = 1; j <= 3; j++) { + const game = { + questions: [ + { + question: `Question ${j} for ${user}`, + answers: ["Answer 1", "Answer 2", "Answer 3", "Answer 4"], + answerGiven: "Answer 1", + correctAnswer: "Answer 1" + } + ], + points: i * 10, + date: "04/01/2024", + competitive: j <= 2 ? true : false // Only 2 games are competitive + }; + games.push(game); + } + + // Guardar el usuario en la base de datos + await Record.create({ user, games }); + } + + console.log('Database populated successfully'); + } catch (error) { + console.error('Error populating database:', error); + } +} \ No newline at end of file diff --git a/users/userservice/package-lock.json b/users/userservice/package-lock.json index f21b26cb..e2ccf4a2 100644 --- a/users/userservice/package-lock.json +++ b/users/userservice/package-lock.json @@ -12,6 +12,7 @@ "bcrypt": "^5.1.1", "body-parser": "^1.20.2", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.4" }, "devDependencies": { @@ -1656,6 +1657,11 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2061,6 +2067,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3602,6 +3616,51 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", @@ -3646,6 +3705,41 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/users/userservice/package.json b/users/userservice/package.json index 71cc1cdb..a2efb649 100644 --- a/users/userservice/package.json +++ b/users/userservice/package.json @@ -21,6 +21,7 @@ "bcrypt": "^5.1.1", "body-parser": "^1.20.2", "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.4" }, "devDependencies": { diff --git a/users/userservice/user-model.js b/users/userservice/user-model.js index 71d81b5f..e6643ff2 100644 --- a/users/userservice/user-model.js +++ b/users/userservice/user-model.js @@ -1,6 +1,10 @@ const mongoose = require('mongoose'); const userSchema = new mongoose.Schema({ + email: { + type: String, + required: true, + }, username: { type: String, required: true, diff --git a/users/userservice/user-service.js b/users/userservice/user-service.js index 69899c29..a6ea8a09 100644 --- a/users/userservice/user-service.js +++ b/users/userservice/user-service.js @@ -3,6 +3,7 @@ const express = require('express'); const mongoose = require('mongoose'); const bcrypt = require('bcrypt'); const bodyParser = require('body-parser'); +const jwt = require('jsonwebtoken'); const User = require('./user-model') const app = express(); @@ -16,6 +17,11 @@ const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/userdb'; mongoose.connect(mongoUri); +const validateEmail = (email) => { + return String(email) + .toLowerCase() + .match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/); +}; // Function to validate required fields in the request body function validateRequiredFields(req, requiredFields) { @@ -24,35 +30,80 @@ function validateRequiredFields(req, requiredFields) { throw new Error(`Missing required field: ${field}`); } } + + let email = req.body.email.toString(); + let username = req.body.username.toString(); + let password = req.body.password.toString(); + let repeatPassword = req.body.repeatPassword.toString(); + + if(!validateEmail(email)){ + //User put a wrong format email + throw new Error("Wrong email format (example@example.com)") + } + + if(password !== repeatPassword){ + //User put the same password + throw new Error("Passwords dont match"); + } + if(/\s/.test(password)){ + //User put spaces in password + throw new Error("Password cannot have spaces"); + } + if(password.length < 8){ + //Password too short + throw new Error("Password must be at least 8 characters long"); + } + + if(password.length > 64){ + //Password too long + throw new Error("Password must less than 64 characters long"); + } + + if(/\s/.test(username)){ + //Spaces in username + throw new Error("Username cannot have spaces"); + } + } app.post('/adduser', async (req, res) => { try { // Check if required fields are present in the request body try{ - validateRequiredFields(req, ['username', 'password']); + validateRequiredFields(req, ['email', 'username', 'password', 'repeatPassword']); } catch(error){ res.status(400).json({ error : error.message }); + console.log(res) return } //Check there is not a user with the same name - const user = await User.findOne({username: req.body.username}); + const userUsername = await User.findOne({username: req.body.username.toString()}); + + //Check there is not a user with the same name + const userEmail = await User.findOne({email: req.body.email.toString()}); - if(user) + if(userUsername) return res.status(400).json({error : "Username already in use"}) + if(userEmail) + return res.status(400).json({error : "Email already in use"}) + // Encrypt the password before saving it const hashedPassword = await bcrypt.hash(req.body.password, 10); const newUser = new User({ + email: req.body.email, username: req.body.username, password: hashedPassword, }); - await newUser.save(); - res.json({username: newUser.username}); + const savedUser = await newUser.save(); + + const token = jwt.sign({ userId: savedUser._id }, (process.env.JWT_KEY??'my-key'), { expiresIn: '1h' }); + + res.json({ token: token, username: savedUser.username, email: savedUser.email}); } catch (error) { res.status(400).json({ error: error.message }); }}); diff --git a/users/userservice/user-service.test.js b/users/userservice/user-service.test.js index 7b9e1b4f..ab9fa21e 100644 --- a/users/userservice/user-service.test.js +++ b/users/userservice/user-service.test.js @@ -4,7 +4,12 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); let mongoServer; let app; - +let newUser = { + email: 'example@example.com', + username: 'testuser', + password: 'testpassword', + repeatPassword: 'testpassword' +}; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); @@ -18,35 +23,91 @@ afterAll(async () => { await mongoServer.stop(); }); +afterEach(async () => { + newUser = { + email: 'example@example.com', + username: 'testuser', + password: 'testpassword', + repeatPassword: 'testpassword' + }; +}) + describe('User Service', () => { it('should add a new user on POST /adduser', async () => { - const newUser = { - username: 'testuser', - password: 'testpassword' - }; - const response = await request(app).post('/adduser').send(newUser); expect(response.status).toBe(200); expect(response.body).toHaveProperty('username', 'testuser'); }); - it('Should show missing field user /adduser', async () => { + it('Should show missing field email /adduser', async () => { const response = await request(app).post('/adduser').send(); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error', 'Missing required field: username'); + expect(response.body).toHaveProperty('error', 'Missing required field: email'); }); it('Should not register user /adduser', async () => { - const newUser = { - username: 'testuser', - password: 'testpassword' - }; + newUser.email = 'example2@example.com'; const response = await request(app).post('/adduser').send(newUser); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error', 'Username already in use'); }); - + it('Should not register user /adduser', async () => { + newUser.username = 'testuser2'; + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Email already in use'); + }); + +}); + +describe('User service validations', () => { + it('shows error message on wrong formed email', async () => { + newUser.email = "test" + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Wrong email format (example@example.com)'); + }); + + it('shows error message on not equal passwords', async () => { + newUser.repeatPassword = newUser.repeatPassword + "n"; + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Passwords dont match'); + }); + + it('shows error message on password have spaces', async () => { + setPassword("1234 56789") + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Password cannot have spaces'); + }); + + it('shows error message on password length is less than 8', async () => { + setPassword("12") + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Password must be at least 8 characters long'); + }); + + it('shows error message on password length is more than 64', async () => { + setPassword("01234567890123456789012345678901234567890123456789012345678901234") + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Password must less than 64 characters long'); + }); + + it('shows error message on username has spaces', async () => { + newUser.username = newUser.username + " yes" + const response = await request(app).post('/adduser').send(newUser); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error', 'Username cannot have spaces'); + }); }); + +function setPassword(newPassword){ + newUser.password = newPassword; + newUser.repeatPassword = newPassword; +} \ No newline at end of file diff --git a/webapp/e2e/features/gameMenu.feature b/webapp/e2e/features/gameMenu.feature new file mode 100644 index 00000000..a156fbbc --- /dev/null +++ b/webapp/e2e/features/gameMenu.feature @@ -0,0 +1,8 @@ +Feature: Game Menu page functionality + Scenario: There should be visible three links + Given I am on the game menu + Then three buttons should be visible + Scenario: New Game should go to game configurator + Given I am on the game menu + When I click on New Game + Then I should be in the game configurator diff --git a/webapp/e2e/steps/gameMenu.steps.js b/webapp/e2e/steps/gameMenu.steps.js new file mode 100644 index 00000000..9d00a32d --- /dev/null +++ b/webapp/e2e/steps/gameMenu.steps.js @@ -0,0 +1,62 @@ +const puppeteer = require('puppeteer'); +const { defineFeature, loadFeature } = require('jest-cucumber'); +const setDefaultOptions = require('expect-puppeteer').setDefaultOptions; + +const feature = loadFeature('./features/gameMenu.feature'); + +const { register, login, logout } = require("../utils"); + +let page; +let browser; + +const email = "testUser@example.com"; +const username = "testUser" +const password = "testUserPassword" + +defineFeature(feature, test => { + + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: "new", + slowMo: 40, + defaultViewport: { width: 1920, height: 1080 }, + args: ['--window-size=1920,1080'] + }); + + page = await browser.newPage(); + setDefaultOptions({ timeout: 30000 }); + + await register(page, email, username, password); + }); + + beforeEach(async () => { + await logout(page); + await login(page, username, password); + }) + + test('There should be visible three links', ({ given, then }) => { + given('I am on the game menu', async () => { + await page.goto('http://localhost:3000/menu'); + await page.waitForSelector('.divMenu'); + }); + + then('three buttons should be visible', async () => { + //await expect(page).toMatchElement('.linkButton'); + const elements = await page.$$('.linkButton'); + expect(elements.length).toBe(3); + }); + }); + test('New Game should go to game configurator', ({ given, when, then }) => { + given('I am on the game menu', async () => { + await page.goto('http://localhost:3000/menu'); + await page.waitForSelector('.divMenu'); + }); + when('I click on New Game', async () => { + await page.click('.linkButton'); + }); + then('I should be in the game configurator', async () => { + await expect(page).toMatchElement('.GameConfiguratorDiv'); + }); + }); + +}); diff --git a/webapp/e2e/steps/home.steps.js b/webapp/e2e/steps/home.steps.js index 22d6dec1..eeb89d7e 100644 --- a/webapp/e2e/steps/home.steps.js +++ b/webapp/e2e/steps/home.steps.js @@ -10,17 +10,17 @@ let browser; defineFeature(feature, test => { beforeAll(async () => { - browser = await puppeteer.launch({ - headless: "new", // Cambiado a "new" para evitar la advertencia de deprecación - slowMo: 20, - defaultViewport: { width: 1920, height: 1080 }, - args: ['--window-size=1920,1080'] + browser = await puppeteer.launch({ + headless: "new", + slowMo: 20, + defaultViewport: { width: 1920, height: 1080 }, + args: ['--window-size=1920,1080'] + }); + + page = await browser.newPage(); + setDefaultOptions({ timeout: 10000 }); }); - page = await browser.newPage(); - setDefaultOptions({ timeout: 10000 }); -}); - // test('The text container is initially visible', ({ given, then }) => { // given('I am on the home page', async () => { // await page.goto('http://localhost:3000/home'); diff --git a/webapp/e2e/utils.js b/webapp/e2e/utils.js new file mode 100644 index 00000000..b25f6aaa --- /dev/null +++ b/webapp/e2e/utils.js @@ -0,0 +1,37 @@ +async function register(page, email, username, password) { + await page.goto('http://localhost:3000/addUser'); + await page.waitForSelector('.general'); + + await page.type('input[name="email"]', email); + await page.type('input[name="username"]', username); + await page.type('input[name="password"]', password); + await page.type('input[name="repeat_password"]', password); + await page.click('button[type="submit"]'); + //Wait for menu to load + await page.waitForSelector('.divMenu'); +} + +async function login(page, username, password) { + await page.goto('http://localhost:3000/login'); + await page.waitForSelector('.general'); + + await page.type('input[type="text"]', username); + await page.type('input[type="password"]', password); + await page.click('button[type="submit"]'); + //Wait for menu to load + await page.waitForSelector('.divMenu'); +} + +async function logout(page){ + await page.click('.user-button'); + await page.waitForSelector('.MuiMenu-paper', { visible: true }); + await page.click('text=Log Out'); + //Wait for home to load + await page.waitForSelector('.general'); +} + +module.exports = { + register, + login, + logout + }; \ No newline at end of file diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 01a7cfde..e71a4dd5 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -19,6 +19,7 @@ "express": "^4.19.2", "i18n": "^0.15.1", "jquery": "^3.7.1", + "js-cookie": "^3.0.5", "jsonwebtoken": "^9.0.2", "mongoose": "^8.3.0", "react": "^18.2.0", @@ -28,7 +29,8 @@ "react-icons": "^5.0.1", "react-router-dom": "^6.22.3", "react-scripts": "^5.0.1", - "web-vitals": "^3.5.1" + "web-vitals": "^3.5.1", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -18530,6 +18532,14 @@ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -28486,6 +28496,11 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } } } diff --git a/webapp/package.json b/webapp/package.json index 53a5c05f..c258e1f2 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -14,6 +14,7 @@ "express": "^4.19.2", "i18n": "^0.15.1", "jquery": "^3.7.1", + "js-cookie": "^3.0.5", "jsonwebtoken": "^9.0.2", "mongoose": "^8.3.0", "react": "^18.2.0", @@ -23,7 +24,8 @@ "react-icons": "^5.0.1", "react-router-dom": "^6.22.3", "react-scripts": "^5.0.1", - "web-vitals": "^3.5.1" + "web-vitals": "^3.5.1", + "zxcvbn": "^4.4.2" }, "scripts": { "start": "react-scripts start", diff --git a/webapp/public/favicon.ico b/webapp/public/favicon.ico index a11777cc..5019a7d6 100644 Binary files a/webapp/public/favicon.ico and b/webapp/public/favicon.ico differ diff --git a/webapp/public/index.html b/webapp/public/index.html index aa069f27..a9a639d1 100644 --- a/webapp/public/index.html +++ b/webapp/public/index.html @@ -3,6 +3,7 @@ + { + document.title = 'WIQ'; + }, []); + + //The double !! converts an expression that can be a boolean into an actual boolean + const isLoggedIn = !!Cookies.get('user'); + return ( -
} /> - } /> - } /> + : } /> + : } /> } /> - } /> - } /> - } /> - } /> + : } /> + : } /> + : } /> + : } /> + : }/> + : } /> } />
-
); } diff --git a/webapp/src/App.test.js b/webapp/src/App.test.js index 0d9927ca..3756fd6f 100644 --- a/webapp/src/App.test.js +++ b/webapp/src/App.test.js @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render} from '@testing-library/react'; import App from './App'; diff --git a/webapp/src/components/GameConfigurator/GameConfigurator.js b/webapp/src/components/GameConfigurator/GameConfigurator.js new file mode 100644 index 00000000..4a536729 --- /dev/null +++ b/webapp/src/components/GameConfigurator/GameConfigurator.js @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; + +import {useTranslation} from "react-i18next"; +import { Link } from "react-router-dom"; +import QuestionView from '../questionView/QuestionView' +import BackButtonToGameMenu from '../fragments/BackButtonToGameMenu'; + +function GameConfigurator(){ + const [tipoPregunta, setTipoPregunta] = useState('POPULATION'); + const [numeroPreguntas, setNumeroPreguntas] = useState(5); + const [clickedForNewGame, setClickedForNewGame]= useState(false); + const[t] = useTranslation("global"); + + function handleClick() { + setClickedForNewGame(true); + } + + function handleClickRandomize() { + const options = ['ALL', 'POPULATION', 'CAPITAL', 'LANGUAGE', 'SIZE']; + const randomOptionIndex = Math.floor(Math.random() * options.length); + setTipoPregunta(options[randomOptionIndex]); + + const randomNumQuestions = Math.floor(Math.random() * 20) + 1; // Random number between 1 and 20 + setNumeroPreguntas(randomNumQuestions); + } + return ( + clickedForNewGame ? : +
+ +

{t("gameConfigurator.game_config")}

+

{t("gameConfigurator.custo_game")}

+ + + +

+ + + {/* Spinner para seleccionar el número de preguntas */} + setNumeroPreguntas(e.target.value)} + min="1" max="20" + /> +

+ +

+
+

+

{t("gameConfigurator.competi_game")}

+

{t("gameConfigurator.rules_competi")}

+ {/* Botones para jugar un juego personalizado o competitivo */} + + +
+ ); +} + +function ButtonRandomizeCustom({t,handleClick}){ + return ( + + ); + +} + +function ButtonCustomized({t,handleClick}) { + return ( + + ); +} + + +function ButtonCompetitive({t}){ + + return ( + +

{t("gameConfigurator.play_competi")}

+ + ); +} + + +export default GameConfigurator; diff --git a/webapp/src/components/GameConfigurator/GameConfigurator.test.js b/webapp/src/components/GameConfigurator/GameConfigurator.test.js new file mode 100644 index 00000000..273b93c0 --- /dev/null +++ b/webapp/src/components/GameConfigurator/GameConfigurator.test.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For additional matchers like toBeInTheDocument +import { BrowserRouter as Router } from 'react-router-dom'; +import GameConfigurator from './GameConfigurator'; +import { initReactI18next } from 'react-i18next'; +import i18en from 'i18next'; + + +i18en.use(initReactI18next).init({ + resources: {}, + lng: 'en', + interpolation:{ + escapeValue: false, + } +}); +global.i18en = i18en; + +describe('GameConfigurator', () => { + test('renders GameConfigurator component', () => { + render(); + expect(screen.getByText(i18en.t("gameConfigurator.game_config"))).toBeInTheDocument(); + }); + + + test('updates tipoPregunta state when select value changes', () => { + render(); + const selectElement = screen.getByLabelText(i18en.t("gameConfigurator.type_quest")); + fireEvent.change(selectElement, { target: { value: 'CAPITAL' } }); + expect(selectElement.value).toBe('CAPITAL'); + }); + + test('updates numeroPreguntas state when input value changes', () => { + render(); + const inputElement = screen.getByLabelText(i18en.t("gameConfigurator.num_quest")); + fireEvent.change(inputElement, { target: { value: '10' } }); + expect(inputElement.value).toBe('10'); + }); + it('renders option to play customized game', () => { + render(); + const text = screen.getByText(i18en.t('gameConfigurator.custo_game')); + expect(text).toBeInTheDocument(); +}); + +it('renders option to play Competitive game', () => { + render(); + const text = screen.getByText(i18en.t('gameConfigurator.competi_game')); + expect(text).toBeInTheDocument(); +}); + +}); diff --git a/webapp/src/components/GameMenu/GameMenu.js b/webapp/src/components/GameMenu/GameMenu.js index 7f7bd957..70ef4d0e 100644 --- a/webapp/src/components/GameMenu/GameMenu.js +++ b/webapp/src/components/GameMenu/GameMenu.js @@ -3,14 +3,18 @@ import { Link } from "react-router-dom"; import {useTranslation} from "react-i18next"; import ButtonHistoricalData from "../HistoricalData/ButtonHistoricalData"; + export default function GameMenu() { const[t] = useTranslation("global"); + + return (

{t("gameMenu.title")}

- + +
); } @@ -18,8 +22,19 @@ export default function GameMenu() { function ButtonNewGame({ t }) { return ( - +

{t("gameMenu.new_game_button")}

); - } \ No newline at end of file + } + + function ButtonRanking({ t }) { + return ( + +

{t("gameMenu.view_ranking")}

+ + + ); + } + + \ No newline at end of file diff --git a/webapp/src/components/GameMenu/GameMenu.test.js b/webapp/src/components/GameMenu/GameMenu.test.js index 7d9b7e4d..a52ed153 100644 --- a/webapp/src/components/GameMenu/GameMenu.test.js +++ b/webapp/src/components/GameMenu/GameMenu.test.js @@ -33,6 +33,12 @@ describe('GameMenu component', () => { const text = screen.getByText(i18en.t('gameMenu.history_button')); expect(text).toBeInTheDocument(); }); + + it('renders option to view ranking data', () => { + render(); + const text = screen.getByText(i18en.t('gameMenu.view_ranking')); + expect(text).toBeInTheDocument(); + }); }); diff --git a/webapp/src/components/HistoricalData/HistoricalView.js b/webapp/src/components/HistoricalData/HistoricalView.js index f110430c..2a4d336b 100644 --- a/webapp/src/components/HistoricalData/HistoricalView.js +++ b/webapp/src/components/HistoricalData/HistoricalView.js @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import {useTranslation} from "react-i18next"; import HistoryRecordRetriever from './HistoryRecordRetriever'; -import { useUserContext } from '../loginAndRegistration/UserContext'; - +import Cookies from 'js-cookie' +import BackButtonToGameMenu from '../fragments/BackButtonToGameMenu'; import RecordList from './RecordList'; @@ -12,13 +12,13 @@ const retriever = new HistoryRecordRetriever(); export default function HistoricalView() { const [records, setRecords]= useState(null); const[t] = useTranslation("global"); - const {user} = useUserContext(); const getRecords = async ()=>{ try { - var jsonRecords = await retriever.getRecords(user.username); - var recordsArray = jsonRecords.games; - setRecords(recordsArray); + let cookie = JSON.parse(Cookies.get('user')) + var jsonRecords = await retriever.getRecords(cookie.username, cookie.token); + var recordsArray = jsonRecords.games; + setRecords(recordsArray); } catch (error) { console.log(error); } @@ -29,6 +29,7 @@ export default function HistoricalView() { return (
+ {(records && records.length !== 0) ? records.map((record, index) => ( )):

{t("historicalView.no_games_played")}

} @@ -53,4 +54,5 @@ function HistoricalGameElement({record,t}){
); -} \ No newline at end of file +} + diff --git a/webapp/src/components/HistoricalData/HistoricalView.test.js b/webapp/src/components/HistoricalData/HistoricalView.test.js index e224a4ce..e2a5f39d 100644 --- a/webapp/src/components/HistoricalData/HistoricalView.test.js +++ b/webapp/src/components/HistoricalData/HistoricalView.test.js @@ -4,9 +4,9 @@ import i18en from 'i18next'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { MemoryRouter } from 'react-router-dom'; -import { UserContextProvider} from '../loginAndRegistration/UserContext'; import HistoricalView from './HistoricalView'; import { act } from 'react-dom/test-utils'; +import Cookies from 'js-cookie'; const mockAxios = new MockAdapter(axios); i18en.use(initReactI18next).init({ resources: {}, @@ -16,29 +16,27 @@ i18en.use(initReactI18next).init({ } }); global.i18en = i18en; +Cookies.set('user', JSON.stringify({username:"dummy", token:"fasfda"})) describe('Historical View component', () => { beforeEach(() => { mockAxios.reset(); }); it('renders no games if the api is down', () => { - const user = { username: 'dummy' }; - render(); + render(); const text = screen.getByText(i18en.t('historicalView.no_games_played')); expect(text).toBeInTheDocument(); }); it('renders no games if the user has no games', async () => { - const user = { username: 'dummy' }; mockAxios.onGet('http://localhost:8000/record/dummy').reply(200, "undefined"); await act(async()=> - await render() + await render() ) const text = screen.getByText(i18en.t('historicalView.no_games_played')); expect(text).toBeInTheDocument(); }); it('renders Game Record Buttons', async () => { - const user = { username: 'dummy' }; mockAxios.onGet('http://localhost:8000/record/dummy').reply(200, {record : {user: "dummy", games: [{ @@ -53,7 +51,7 @@ describe('Historical View component', () => { }]}}); await act(async()=> - await render() + await render() ) const regex = /\d{1,2}\/\d{1,2}\/\d{4}/; // Expresión regular para el formato de fecha "MM/DD/YYYY" o "M/D/YYYY" const textRegex = new RegExp(regex); diff --git a/webapp/src/components/HistoricalData/HistoryRecordRetriever.js b/webapp/src/components/HistoricalData/HistoryRecordRetriever.js index d586a867..0f554460 100644 --- a/webapp/src/components/HistoricalData/HistoryRecordRetriever.js +++ b/webapp/src/components/HistoricalData/HistoryRecordRetriever.js @@ -7,19 +7,85 @@ class HistoryRecordRetriever{ } - async getRecords(user) { + async getRecords(user, token) { try { - const response = await axios.get(this.apiUrl + '/' + user); + const response = await axios.get(this.apiUrl + '/' + user, { headers : {'token':token}}); const receivedRecords = await response.data; - console.log(receivedRecords) - console.log(receivedRecords[0]) return receivedRecords.record; } catch (error) { console.log(error) throw new Error(error); } + /* + return { + userId: user, + games: [ + { + questions: [ + { + question: "¿Cuál es la capital de Francia?", + answers: ["Madrid", "París", "Londres", "Roma"], + answerGiven: "París", + correctAnswer: "París" + }, + { + question: "¿En qué año comenzó la Segunda Guerra Mundial?", + answers: ["1939", "1945", "1914", "1941"], + answerGiven: "1939", + correctAnswer: "1939" + }, + { + question: "¿Quién escribió 'Don Quijote de la Mancha'?", + answers: ["Miguel de Cervantes", "Gabriel García Márquez", "Federico García Lorca", "Jorge Luis Borges"], + answerGiven: "Miguel de Cervantes", + correctAnswer: "Miguel de Cervantes" + } + ], + points: 3000, + date: "01/02/24" + }, + { + questions: [ + { + question: "¿Cuál es el río más largo del mundo?", + answers: ["Nilo", "Amazonas", "Yangtsé", "Misisipi"], + answerGiven: "Amazonas", + correctAnswer: "Amazonas" + }, + { + question: "¿Cuál es el elemento más abundante en la corteza terrestre?", + answers: ["Hierro", "Oxígeno", "Silicio", "Aluminio"], + answerGiven: "Oxígeno", + correctAnswer: "Oxígeno" + } + ], + points: 2500, + date: "02/02/24" + }, + { + questions: [ + { + question: "¿Quién pintó la Mona Lisa?", + answers: ["Leonardo da Vinci", "Pablo Picasso", "Vincent van Gogh", "Rembrandt"], + answerGiven: "Leonardo da Vinci", + correctAnswer: "Leonardo da Vinci" + }, + { + question: "¿Cuál es el planeta más grande del sistema solar?", + answers: ["Júpiter", "Saturno", "Neptuno", "Urano"], + answerGiven: "Júpiter", + correctAnswer: "Júpiter" + } + ], + points: 3500, + date: "03/02/24" + } + ] + };*/ } + + } diff --git a/webapp/src/components/fragments/BackButtonToGameMenu.js b/webapp/src/components/fragments/BackButtonToGameMenu.js new file mode 100644 index 00000000..e9bab12c --- /dev/null +++ b/webapp/src/components/fragments/BackButtonToGameMenu.js @@ -0,0 +1,9 @@ +import { Link } from "react-router-dom"; + +export default function BackButton({t}){ + return( + +

⬅ {t("gameMenu.back")}

+ + ); + } \ No newline at end of file diff --git a/webapp/src/components/fragments/BackButtonToGameMenu.test.js b/webapp/src/components/fragments/BackButtonToGameMenu.test.js new file mode 100644 index 00000000..586cf695 --- /dev/null +++ b/webapp/src/components/fragments/BackButtonToGameMenu.test.js @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; +import BackButtonToGameMenu from './BackButtonToGameMenu'; +import { MemoryRouter } from 'react-router-dom'; + +import { initReactI18next } from 'react-i18next'; +import i18en from 'i18next'; + +i18en.use(initReactI18next).init({ + resources: {}, + lng: 'en', + interpolation:{ + escapeValue: false, + } +}); +global.i18en = i18en; + + +describe('BackButtonToGameMenu component', () => { + + it('renders option to go back to the game menu', () => { + render(); + const text = screen.getByText((content, element) => { + const regex = new RegExp(i18en.t("gameMenu.back")); + return regex.test(content); + }); + + expect(text).toBeInTheDocument(); + }); +}); + + diff --git a/webapp/src/components/fragments/Loader.js b/webapp/src/components/fragments/Loader.js new file mode 100644 index 00000000..2cd8af7c --- /dev/null +++ b/webapp/src/components/fragments/Loader.js @@ -0,0 +1,15 @@ +import React from 'react'; + +const Loader = () => { + return ( +
+
+
Status
+ +
+
Loading...
+
+ ); +} + +export default Loader; diff --git a/webapp/src/components/fragments/NavBar.js b/webapp/src/components/fragments/NavBar.js index d6938b13..5ce411cd 100644 --- a/webapp/src/components/fragments/NavBar.js +++ b/webapp/src/components/fragments/NavBar.js @@ -4,21 +4,36 @@ import MenuItem from '@mui/material/MenuItem'; import Menu from '@mui/material/Menu'; import "../../custom.css"; import { useTranslation } from "react-i18next"; -import { useUserContext } from '../loginAndRegistration/UserContext'; +import { useNavigate } from 'react-router-dom'; +import Cookies from 'js-cookie'; function Navbar() { - + const navigate = useNavigate(); const [t, i18n] = useTranslation("global"); - const [anchorEl, setAnchorEl] = useState(null); - const { user } = useUserContext(); + const [anchorLanguage, setAnchorLanguage] = useState(null); + const [anchorUser, setAnchorUser] = useState(null); const handleLanguageMenuOpen = (event) => { - setAnchorEl(event.currentTarget); + setAnchorLanguage(event.currentTarget); }; const handleLanguageMenuClose = () => { - setAnchorEl(null); + setAnchorLanguage(null); + }; + + const handleUserMenuOpen = (event) => { + setAnchorUser(event.currentTarget); + }; + + const handleUserMenuClose = () => { + setAnchorUser(null); + }; + + const removeCookie = () => { + Cookies.remove('user'); + navigate('/home'); + window.location.reload(); }; const changeLanguage = (language) => { @@ -37,18 +52,31 @@ function Navbar() {
changeLanguage("en")}> {t("navBar.en")} changeLanguage("es")}> {t("navBar.es")} changeLanguage("tk")}> {t("navBar.tk")} - {user != null ? ( -

{user.username}

- ) : null} + + {Cookies.get('user') ? ( + <> + + + removeCookie()}> {t("navBar.logout")} + + + ) : null} +
); diff --git a/webapp/src/components/fragments/NavBar.test.js b/webapp/src/components/fragments/NavBar.test.js index 29ceb23f..7fe0137f 100644 --- a/webapp/src/components/fragments/NavBar.test.js +++ b/webapp/src/components/fragments/NavBar.test.js @@ -2,12 +2,10 @@ import { render , screen, fireEvent } from '@testing-library/react'; import { initReactI18next } from 'react-i18next'; import i18en from 'i18next'; import { MemoryRouter } from 'react-router-dom'; - -import { UserContextProvider} from '../loginAndRegistration/UserContext'; import Navbar from './NavBar' +import Cookies from 'js-cookie' import App from '../../App' - i18en.use(initReactI18next).init({ resources: {}, lng: 'en', @@ -16,12 +14,12 @@ i18en.use(initReactI18next).init({ } }); global.i18en = i18en; +Cookies.set('user', JSON.stringify({ username: 'dummy' })) describe('NavBar fragment', () => { it('shows the user name',async () => { - const user = { username: 'dummy' }; - render(); + render(); const text2 = await screen.findByText('dummy') expect(text2).toBeInTheDocument(); // Wait for questions to load diff --git a/webapp/src/components/loginAndRegistration/AddUser.js b/webapp/src/components/loginAndRegistration/AddUser.js index b0cb6f50..66683cc6 100644 --- a/webapp/src/components/loginAndRegistration/AddUser.js +++ b/webapp/src/components/loginAndRegistration/AddUser.js @@ -5,83 +5,185 @@ import { useTranslation } from "react-i18next"; import axios from 'axios'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import zxcvbn from "zxcvbn"; +import Cookies from 'js-cookie'; +import { manageError } from "../../utils/manageError"; const AddUser = () => { const navigate = useNavigate(); const apiUrl = (process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000') + "/adduser"; const { t } = useTranslation("global"); + + const [email, setEmail] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [repeatPassword, setRepeatPassword] = useState(''); + const [passwordStrength, setPasswordStrength] = useState(''); + const [passwordStrengthText, setPasswordStrengthText] = useState(''); + const [submitErrors, setSubmitErrors] = useState([]); + const validateEmail = (email) => { + return String(email) + .toLowerCase() + .match(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/); + }; const handleSubmit = async (event) => { event.preventDefault(); - try { - //TODO: Add more validations - if(password === repeatPassword){ //User put the same password - const response = await axios.post(apiUrl, { username, password }); - console.log("Registered user: " + response.data.username); - navigate('/login'); - } - else{ - //TODO: Show some errors to the user + + const newSubmitErrors = []; + + //Validations + + if(!validateEmail(email)){ + //User put a wrong format email + newSubmitErrors.push("addUser.error_wrong_email_format") + } + + if(password !== repeatPassword){ + //User put the same password + newSubmitErrors.push("addUser.error_passwords_no_match"); + } + if(/\s/.test(password)){ + //User put spaces in password + newSubmitErrors.push("addUser.error_password_spaces"); + } + if(password.length < 8){ + //Password too short + newSubmitErrors.push("addUser.error_password_minimum_length"); + } + + if(password.length > 64){ + //Password too long + newSubmitErrors.push("addUser.error_password_maximum_length"); + } + + if(/\s/.test(username)){ + //Spaces in username + newSubmitErrors.push("addUser.error_username_spaces"); + } + + setSubmitErrors(newSubmitErrors); + + if (newSubmitErrors.length === 0) { + try { + const response = await axios.post(apiUrl, { email, username, password, repeatPassword }); + let oneHourAfter = new Date().getTime() + (1 * 60 * 60 * 1000) + Cookies.set('user', JSON.stringify({username : response.data.username, token : response.data.token}) + , {expires:oneHourAfter}); + navigate('/menu'); + window.location.reload(); + } catch (error) { + let processedError = manageError(error); + if(processedError.status !== 500) + setSubmitErrors(['addUser.error_username_in_use']); } + } + }; - } catch (error) { - console.error('Error adding user:', error); + const handlePasswordChange = (e) => { + const newPassword = e.target.value; + setPassword(newPassword); + + const newStrength = zxcvbn(newPassword); + + switch(newStrength.score){ + case 0: + setPasswordStrengthText("addUser.very_weak_password"); + break; + case 1: + setPasswordStrengthText("addUser.very_weak_password"); + break; + case 2: + setPasswordStrengthText("addUser.weak_password"); + break; + case 3: + setPasswordStrengthText("addUser.good_password"); + break; + case 4: + setPasswordStrengthText("addUser.strong_password"); + break; + default: + setPasswordStrengthText("addUser.very_weak_password"); + break; + } + setPasswordStrength(newStrength); + }; + + const showErrors = () => { + if (submitErrors.length > 0) { + return submitErrors.map((error, index) => ( +

{t(error)}

+ )); } + return null; }; return (
-
-
-
-

{t("addUser.title")}

-
-

{t("addUser.username_placeholder")}:

- +
+ +

{t("addUser.title")}

+ {showErrors()} +
+

{t("addUser.email_placeholder")}:

+ setEmail(e.target.value)} + /> +
+
+

{t("addUser.username_placeholder")}:

+ setUsername(e.target.value)} /> -
-
-

{t("addUser.password_placeholder")}:

- +
+

{t("addUser.password_placeholder")}:

+ setPassword(e.target.value)} + onChange={handlePasswordChange} /> -
-
-

{t("addUser.repeat_password_placeholder")}:

- setRepeatPassword(e.target.value)} - /> -
- - - - - - - -
-
+
+
+ {t(passwordStrengthText)} + +
+
+

{t("addUser.repeat_password_placeholder")}:

+ setRepeatPassword(e.target.value)} + /> +
+ + + + +
+
); }; @@ -89,61 +191,10 @@ const AddUser = () => { function LinkLogin() { const { t } = useTranslation("global"); return ( - + {t("addUser.login_link")} ); } export default AddUser; - -// const [username, setUsername] = useState(''); -// const [password, setPassword] = useState(''); -// const [error, setError] = useState(''); -// const [openSnackbar, setOpenSnackbar] = useState(false); - -// const addUser = async () => { -// try { -// await axios.post(`${apiEndpoint}/adduser`, { username, password }); -// setOpenSnackbar(true); -// } catch (error) { -// setError(error.response.data.error); -// } -// }; - -// const handleCloseSnackbar = () => { -// setOpenSnackbar(false); -// }; - -// return ( -// -// -// Add User -// -// setUsername(e.target.value)} -// /> -// setPassword(e.target.value)} -// /> -// -// -// {error && ( -// setError('')} message={`Error: ${error}`} /> -// )} -// -// ); -// }; diff --git a/webapp/src/components/loginAndRegistration/AddUser.test.js b/webapp/src/components/loginAndRegistration/AddUser.test.js index 8cf04089..f95b52ef 100644 --- a/webapp/src/components/loginAndRegistration/AddUser.test.js +++ b/webapp/src/components/loginAndRegistration/AddUser.test.js @@ -1,20 +1,28 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import AddUser from './AddUser'; -import { BrowserRouter as Router } from 'react-router-dom'; +import axios from 'axios'; +import { BrowserRouter as Router } from 'react-router-dom'; // Mocking useTranslation hook jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: key => key }), })); +// Mocking axios to simulate an error response +jest.mock('axios'); + describe('', () => { - test('renders the AddUser component', () => { + + beforeEach(() => { render( ); + }); + + test('renders the AddUser component', () => { expect(screen.getByText('addUser.title')).toBeInTheDocument(); expect(screen.getByText('addUser.username_placeholder:')).toBeInTheDocument(); @@ -24,6 +32,61 @@ describe('', () => { expect(screen.getByText('addUser.login_link')).toBeInTheDocument(); }); + + const fillFormAndSubmit = (email, username, password, repeatPassword) => { + const emailInput = screen.getByPlaceholderText('addUser.email_placeholder'); + fireEvent.change(emailInput, { target: { value: email } }); + + const usernameInput = screen.getByPlaceholderText('addUser.username_placeholder'); + fireEvent.change(usernameInput, { target: { value: username } }); + + const passwordInput = screen.getByPlaceholderText('addUser.password_placeholder'); + fireEvent.change(passwordInput, { target: { value: password } }); + + const repeatPasswordInput = screen.getByPlaceholderText('addUser.repeat_password_placeholder'); + fireEvent.change(repeatPasswordInput, { target: { value: repeatPassword } }); + + const submitButton = screen.getByText('addUser.register_button'); + fireEvent.click(submitButton); + }; + + test('displays correct error messages', async () => { + //Wrong email format lacks @ + fillFormAndSubmit('userexample.com', 'username', '12345678', '123456789'); + expect(screen.getByText('addUser.error_wrong_email_format')).toBeInTheDocument(); + //Wrong email format lacks domain + fillFormAndSubmit('user@example', 'username', '12345678', '123456789'); + expect(screen.getByText('addUser.error_wrong_email_format')).toBeInTheDocument(); + //Passwords do not match + fillFormAndSubmit('user@example.com', 'username', '12345678', '123456789'); + expect(screen.getByText('addUser.error_passwords_no_match')).toBeInTheDocument(); + //Password with spaces + fillFormAndSubmit('user@example.com', 'username', '1234 5678', '1234 5678'); + expect(screen.getByText('addUser.error_password_spaces')).toBeInTheDocument(); + //Password too short + fillFormAndSubmit('user@example.com', 'username', '1234567', '1234567'); + expect(screen.getByText('addUser.error_password_minimum_length')).toBeInTheDocument(); + //Password too long + fillFormAndSubmit('user@example.com', 'username', '01234567890123456789012345678901234567890123456789012345678901234', '01234567890123456789012345678901234567890123456789012345678901234'); + expect(screen.getByText('addUser.error_password_maximum_length')).toBeInTheDocument(); + //Username with spaces + fillFormAndSubmit('user@example.com', 'user name', '12345678', '12345678'); + expect(screen.getByText('addUser.error_username_spaces')).toBeInTheDocument(); + + //Show various errors + fillFormAndSubmit('userexample.com', 'user name', '12345678', '12345678'); + expect(screen.getByText('addUser.error_username_spaces')).toBeInTheDocument(); + expect(screen.getByText('addUser.error_wrong_email_format')).toBeInTheDocument(); + + //Username in use + axios.post.mockRejectedValue({ response: { data: { error: 'Username already in use' } } }); + fillFormAndSubmit('user@example.com', 'existing_user', '12345678', '12345678'); + await waitFor(() => { + expect(screen.getByText('addUser.error_username_in_use')).toBeInTheDocument(); + }); + expect(axios.post).toHaveBeenCalledWith(expect.any(String), { email: 'user@example.com' ,username: 'existing_user', password: '12345678', repeatPassword: "12345678" }); + }); + }); diff --git a/webapp/src/components/loginAndRegistration/Login.js b/webapp/src/components/loginAndRegistration/Login.js index ae209ddd..bc7fd00c 100644 --- a/webapp/src/components/loginAndRegistration/Login.js +++ b/webapp/src/components/loginAndRegistration/Login.js @@ -5,7 +5,7 @@ import "../../custom.css"; import axios from 'axios'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useUserContext } from "./UserContext"; +import Cookies from 'js-cookie'; const Login = () => { const navigate = useNavigate(); @@ -13,15 +13,16 @@ const Login = () => { const { t } = useTranslation("global"); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const { setUser } = useUserContext(); const handleSubmit = async (event) => { event.preventDefault(); try { const response = await axios.post(apiUrl, { username, password }); - setUser({username : response.data.username, token : response.data.token}) - //Used to redirect to the menu to start playing + let oneHourAfter = new Date().getTime() + (1 * 60 * 60 * 1000) + Cookies.set('user', JSON.stringify({username : response.data.username, token : response.data.token}) + , {expires:oneHourAfter}); navigate('/menu'); + window.location.reload(); } catch (error) { console.error('Error adding user:', error); } @@ -56,13 +57,7 @@ const Login = () => { onChange={(e) => setPassword(e.target.value)} /> - {//TODO: Study this option and see if it is viable - } -
- -
+ diff --git a/webapp/src/components/loginAndRegistration/Login.test.js b/webapp/src/components/loginAndRegistration/Login.test.js index edd34e82..d58571a1 100644 --- a/webapp/src/components/loginAndRegistration/Login.test.js +++ b/webapp/src/components/loginAndRegistration/Login.test.js @@ -1,22 +1,23 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Login from './Login'; import { BrowserRouter as Router } from 'react-router-dom'; -import { UserContextProvider } from './UserContext'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; // Mocking useTranslation hook jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: key => key }), })); +const mockAxios = new MockAdapter(axios); + describe('', () => { - test('renders the Login component', () => { + it('renders the Login component', () => { render( - - ); expect(screen.getByText('login.title')).toBeInTheDocument(); @@ -25,4 +26,24 @@ describe('', () => { expect(screen.getByText('login.login_button')).toBeInTheDocument(); expect(screen.getByText('login.register_link')).toBeInTheDocument(); }); + + it('sends a correct log in petition', async () => { + mockAxios.onPost('http://localhost:8000/login').reply(200, { token: "token", username: "username"}); + + render( + + + + ); + + fireEvent.change(screen.getByPlaceholderText('login.username_placeholder'), { target: { value: 'user' } }); + fireEvent.change(screen.getByPlaceholderText('login.password_placeholder'), { target: { value: 'j' } }); + fireEvent.click(screen.getByText('login.login_button')); + + // Wait for redirection to happen + await waitFor(() => { + expect(window.location.pathname).toBe('/menu'); + }); + + }); }); diff --git a/webapp/src/components/loginAndRegistration/UserContext.js b/webapp/src/components/loginAndRegistration/UserContext.js deleted file mode 100644 index 95764078..00000000 --- a/webapp/src/components/loginAndRegistration/UserContext.js +++ /dev/null @@ -1,19 +0,0 @@ -import React, { createContext, useContext, useState } from 'react'; - -const UserContext = createContext(); - -export function UserContextProvider({ children, baseUser = null }) { - const [user, setUser] = useState(baseUser); - - return ( - - {children} - - ); -} - -export function useUserContext() { - return useContext(UserContext); -} - - diff --git a/webapp/src/components/questionView/CreationHistoricalRecord.js b/webapp/src/components/questionView/CreationHistoricalRecord.js index 9da8f624..cac292c9 100644 --- a/webapp/src/components/questionView/CreationHistoricalRecord.js +++ b/webapp/src/components/questionView/CreationHistoricalRecord.js @@ -3,6 +3,9 @@ import axios from 'axios' class CreationHistoricalRecord{ constructor() { + this.initRecord(); + } + initRecord(){ this.record = { game: { questions: [] @@ -29,35 +32,37 @@ class CreationHistoricalRecord{ this.record.game.date = date; } + setCompetitive(isCompetitive){ + this.record.game.competitive = isCompetitive; + } + getRecord() { return this.record; } - async sendRecord(user) { - const apiUrl = (process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000') + "/record"; - - const body = { - user:user, - game:this.record.game - } - try { + async sendRecord(user, token) { + const apiUrl = (process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000') + "/record"; + + const body = { + user: user, + game: this.record.game + }; + + try { const response = await axios.post(apiUrl, body, { - headers: { - 'Content-Type': 'application/json' - } + headers: { + 'Content-Type': 'application/json', + 'token':token + } }); - - if (!response.ok) { - throw new Error('Error al enviar el registro'); - } - - const data = await response.json(); - console.log(data); - } catch (error) { - console.error('Error:', error); - } + + this.initRecord(); + console.log('Registro enviado:', response.data); + } catch (error) { + console.error('Error al enviar el registro:', error.message); } + } } export default CreationHistoricalRecord; diff --git a/webapp/src/components/questionView/QuestionGenerator.js b/webapp/src/components/questionView/QuestionGenerator.js index 32facf1a..ec7a1b6d 100644 --- a/webapp/src/components/questionView/QuestionGenerator.js +++ b/webapp/src/components/questionView/QuestionGenerator.js @@ -8,37 +8,43 @@ class QuestionGenerator{ } - async generateQuestions(lang) { - - // try { - // //const response = await fetch(this.apiUrl); - // //const receivedQuestions = await response.json(); + async generateQuestions(lang, type, amount, token) { + /* + try { + //const response = await fetch(this.apiUrl); + //const receivedQuestions = await response.json(); - // //Mockup - // // console.log("type: "+type+" amount: "+amount) - // const receivedQuestions = JSON.parse('{"0":{"question":"¿Cuál es la población de Oviedo?","answers":["225089","191325","220587","121548"]},'+ - // '"1":{"question":"¿Which is the population of Gijon?","answers":["275274","159658","233982","305554"]},'+ - // '"2":{"question":"¿Cuál es la población de Avilés?","answers":["82568","115595","41284","122200"]},'+ - // '"3":{"question":"¿Cuál es la capital de Asturias?","answers":["Ciudad de Oviedo","a","b","c"]},'+ - // '"4":{"question":"¿Cuál es la capital de España?","answers":["Madrid","a","b","c"]},'+ - // '"5":{"question":"¿Cuál es la capital de Turquía?","answers":["Ankara","a","b","c"]}}') + //Mockup + console.log("type: "+type+" amount: "+amount) + const receivedQuestions = JSON.parse('{"0":{"question":"¿Cuál es la población de Oviedo?","answers":["225089","191325","220587","121548"]},'+ + '"1":{"question":"¿Cuál es la población de Gijón?","answers":["275274","159658","233982","305554"]},'+ + '"2":{"question":"¿Cuál es la población de Avilés?","answers":["82568","115595","41284","122200"]},'+ + '"3":{"question":"¿Cuál es la capital de Asturias?","answers":["Ciudad de Oviedo","a","b","c"]},'+ + '"4":{"question":"¿Cuál es la capital de España?","answers":["Madrid","a","b","c"]},'+ + '"5":{"question":"¿Cuál es la capital de Turquía?","answers":["Ankara","a","b","c"]}}') - // let i = 0; - // var questions = []; - // for (const key in receivedQuestions) { - // questions[i] = new Question(receivedQuestions[key]); - // i += 1; - // } - // console.log(questions); - // return questions; - // } catch (error) { - // throw new Error(error); - // } - + let i = 0; + var questions = []; + for (const key in receivedQuestions) { + questions[i] = new Question(receivedQuestions[key]); + i += 1; + } + console.log(questions); + return questions; + } catch (error) { + throw new Error(error); + } + */ try { - const response = await axios.get(this.apiUrl + '/' + lang); + let response; + if(type==="COMPETITIVE"){ + response = await axios.get(this.apiUrl + '/' + lang, {headers : {'token':token}}); + }else{ + response = await axios.get(this.apiUrl + '/' + lang + '/' +amount + '/' + type, {headers : {'token':token}}); + } + console.log(response) const receivedQuestions = await response.data; let i = 0; var questions = []; diff --git a/webapp/src/components/questionView/QuestionView.js b/webapp/src/components/questionView/QuestionView.js index ca598e11..b84cc18b 100644 --- a/webapp/src/components/questionView/QuestionView.js +++ b/webapp/src/components/questionView/QuestionView.js @@ -8,25 +8,27 @@ import {useTranslation} from "react-i18next"; import $ from 'jquery'; import RecordList from '../HistoricalData/RecordList'; import ButtonHistoricalData from "../HistoricalData/ButtonHistoricalData"; -import { useUserContext } from '../loginAndRegistration/UserContext'; +import Cookies from 'js-cookie' +import BackButtonToGameMenu from '../fragments/BackButtonToGameMenu'; const creationHistoricalRecord = new CreationHistoricalRecord(); const questionGenerator = new QuestionGenerator(); var points = 0; -function QuestionView(){ - +function QuestionView({type= "COMPETITIVE", amount=5}){ const [numQuestion, setnumQuestion] = useState(-1); const [questions, setQuestions] = useState(null); const[t, i18n] = useTranslation("global"); - const {user} = useUserContext(); + const cookie = JSON.parse(Cookies.get('user')) const [audio] = useState(new Audio('/tictac.mp3')); const generateQuestions = async (numQuestion) => { if (numQuestion < 0) { try { - var generatedQuestions = await questionGenerator.generateQuestions(i18n.language); + + var generatedQuestions = await questionGenerator.generateQuestions(i18n.language, type, amount, cookie.token); setQuestions(generatedQuestions); + points=0; setnumQuestion(0); } catch (error) { //Como hacer que funcione esto @@ -106,9 +108,10 @@ function QuestionView(){ //Last question sends the record if(!(numQuestion < questions.length - 1)){ audio.pause(); + creationHistoricalRecord.setCompetitive(type === 'COMPETITIVE'); creationHistoricalRecord.setDate(Date.now()); creationHistoricalRecord.setPoints(points); - creationHistoricalRecord.sendRecord(user.username); + creationHistoricalRecord.sendRecord(cookie.username, cookie.token); } }, 1000); @@ -204,6 +207,7 @@ function QuestionComponent({questions, numQuestion, handleClick, t, points, audi <>

{t("questionView.finished_game")}

+

{points} {t("questionView.point")}

    < RecordList record={creationHistoricalRecord.getRecord().game}/>
diff --git a/webapp/src/components/questionView/QuestionView.test.js b/webapp/src/components/questionView/QuestionView.test.js index a3a34531..d00a36e9 100644 --- a/webapp/src/components/questionView/QuestionView.test.js +++ b/webapp/src/components/questionView/QuestionView.test.js @@ -4,11 +4,10 @@ import i18en from 'i18next'; import QuestionView from './QuestionView'; import { MemoryRouter } from 'react-router-dom'; import { act } from 'react-dom/test-utils'; -import { UserContextProvider} from '../loginAndRegistration/UserContext'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import {configure} from '@testing-library/dom'; - +import Cookies from 'js-cookie' // Función para configurar el mock de global.Audio const setupAudioMock = () => { @@ -48,15 +47,16 @@ i18en.use(initReactI18next).init({ } }); global.i18en = i18en; +Cookies.set('user', JSON.stringify({username:"dummy", token:"fasfda"})) describe('Question View component', () => { - beforeEach(() => { - mockAxios.reset(); - }); + mockAxios.onGet('http://localhost:8000/questions/en').reply(200, + [{question: "What is the population of Oviedo?", + answers: ["225089","272357","267855","231841"]}]); it('shows the no_questions_message as the endpoint does not exist',async () => { - render(); + render(); const text = screen.getByText(i18en.t('questionView.no_questions_message')); expect(text).toBeInTheDocument(); // Wait for questions to load @@ -65,13 +65,9 @@ describe('Question View component', () => { // Test for sound functionality it('speaks the question when the speaker button is clicked', async () => { - const questionText = "What is the population of Oviedo?"; - mockAxios.onGet('http://localhost:8000/questions/en').reply(200, - [{question: questionText, - answers: ["225089","272357","267855","231841"]}]); await act(async () => { - render(); + render(); }); fireEvent.click(screen.getByText('🔊')); @@ -83,14 +79,10 @@ describe('Question View component', () => { }); it('shows a question and answers',async () => { - - mockAxios.onGet('http://localhost:8000/questions/en').reply(200, - [{question: "What is the population of Oviedo?", - answers: ["225089","272357","267855","231841"]}]); //It gives an error as we are not wrapping it by act, however by doing this we simulate a no questions situation await act(async () =>{ - await render(); + await render(); }) await waitFor(() => expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument()); @@ -104,11 +96,8 @@ describe('Question View component', () => { }); it('shows colors to reveal correct answer and it sounds', async () => { setupAudioMock(); - mockAxios.onGet('http://localhost:8000/questions/en').reply(200, - [{question: "What is the population of Oviedo?", - answers: ["225089","272357","267855","231841"]}]); await act(async () =>{ - await render(); + await render(); }) await waitFor(() => expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument()); @@ -125,11 +114,8 @@ describe('Question View component', () => { }); it('shows colors to reveal false answer and it sounds', async () => { setupAudioMock() - mockAxios.onGet('http://localhost:8000/questions/en').reply(200, - [{question: "What is the population of Oviedo?", - answers: ["225089","272357","267855","231841"]}]); await act(async () =>{ - await render(); + await render(); }) await waitFor(() => expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument()); @@ -146,11 +132,8 @@ describe('Question View component', () => { it('shows timer and tiktak sound', async () => { setupAudioMock() - mockAxios.onGet('http://localhost:8000/questions/en').reply(200, - [{question: "What is the population of Oviedo?", - answers: ["225089","272357","267855","231841"]}]); await act(async () =>{ - await render(); + await render(); }) await waitFor(() => expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument()); @@ -158,7 +141,22 @@ describe('Question View component', () => { const timerElement = screen.getByText(new RegExp(`(\\d+) ${i18en.t('questionView.seconds')}`)); expect(timerElement).toBeInTheDocument(); // Verificar que el temporizador esté presente en el DOM - }); + }); + + it('shows finish game review',async () => { + mockAxios.onGet('http://localhost:8000/questions/en').reply(200, []); + mockAxios.onPost('http://localhost:8000/record').reply(200, {user:'myUser'}); + + //It gives an error as we are not wrapping it by act, however by doing this we simulate a no questions situation + await act(async () =>{ + await render(); + }) + + await waitFor(() => expect(screen.getByText(i18en.t('questionView.finished_game'))).toBeInTheDocument()); + + expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument() + + }); // it('renders end message when countdown completes', async() => { @@ -168,7 +166,7 @@ describe('Question View component', () => { // [{question: "What is the population of Oviedo?", // answers: ["225089","272357","267855","231841"]}]); // await act(async () =>{ - // await render(); + // await render(); // }) // await waitFor(() => expect(screen.getByText('What is the population of Oviedo?')).toBeInTheDocument()); diff --git a/webapp/src/components/questionView/test.html b/webapp/src/components/questionView/test.html deleted file mode 100644 index b1ee2c8e..00000000 --- a/webapp/src/components/questionView/test.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - -

Hola

- - - \ No newline at end of file diff --git a/webapp/src/components/ranking/RankingRetriever.js b/webapp/src/components/ranking/RankingRetriever.js new file mode 100644 index 00000000..03ebcd6c --- /dev/null +++ b/webapp/src/components/ranking/RankingRetriever.js @@ -0,0 +1,101 @@ +import axios from 'axios'; + +class RankingRetriever{ + + constructor(){ + this.apiUrl = (process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000')+ "/record/ranking"; + + } + + async getTopTen(token) { + + try { + const response = await axios.get(this.apiUrl + '/top10', {headers : {'token':token}});//finding the top ten + const receivedTopTenRanking = await response.data; + return receivedTopTenRanking; + } catch (error) { + console.log(error) + throw new Error(error); + + } + /* + return { + "usersCompetitiveStats": [ + { + "_id": "user", + "totalPoints": 1000, + "totalCompetitiveGames": 4 + }, + { + "_id": "user2", + "totalPoints": 900, + "totalCompetitiveGames": 2 + }, + { + "_id": "user3", + "totalPoints": 800, + "totalCompetitiveGames": 3 + }, + { + "_id": "user4", + "totalPoints": 700, + "totalCompetitiveGames": 5 + }, + { + "_id": "user5", + "totalPoints": 600, + "totalCompetitiveGames": 6 + }, + { + "_id": "user6", + "totalPoints": 500, + "totalCompetitiveGames": 7 + }, + { + "_id": "user7", + "totalPoints": 400, + "totalCompetitiveGames": 8 + }, + { + "_id": "user8", + "totalPoints": 300, + "totalCompetitiveGames": 9 + }, + { + "_id": "user9", + "totalPoints": 200, + "totalCompetitiveGames": 10 + }, + { + "_id": "user10", + "totalPoints": 100, + "totalCompetitiveGames": 11 + } + ] + }*/ + } + async getUser(user, token){ + + try { + const response = await axios.get(this.apiUrl + '/'+user, {headers : {'token':token}});//finding the top ten + const receivedMyRanking = await response.data; + return receivedMyRanking.userCompetitiveStats; + } catch (error) { + console.log(error) + throw new Error(error); + + } + /* + return { + "_id": "myUser", + "totalPoints": 250, + "totalCompetitiveGames": 1 + };*/ + } + + + +} + +export default RankingRetriever; + diff --git a/webapp/src/components/ranking/RankingView.js b/webapp/src/components/ranking/RankingView.js new file mode 100644 index 00000000..60fd368d --- /dev/null +++ b/webapp/src/components/ranking/RankingView.js @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import RankingRetriever from './RankingRetriever'; +import {useTranslation} from "react-i18next"; +import Loader from "../fragments/Loader" +import BackButton from '../fragments/BackButtonToGameMenu'; +import Cookies from 'js-cookie' + +const retriever = new RankingRetriever(); + +const RankingView = () => { + const[t] = useTranslation("global"); + const cookie = JSON.parse(Cookies.get('user')) + + const [rankingData, setRankingData] = useState(null); + const [myRankingData, setMyRankingData] = useState(null); + const [searchTerm, setSearchTerm] = useState(cookie.username); + + + + const getRanking = async () => { + try { + var ranking = await retriever.getTopTen(cookie.token); + setRankingData(ranking.usersCompetitiveStats); + var myrank = await retriever.getUser(cookie.username, cookie.token); + setMyRankingData(myrank); + } catch (error) { + console.log(error); + } + } + const handleSearch = async (e) => { + e.preventDefault(); + if(searchTerm.length!==0){ + try { + const rank = await retriever.getUser(searchTerm, cookie.token); + setMyRankingData(rank); + } catch (error) { + console.log(error); + } + } + + } + if(rankingData==null || myRankingData == null){ + getRanking(); + } + + return ( +
+ +

{t("ranking.ranking")}

+ {rankingData && rankingData.length > 0 && myRankingData ? ( + <> +
+ + + + + + + + + + + {rankingData.map((user, index) => ( + + + + + + + ))} + {/* Blank row */} + + + + + + + + + + + + +
{t("ranking.position")}{t("ranking.username")}{t("ranking.points")}{t("ranking.num_games")}
{index + 1}{user._id}{user.totalPoints}{user.totalCompetitiveGames}
+ setSearchTerm(e.target.value)} + placeholder={t("ranking.enter_username")} + /> + +
+ +
+
{myRankingData.position}{myRankingData._id}{myRankingData.totalPoints}{myRankingData.totalCompetitiveGames}
+
+ + ) : ( + < Loader /> + )} +
+ ); +}; + +export default RankingView; diff --git a/webapp/src/components/ranking/RankingView.test.js b/webapp/src/components/ranking/RankingView.test.js new file mode 100644 index 00000000..d8640b31 --- /dev/null +++ b/webapp/src/components/ranking/RankingView.test.js @@ -0,0 +1,181 @@ +import { render , screen, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import axios from 'axios'; +import { initReactI18next } from 'react-i18next'; +import i18en from 'i18next'; +import RankingView from './RankingView'; +import MockAdapter from 'axios-mock-adapter'; +import { act } from 'react-dom/test-utils'; +import Cookies from 'js-cookie' + +i18en.use(initReactI18next).init({ + resources: {}, + lng: 'en', + interpolation:{ + escapeValue: false, + } +}); +global.i18en = i18en; +Cookies.set('user', JSON.stringify({username:"myUser", token:"fasfda"})) +const mockAxios = new MockAdapter(axios); +describe('RankingView component', () => { + + it('renders title', () => { + act(()=>{ + render(); + }) + const text = screen.getByText(i18en.t('ranking.ranking')); + expect(text).toBeInTheDocument(); + }); + it('renders Loading if the call to the gateway has not been done', () => { + act(()=>{ + render(); + }) + const text = screen.getByText('Loading...'); + expect(text).toBeInTheDocument(); + }); +}); + describe('RankingView component with endpoint', ()=>{ + mockAxios.onGet('http://localhost:8000/record/ranking/top10').reply(200, + { + "usersCompetitiveStats": [ + { + "_id": "user", + "totalPoints": 1000, + "totalCompetitiveGames": 4 + }, + { + "_id": "user2", + "totalPoints": 900, + "totalCompetitiveGames": 2 + }, + { + "_id": "user3", + "totalPoints": 800, + "totalCompetitiveGames": 3 + }, + { + "_id": "user4", + "totalPoints": 700, + "totalCompetitiveGames": 5 + }, + { + "_id": "user5", + "totalPoints": 600, + "totalCompetitiveGames": 6 + }, + { + "_id": "user6", + "totalPoints": 500, + "totalCompetitiveGames": 7 + }, + { + "_id": "user7", + "totalPoints": 400, + "totalCompetitiveGames": 8 + }, + { + "_id": "user8", + "totalPoints": 300, + "totalCompetitiveGames": 9 + }, + { + "_id": "user9", + "totalPoints": 200, + "totalCompetitiveGames": 10 + }, + { + "_id": "user10", + "totalPoints": 100, + "totalCompetitiveGames": 11 + } + ] + }); + + mockAxios.onGet('http://localhost:8000/record/ranking/myUser').reply(200, + {userCompetitiveStats: + { + "_id": "myUser", + "totalPoints": 250, + "totalCompetitiveGames": 1, + "position":10 + } + } + ); + + it('renders position all headers in the table',async ()=>{ + + await act(async () =>{ + await render(); + + }) + await waitFor(() => expect(screen.getByText(i18en.t('ranking.position'))).toBeInTheDocument()); + expect(screen.getByText(i18en.t('ranking.username'))).toBeInTheDocument() + expect(screen.getByText(i18en.t('ranking.points'))).toBeInTheDocument() + expect(screen.getByText(i18en.t('ranking.num_games'))).toBeInTheDocument() + }); + + it('renders position all users usernames',async ()=>{ + await act(async () =>{ + await render(); + + }) + await waitFor(() => expect(screen.getByText(i18en.t('ranking.position'))).toBeInTheDocument()); + //expect(screen.getByText("user1")).toBeInTheDocument() + expect(screen.getByText("user2")).toBeInTheDocument() + expect(screen.getByText("user3")).toBeInTheDocument() + expect(screen.getByText("user4")).toBeInTheDocument() + expect(screen.getByText("user5")).toBeInTheDocument() + expect(screen.getByText("user6")).toBeInTheDocument() + expect(screen.getByText("user7")).toBeInTheDocument() + expect(screen.getByText("user8")).toBeInTheDocument() + expect(screen.getByText("user9")).toBeInTheDocument() + expect(screen.getByText("user10")).toBeInTheDocument() + }); + it('renders position all users totalPoints',async ()=>{ + + await act(async () =>{ + await render(); + + }) + await waitFor(() => expect(screen.getByText(i18en.t('ranking.position'))).toBeInTheDocument()); + expect(screen.getByText("1000")).toBeInTheDocument() + expect(screen.getByText("900")).toBeInTheDocument() + expect(screen.getByText("800")).toBeInTheDocument() + expect(screen.getByText("700")).toBeInTheDocument() + expect(screen.getByText("600")).toBeInTheDocument() + expect(screen.getByText("500")).toBeInTheDocument() + expect(screen.getByText("400")).toBeInTheDocument() + expect(screen.getByText("300")).toBeInTheDocument() + expect(screen.getByText("200")).toBeInTheDocument() + }); + it('renders position all users competitive games',async ()=>{ + + await act(async () =>{ + await render(); + + }) + await waitFor(() => expect(screen.getByText(i18en.t('ranking.position'))).toBeInTheDocument()); + + expect(screen.getAllByText(/2/).length).toBeGreaterThanOrEqual(2);//hay dos pq hay una posicion + expect(screen.getAllByText(/5/).length).toBeGreaterThanOrEqual(2);//hay dos pq hay una posicion + expect(screen.getAllByText(/6/).length).toBeGreaterThanOrEqual(2);//hay dos pq hay una posicion + expect(screen.getAllByText(/7/).length).toBeGreaterThanOrEqual(2);//hay dos pq hay una posicion + }); + it('renders position all users competitive games',async ()=>{ + + await act(async () =>{ + await render(); + + }) + await waitFor(() => expect(screen.getByText(i18en.t('ranking.position'))).toBeInTheDocument()); + + expect(screen.getByText("myUser")).toBeInTheDocument() + expect(screen.getByText("250")).toBeInTheDocument() + //should be one if only your rank is shown + expect(screen.getAllByText(/1/).length).toBeGreaterThanOrEqual(2);//hay dos pq hay una posicion + + }); + +}) + diff --git a/webapp/src/custom.css b/webapp/src/custom.css index d0c542a6..f6660f91 100644 --- a/webapp/src/custom.css +++ b/webapp/src/custom.css @@ -160,6 +160,44 @@ padding: 10px; } + .input-box-password-register { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 0px; + } + + .input-box-password-register p { + font-weight: bold; + margin-right: 10%; + width: 150px; + text-align: right; + } + + .input-box-password-register input { + flex: 1; + height: 40px; + background: rgb(255, 255, 255); + border: 2px solid gray; + border-radius: 10px; + font-size: 18px; + color: black; + padding: 10px; + padding-bottom: 0px; + } + + .password-strength-meter { + display: flex; + flex-direction: row; + align-items: left; + gap: 20px; + margin-top: 0px; + } + + .password-strength-meter span { + font-size: 14px; + color: rgba(255, 255, 255, 0.5); + } .button-register, .button-login { appearance: none; @@ -1324,6 +1362,26 @@ svg { /*------------------------------Historical--------------------------------------------*/ /* Estilos para los botones */ +.linkButtonHistorical{ + display: flex; + justify-content: center; + align-items: center; + width: 10em; + height: 45px; + background:#00b8ff; + border: none; + outline: none; + border-radius: 40px; + box-shadow: 0 0 10px black; + cursor: pointer; + font-size: 1em; + color: black; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 1em; + text-decoration: none; + +} .historicalButton { color: white; width: 50em; @@ -1388,3 +1446,235 @@ svg { h2{ text-align: center; } +/*------------------------Loader------------------------------*/ +@keyframes blinkCursor { + 50% { + border-right-color: transparent; + } +} + +@keyframes typeAndDelete { + 0%, + 10% { + width: 0; + } + 45%, + 55% { + width: 6.2em; + } /* adjust width based on content */ + 90%, + 100% { + width: 0; + } +} + +.terminal-loader { + border: 0.1em solid #333; + background-color: #1a1a1a; + color: #0f0; + font-family: "Courier New", Courier, monospace; + font-size: 1em; + padding: 1.5em 1em; + width: 12em; + margin: 100px auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + border-radius: 4px; + position: relative; + overflow: hidden; + box-sizing: border-box; +} + +.terminal-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1.5em; + background-color: #333; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 0 0.4em; + box-sizing: border-box; +} + + + +.terminal-title { + float: left; + line-height: 1.5em; + color: #eee; +} + +.text { + display: inline-block; + white-space: nowrap; + overflow: hidden; + border-right: 0.2em solid green; /* Cursor */ + animation: typeAndDelete 4s steps(11) infinite, + blinkCursor 0.5s step-end infinite alternate; + margin-top: 1.5em; +} + +/*--------------------------------------------------Configurator---------------------------------*/ +/* Estilo para el elemento select */ +.buttonRandomize{ + display: flex; + justify-content: center; + align-items: center; + width: 15em; + height: 45px; + background:#00b8ff; + border: none; + outline: none; + border-radius: 40px; + box-shadow: 0 0 10px black; + cursor: pointer; + font-size: 1em; + color: black; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 1em; + text-decoration: none; +} +.select-style { + width: 200px; + height: 35px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f8f8f8; + padding: 5px; + font-size: 16px; + color: #333; +} + +.select-style:focus { + border-color: #6e9ecf; + outline: none; +} + +/* Estilo para el spinner */ +.spinner-style { + width: 40px; + height: 30px; + border: 1px solid #ccc; + background-color: #f8f8f8; + border-radius: 5px; + font-size: medium; +} +.GameConfiguratorDiv> * { + margin-top: 10px; + margin-bottom: 10px; +} +.GameConfiguratorDiv { + left: 50%; + top: 50%; +} +/* +.GameConfiguratorDiv { + display: flex; + flex-direction: column; +} + +.GameConfiguratorDiv > *:not(:first-child):not(:nth-child(4)) { + text-align: left; +}*/ + +.hr-style { + border: 0; + border-top: 1px solid #8e888885; /* Color gris */ + margin: 20px 0; /* Espacio antes y después de la línea */ +} + +.GameConfiguratorDiv h2 { + text-align: left; /* Alinea el texto a la izquierda */ + margin-top:20px; + margin-bottom:20px; +} +.table tbody tr.penultimate-row td { + background-color: black; + padding: 15px; + /* + border: 1px solid rgba(205, 187, 187, 0.455); + box-shadow: 0 0 10px rgba(197, 191, 191, 0.726);*/ +} + +.table tbody tr.penultimate-row td div{ + background-color: #171717; + padding: 0.5em; +} + +.table th,td{ + padding-left: 30px; + padding-right: 30px; + padding-top: 10px; + padding-bottom: 10px; + text-align: center; + +} +/* Estilo para todos los td excepto el penúltimo */ +.table tbody tr:not(:nth-last-child(2)) td:nth-child(1) { + background-color: #4d1e51a8; +} + + +.table tr:nth-child(even) {background-color: #f2f2f22a;} +.table th { + background-color: #69276f; + color: white; +} +.table tr:hover {background-color: #a59c9ca2;} + +.table table{ + border-collapse: collapse; +} +/* Estilo para la fila de headers */ +.table th { + border-top: 2px solid rgba(205, 187, 187, 0.455); /* Borde superior para todas las celdas de la fila de headers */ + border-left: 2px solid rgba(205, 187, 187, 0.455); /* Borde izquierdo en la primera celda */ + border-right: 2px solid rgba(205, 187, 187, 0.455); /* Borde derecho en la última celda */ +} + +/* Estilo para las filas de datos (excepto la penúltima) */ +.table tbody tr:not(:nth-last-child(2)) td { + border-left: 2px solid rgba(205, 187, 187, 0.455); /* Borde izquierdo en la primera celda */ + border-right: 2px solid rgba(205, 187, 187, 0.455); /* Borde derecho en la última celda */ +} + +/* Borde superior e inferior para la penúltima fila */ +.table tbody tr:nth-last-child(2) td { + border-top: 2px solid rgba(205, 187, 187, 0.455); /* Borde superior */ + border-bottom: 2px solid rgba(205, 187, 187, 0.455); /* Borde inferior */ +} + +/* Borde inferior para todas las celdas de la última fila */ +.table tbody tr:last-child td { + border-bottom: 2px solid rgba(205, 187, 187, 0.455); /* Borde inferior */ +} + +/* Borde izquierdo para la primera celda de la última fila */ +.table tbody tr:last-child td:first-child { + border-left: 2px solid rgba(205, 187, 187, 0.455); /* Borde izquierdo */ +} + +/* Borde derecho para la última celda de la última fila */ +.table tbody tr:last-child td:last-child { + border-right: 2px solid rgba(205, 187, 187, 0.455); /* Borde derecho */ +} + +/* Estilo para el cuadro de búsqueda */ +input[type="text"] { + border: 2px solid #636262; /* Borde sólido */ + padding: 8px; /* Espaciado interno */ + font-size: 16px; /* Tamaño de letra */ + color: #e5e0e0; /* Color de letra */ + background-color: #2a2929; /* Color de fondo */ +} + +/* Estilo para el botón */ +#search { + font-size: 18px; /* Tamaño de letra */ + width: 7em; + height: 2em; +} + + diff --git a/webapp/src/index.js b/webapp/src/index.js index 409a8681..103dc805 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -32,12 +32,14 @@ i18next.init({ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( + {/* envolviendo la app con el sistema de traducciones */} + ); // If you want to start measuring performance in your app, pass a function diff --git a/webapp/src/translations/en/global.json b/webapp/src/translations/en/global.json index 52d76ec0..54f087cf 100644 --- a/webapp/src/translations/en/global.json +++ b/webapp/src/translations/en/global.json @@ -16,7 +16,8 @@ "en": "English", "es": "Spanish", "tk": "Turkish", - "language": "Language" + "language": "Language", + "logout":"Log Out" }, "instructions": { "title": "WIQ Instructions", @@ -37,7 +38,7 @@ }, "login": { "title": "Login", - "username_placeholder": "Username", + "username_placeholder": "Username or Email", "password_placeholder": "Password", "remember_me": "Remember me", "forgot_password": "Forgot password?", @@ -50,12 +51,26 @@ "password_placeholder": "Password", "repeat_password_placeholder": "Repeat password", "register_button": "Register", - "login_link": "Do you have an account? Login here." + "login_link": "Do you have an account? Login here.", + "email_placeholder" : "Email", + "very_weak_password": "Very weak password", + "weak_password": "Weak password", + "good_password": "Good password", + "strong_password": "Strong password", + "error_passwords_no_match": "Passwords do not match", + "error_password_spaces": "Password cannot contain spaces", + "error_username_spaces": "Username cannot contain spaces", + "error_password_minimum_length": "Password must be at least 8 characters long", + "error_password_maximum_length": "Password cannot be over 64 characters long", + "error_username_in_use": "Username already in use", + "error_wrong_email_format": "Wrong email format (user@example.com)" }, "gameMenu":{ "history_button":"View Historical Data", "new_game_button":"Create New Game", - "title":"Game Menu" + "view_ranking":"Ranking", + "title":"Game Menu", + "back":"Back" }, "questionView":{ "seconds":"seconds", @@ -70,6 +85,31 @@ "points":"points", "no_games_played":"No games played yet" }, + "gameConfigurator":{ + "game_config":"Game configuration", + "type_quest":"Type of question: ", + "num_quest":"Number of questions: ", + "play_custom":"Play Customized Game", + "rules_competi":"Play with all kinds of questions and a quantity of 5", + "play_competi":"Play competitive Game", + "option_all":"All", + "option_population":"Population", + "option_capital":"Capital", + "option_language":"Language", + "option_size":"Size", + "custo_game":"Create custom game", + "competi_game":"Play Competitive", + "randomize":"Randomize Parameters" + }, + "ranking":{ + "ranking":"Ranking", + "position":"Position", + "username":"Username", + "points":"Points", + "num_games":"Competitive games", + "search":"Search", + "enter_username":"Enter Username..." + }, "error":{ "error":"Error", "sorry":"We're sorry, this page does not exist. Don't be angry, I'm just a little cat." diff --git a/webapp/src/translations/es/global.json b/webapp/src/translations/es/global.json index ff6519e7..092ecc74 100644 --- a/webapp/src/translations/es/global.json +++ b/webapp/src/translations/es/global.json @@ -17,7 +17,8 @@ "en": "Inglés", "es": "Español", "tk": "Turco", - "language": "Idioma" + "language": "Idioma", + "logout":"Desconectarse" }, "instructions": { @@ -40,7 +41,7 @@ }, "login": { "title": "Inicio de sesión", - "username_placeholder": "Nombre de usuario", + "username_placeholder": "Nombre de usuario o Correo Electrónico", "password_placeholder": "Contraseña", "remember_me": "Recordarme", "forgot_password": "¿Olvidaste tu contraseña?", @@ -54,12 +55,25 @@ "password_placeholder": "Contraseña", "repeat_password_placeholder": "Repetir contraseña", "register_button": "Registrarse", - "login_link": "¿Ya tienes una cuenta? Inicia sesión aquí." + "login_link": "¿Ya tienes una cuenta? Inicia sesión aquí.", + "email_placeholder" : "Correo electrónico", + "very_weak_password": "Contraseña muy débil", + "weak_password": "Contraseña débil", + "good_password": "Contraseña buena", + "strong_password": "Contraseña fuerte", + "error_passwords_no_match": "Las contraseñas no coinciden", + "error_password_spaces": "La contraseña no puede contener espacios", + "error_username_spaces": "El nombre de usuario no puede contener espacios", + "error_password_minimum_length": "La contraseña debe tener al menos 8 caracteres", + "error_password_maximum_length": "La contraseña no debe tener más de 64 caracteres", + "error_username_in_use": "Nombre de usuario no disponible" }, "gameMenu":{ "history_button":"Ver Historial", "new_game_button":"Crear nuevo juego", - "title":"Menú del Juego" + "view_ranking":"Ranking", + "title":"Menú del Juego", + "back":"Atrás" },"questionView":{ "seconds":"segundos", "question_counter":"Pregunta nº ", @@ -77,8 +91,32 @@ "error":{ "error":"Error", "sorry":"Lo sentimos, esta página no existe. No te enfades, solo soy un gatito." - } - + }, + "gameConfigurator":{ + "game_config":"Configuración del Juego", + "type_quest":"Tipo de Pregunta : ", + "num_quest":"Número de Preguntas : ", + "play_custom":"Jugar personalizado", + "rules_competi":"Jugar con todo tipo de preguntas siendo estas 5", + "play_competi":"Jugar Competitivo", + "option_all":"Todas", + "option_population":"Población", + "option_capital":"Capital", + "option_language":"Lenguaje", + "option_size":"Extensión", + "custo_game":"Crea una partida personalizada", + "competi_game":"Juega en modo Competitivo", + "randomize":"Randomiza Parámetros" + }, + "ranking":{ + "ranking":"Ranking", + "position":"Posición", + "username":"Username", + "points":"Puntos", + "num_games":"Juegos Competitivos", + "search":"Buscar", + "enter_username":"Inserta usuario..." + } } \ No newline at end of file diff --git a/webapp/src/translations/tk/global.json b/webapp/src/translations/tk/global.json index 2fd2da43..8fa408ff 100644 --- a/webapp/src/translations/tk/global.json +++ b/webapp/src/translations/tk/global.json @@ -16,7 +16,8 @@ "en": "İngilizce", "es": "İspanyolca", "tk": "Türkçe", - "language": "Dil" + "language": "Dil", + "logout":"Log Out" }, "instructions": { "title": "WIQ Talimatları", @@ -50,7 +51,17 @@ "password_placeholder": "Şifre", "repeat_password_placeholder": "Şifreyi Tekrar Girin", "register_button": "Kayıt Ol", - "login_link": "Hesabınız var mı? Buradan giriş yapın." + "login_link": "Hesabınız var mı? Buradan giriş yapın.", + "very_weak_password": "Çok zayıf şifre", + "weak_password": "Zayıf şifre", + "good_password": "İyi şifre", + "strong_password": "Güçlü şifre", + "error_passwords_no_match": "Şifreler eşleşmiyor", + "error_password_spaces": "Şifre boşluk içeremez", + "error_username_spaces": "Kullanıcı adı boşluk içeremez.", + "error_password_minimum_length": "Şifre en az 8 karakter uzunluğunda olmalıdır", + "error_password_maximum_length": "Şifre en fazla 64 karakter uzunluğunda olabilir", + "error_username_in_use": "Kullanıcı adı zaten kullanımda" }, "gameMenu": { "history_button": "Tarihsel Verileri Görüntüle", diff --git a/webapp/src/utils/manageError.js b/webapp/src/utils/manageError.js new file mode 100644 index 00000000..ea56f89c --- /dev/null +++ b/webapp/src/utils/manageError.js @@ -0,0 +1,17 @@ +/** + * Receives an error found in a catch after doing some axios petition in the try + * returns {status: "4xx", error: "error message"} + * or {status: "500", error: "Internal server error"} + * @param {*} error + */ + +function manageError(error){ + if(error.response) + return {status : error.response.status, error : error.response.data.error}; + else //Some other error + return {status : 500, error : "Interanl server error"}; +} + +module.exports = { + manageError +}; \ No newline at end of file