Skip to content

Commit

Permalink
v1.0 release
Browse files Browse the repository at this point in the history
Master update
andrrsin authored Apr 27, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 4876433 + a1c5a65 commit 1530142
Showing 54 changed files with 23,157 additions and 851 deletions.
51 changes: 25 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -25,30 +25,29 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# e2e-tests:
# needs: [unit-tests]
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# - run: npm --prefix users/authservice install
# - run: npm --prefix users/userservice install
# - run: npm --prefix gatewayservice install
# - run: npm --prefix webapp install
# - run: npm --prefix webapp run build
# - run: npm --prefix webapp run test:e2e
# env:
# CI: ""
e2e-tests:
needs: [unit-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm --prefix users/authservice install
- run: npm --prefix users/userservice install
- run: npm --prefix gatewayservice install
- run: npm --prefix webapp install
- run: CI=false npm --prefix webapp run build
- run: CI=true npm --prefix webapp run test:e2e

docker-push-webapp:
name: Push webapp Docker Image to GitHub Packages
runs-on: ARM64
permissions:
contents: read
packages: write
# needs: [e2e-tests]
needs: [unit-tests]
needs: [e2e-tests]
# needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- name: Publish to Registry
@@ -68,8 +67,8 @@ jobs:
permissions:
contents: read
packages: write
# needs: [e2e-tests]
needs: [unit-tests]
needs: [e2e-tests]
# needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- name: Publish to Registry
@@ -86,8 +85,8 @@ jobs:
permissions:
contents: read
packages: write
# needs: [e2e-tests]
needs: [unit-tests]
needs: [e2e-tests]
# needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- name: Publish to Registry
@@ -104,8 +103,8 @@ jobs:
permissions:
contents: read
packages: write
# needs: [e2e-tests]
needs: [unit-tests]
needs: [e2e-tests]
# needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- name: Publish to Registry
@@ -122,8 +121,8 @@ jobs:
permissions:
contents: read
packages: write
# needs: [e2e-tests]
needs: [unit-tests]
needs: [e2e-tests]
# needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- name: Publish to Registry
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -92,7 +92,7 @@ services:
prometheus:
image: prom/prometheus
container_name: prometheus-${teamname:-defaultASW}
profiles: ["dev"]
profiles: ["dev","prod"]
networks:
- mynetwork
volumes:
@@ -107,7 +107,7 @@ services:
grafana:
image: grafana/grafana
container_name: grafana-${teamname:-defaultASW}
profiles: ["dev"]
profiles: ["dev","prod"]
networks:
- mynetwork
volumes:
11 changes: 8 additions & 3 deletions docs/src/01_introduction_and_goals.adoc
Original file line number Diff line number Diff line change
@@ -15,7 +15,12 @@ These include
* quality goals for the architecture and
* relevant stakeholders and their expectations
****
In these points, the main goals and functional requirements will be explained. In order to give context on how the webapp will be developed.

This project's aim, in summary, is developing a public web application which has a game consisting on answering multiple choice questions
with one correct answer generated using Wikidata. For reference of the general mechanic, see the spanish quiz program 'Saber y Ganar' and
its game mode 'Descartando'

Next, important basic aspects of the project will be described in order to give context on how the webapp will be developed.

=== Requirements Overview

@@ -44,6 +49,7 @@ See https://docs.arc42.org/section-1/[Introduction and Goals] in the arc42 docum
****

The functional requirements are the following:

* The application must be accesible through the web
* Users must be able to register into the system
* Users must be able to see data about their past games (score, correct/wrong answers, number of questions answered...)
@@ -141,10 +147,9 @@ Table with role names, person names, and their expectations with respect to the

[options="header",cols="1,2,2"]
|===
|Role/Name|Contact|Expectations
|Role/Name|Members|Expectations
| *Students* | Andrés Cadenas Blanco, Christian Fernandez Noriega , Adrián González Guadalupe and Luis Salvador Ferrero | Are the ones in charge of web development. They will work together to make the application.
| *Teachers* | Pablo González | In charge of supervising the student's teamwork, ensuring the work accomplishes the goals in the best way possible and helping in the development and solving doubts.
| *Bussineses* | RTve has hired software development company HappySw | Emphasis the SOLID part of the web and have a high understanding of this area
| *Users* | Anyone that wants to use the web | They should be able to understand how to use and move around the web with ease
|===

16 changes: 9 additions & 7 deletions docs/src/03_system_scope_and_context.adoc
Original file line number Diff line number Diff line change
@@ -57,14 +57,16 @@ the past games of all users in a ranking.
----
actor Player
[Wikidata] <<external>>
database MongoDB
[WIQ Game] <<system>>
[UsersAPI] <<internal>>
[questionsAPI] <<internal>>
[QuestionsAPI] <<internal>>
Player ..> (WIQ Game) : register/login
[questionsAPI] ..> Wikidata
[QuestionsAPI] ..> Wikidata
[WIQ Game] ..> UsersAPI
[WIQ Game] ..> questionsAPI
[WIQ Game] ..> QuestionsAPI
[UsersAPI] <--> MongoDB
----

[cols="e,2e" options="header"]
@@ -80,12 +82,12 @@ Player ..> (WIQ Game) : register/login
|Wikidata
|External data repository from which questions are generated

|MongoDB
|Database for storing players' info and scores

|Users Info API
|Manages data of users, both registration/login data and their past scores

|questions API
|Questions API
|Manages generation of questions from Wikidata

|MongoDB
|Database for storing players' info and scores
|===
13 changes: 9 additions & 4 deletions docs/src/04_solution_strategy.adoc
Original file line number Diff line number Diff line change
@@ -2,17 +2,22 @@ ifndef::imagesdir[:imagesdir: ../images]

[[section-solution-strategy]]
== Solution Strategy
The system will follow *MERN Stack* as they key technologies for its development. MERN Stack is composed of four technologies: MongoDB, Express,
React and Node.
The web application is based on MERN stack, a full-stack combination of technologies for developing JavaScript applications fast.

* *Node:* the JavaScript runtime environment.
* *MongoDB:* chosen technology for the persistance of the system, that provides a non-relational database approach.
* *Express and Node:* they make up the middle tier of the application, providing a powerful server platform.
* *React:* front-end framework that will solve the client interfaces of the application.

Regarding the deployment of the application, the strategy chosen is a *cloud-based* system. This decision was motivated because of the nature of
the project, since a web application needs to be live and accesible at any moment.
The server will be an ARM *Linux VM on Oracle*, where some *containers* for different charasteristics of the system will be *deployed with
Docker* (MongoDB database for example)
The server will be an ARM *Linux VM on Oracle*, where some *dockerized microservices* will be deployed:

* users service
* authentication service
* questions service
* web application
* gateway

[role="arc42help"]
****
15 changes: 5 additions & 10 deletions docs/src/05_building_block_view.adoc
Original file line number Diff line number Diff line change
@@ -74,11 +74,11 @@ actor Player
rectangle "WIQ Game (Level 1)"{
[WIQ Game GUI]
[UsersAPI]
[questionsAPI] #BurlyWood
[QuestionsAPI] #BurlyWood
Player ..> (WIQ Game GUI)
[questionsAPI] ..> Wikidata
[QuestionsAPI] ..> Wikidata
[WIQ Game GUI] ..> UsersAPI
[WIQ Game GUI] ..> questionsAPI
[WIQ Game GUI] ..> QuestionsAPI
}
----

@@ -153,7 +153,7 @@ Please prefer relevance over completeness. Specify important, surprising, risky,
Leave out normal, simple, boring or standardized parts of your system
****

==== questions API (White Box)
==== Questions API (White Box)

This is the Component that holds the functionallity for the main purpose of the webapp: Allowing players to see questions and
answer them, getting a consequent score update.
@@ -163,17 +163,15 @@ answer them, getting a consequent score update.
...describes the internal structure of _building block 1_.
****

[plantuml,"questions API (WhiteBox)",png]
[plantuml,"Questions API (WhiteBox)",png]
----
[Wikidata]
[wikibase-sdk] <<library>>
[WIQ Game GUI]
database MongoDB
rectangle "questionsAPI (Level 2)"{
[question-service] ..> [wikibase-sdk]
[question-service] ..> [Wikidata]
[question-service] <--> MongoDB
[WIQ Game GUI] ..> [question-service] : new question
[WIQ Game GUI] ..> [question-service] : validate answer
}
@@ -195,7 +193,4 @@ Contained Black boxes::
|wikibase-sdk
|External library that facilitates and simplifies the use of wikidata for the generation of questions.

|MongoDB
|Data about users and their scores is stored here

|===
75 changes: 45 additions & 30 deletions docs/src/06_runtime_view.adoc
Original file line number Diff line number Diff line change
@@ -6,44 +6,59 @@ ifndef::imagesdir[:imagesdir: ../images]
* Users will log in through the web interface of the application.
* The system stores in database (MongoDB) the users of the application. Any data relative to them is accessed via the database of the system and displayed to them on the web interface.

=== Access to user data via API
* Clients access the users data through an API
=== Rankings
* Users can see the rankings for a specific category

[plantuml,"users diagram",png]
[plantuml,"rankings_diagram",png]
----
entity Client
database API as "Users API"
Client -> API: Data querying request
API --> Client: Retrieved data
entity User
entity WEBC as "Web Client"
entity GW as "Gateway"
entity USERS as "Users service"
database DB as "MongoDB"
User -> WEBC: Navigate to rankings page
WEBC -> GW: Get global statistics
GW -> USERS: Get global statistics
USERS -> DB: Get statistics matching the specified category
USERS -> GW: Send global statistics
GW -> WEBC: Send global statistics
User -> WEBC: Select a category
WEBC -> GW: Get category statistics
GW -> USERS: Get category statistics
USERS -> DB: Get statistics matching the specified category
USERS -> GW: Send category statistics
GW -> WEBC: Send category statistics
----

=== Access to questions data via API
* Clients access the questions data through an API
=== Playing
* An overview of the runtime scenario of a game.

[plantuml,"questions diagram",png]
----
entity Client
database API as "Questions API"
Client -> API: Data querying request
API --> Client: Retrieved data
----

=== Game
* Users can play a Q&A game

[plantuml,"game diagram",png]
[plantuml, "playing_diagram", png]
----
actor User
entity App
database QAPI as "Questions API"
database UAPI as "Users API"
User -> App: Start Game
App -> QAPI: Get Questions
QAPI --> App: Send Questions
App -> User: Show Question
User --> App: Answer Question
App -> UAPI: Update User Data
entity WEBC as "Web Client"
entity GW as "Gateway"
entity QU as "Questions API"
entity USERS as "Users Service API"
database DB as "MongoDB"
User -> WEBC: Select a category to play
WEBC -> GW: Get a question
GW -> QU: Get a question
QU -> QU: Store question and answer
QU -> GW: Send question to client
GW -> WEBC: Send question to client
User -> WEBC: Answer question
WEBC -> GW: Send answer
GW -> QU: Send answer
QU -> QU: Check answer
QU -> USERS: Update user points
USERS -> DB: Modify user data
QU -> GW: Send result and new question
GW -> WEBC: Send result and new question
----

[role="arc42help"]
****
.Contents
3 changes: 0 additions & 3 deletions docs/src/07_deployment_view.adoc
Original file line number Diff line number Diff line change
@@ -121,9 +121,6 @@ MDB-[dashed]->AS
US-[dashed]->MDB
MDB-[dashed]->US
QS-[dashed]->MDB
MDB-[dashed]->QS
----
The architecture of WIQ is based on microservices. Gateway service is the main entry point for the system. The web application is the main interface for the user to interact with the system. The user service is responsible for managing users. The authorization service is responsible for managing user permissions. The question service is responsible for generating questions. The mongo database is used to store data.

9 changes: 9 additions & 0 deletions docs/src/08_concepts.adoc
Original file line number Diff line number Diff line change
@@ -55,6 +55,15 @@ image::08-Crosscutting-Concepts-Structure-EN.png["Possible topics for crosscutti
See https://docs.arc42.org/section-8/[Concepts] in the arc42 documentation.
****

=== _Continuous integration and development_

Our way of working with github is having one developing branch towards which all pull requests are done
and have to be reviewed by at least one team member.
Sonar Cloud is set up so we know the testing coverage and whether we pass the quality gate with every pull request.
Once there is enough change in the develop branch with respect to the main one and it has enough quality we can
merge them and make a release.


=== _Microservice based system_

Different business functionallities will be developed in different independent services.
19 changes: 19 additions & 0 deletions gatewayservice/gateway-service.js
Original file line number Diff line number Diff line change
@@ -127,6 +127,25 @@ app.get('/rankings/:filter', async (req, res) => {
}
});


app.get('/ranking/user', async (req, res) => {
const username = req.query.username;

try {
// Forward the request to the user service
const result = await axios.get(`${userServiceUrl}/ranking/user`, {
params: {
username: username
}
});

res.json(result.data);

} catch (error) {
res.status(error.response.status).json({ error: error.response.data.error });
}
});

// Read the OpenAPI YAML file synchronously
openapiPath='./openapi.yaml'
if (fs.existsSync(openapiPath)) {
40 changes: 38 additions & 2 deletions gatewayservice/gateway-service.test.js
Original file line number Diff line number Diff line change
@@ -59,7 +59,7 @@ describe('Gateway Service', () => {
}
});

axios.get.mockImplementation((url) => {
axios.get.mockImplementation((url,data) => {
if (url.endsWith('/imgs/flags/question')) {
return generateMockResponse(url, [
{ item: 'Flag0', itemLabel: 'Country0', image: 'flag0.jpg' },
@@ -87,8 +87,15 @@ describe('Gateway Service', () => {
]);
} else if (url.endsWith('/rankings/category1')) {
return Promise.resolve({ data: mockUserData });
} else if (url.endsWith('/ranking/user')) {
return Promise.resolve({data:{
username: data.params.username,
points: 5,
questions: 7,
correct: 5,
wrong: 2
}});
}

});

// Test /login endpoint
@@ -263,6 +270,35 @@ it('should forward get Foods request to question service', async () => {

});

// Test /health endpoint
it('should inform that the health is OK if the service is operative', async () => {

// Send POST request to gateway endpoint
const response = await request(app)
.get('/health')

// Verify response body
expect(response.body.status).toEqual('OK');
});

// Test /ranking/user
it('should respond with the ranking info of the given user', async () => {

// Send POST request to gateway endpoint
const response = await request(app)
.get('/ranking/user')
.query({
username: "username",
})

// Verify response body
expect(response.body).toEqual({
username: "username",
points: 5,
questions: 7,
correct: 5,
wrong: 2
});
});

});
2 changes: 1 addition & 1 deletion gatewayservice/monitoring/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
global:
scrape_interval: 5s
scrape_configs:
- job_name: "example-nodejs-app"
- job_name: "WIQ"
static_configs:
- targets: ["gatewayservice:8000"]
35 changes: 21 additions & 14 deletions questionservice/question-service.js
Original file line number Diff line number Diff line change
@@ -212,26 +212,33 @@ app.get('/imgs/foods/question', async (req, res) => {
app.post('/imgs/answer', async (req, res) => {
try {
const obj = req.body;

if (obj.question == answerToQuestionMap.get(obj.answer)) {
await axios.post(userServiceUrl + '/addpoints',
{ username: obj.username, category: obj.category, correct: "true" });
res.status(200).json({
correct: "true",
})
if (obj.answer !== "TimeOut1234;") {
if (obj.question == answerToQuestionMap.get(obj.answer)) {
await axios.post(userServiceUrl + '/addpoints',
{ username: obj.username, category: obj.category, correct: "true" });
res.status(200).json({
correct: "true",
})
} else {
await axios.post(userServiceUrl + '/addpoints',
{ username: obj.username, category: obj.category, correct: "false" });
res.status(200).json({
correct: "false",
correctImg: `${[...answerToQuestionMap].find(([key, val]) => val == obj.question)[0]}`
})
}
} else {
await axios.post(userServiceUrl + '/addpoints',
{ username: obj.username, category: obj.category, correct: "false" });

res.status(200).json({
correct: "false",
associate: `${imgToAssociatedMap.get(obj.answer)}`
correctImg: `${[...answerToQuestionMap].find(([key, val]) => val == obj.question)[0]}`
})
}
} catch (e) { //SIEMPRE RODEAR CON TRY CATCH
res.status(500).json({ error: e.message })
}
});
} catch (e) { //SIEMPRE RODEAR CON TRY CATCH
console.error(e)
res.status(500).json({ error: e.message })
}
});

const server = app.listen(port, () => {
console.log(`Questions service listening on http://localhost:${port}`);
25 changes: 21 additions & 4 deletions questionservice/question-service.test.js
Original file line number Diff line number Diff line change
@@ -18,8 +18,8 @@ jest.spyOn(global, 'fetch').mockImplementation(() => {
let result = { results: {bindings: []} }
for(let i=1;i<=100;i++){
//Simulating there is maximum number of repeated itemLabels (between the valid ones)
result.results.bindings.push({itemLabel: {value: "itemName1"} , image:{value: "imageUrl1_"+i}})
imgsToAssociatesMap.set("imageUrl1_"+i, "itemName1")
result.results.bindings.push({itemLabel: {value: "itemName"+i} , image:{value: "imageUrl1_"+i}})
imgsToAssociatesMap.set("imageUrl1_"+i, "itemName"+i)
}
for(let i=101;i<=195;i++){
//Simulating there are invalid itemLabels
@@ -113,7 +113,7 @@ describe('Question Service', () => {
});

//Test /imgs/answer endpoint (Incorrect answer)
it('should inform the answer is incorrect and what is the element associated to the answer', async () => {
it('should inform the answer is incorrect and what is the correct answer if answering incorrectly', async () => {
//First I ask a question
const response = await request(app).get('/imgs/foods/question');
regex = new RegExp(`Which of the following images corresponds to (\\w+)\\?`);
@@ -135,7 +135,24 @@ describe('Question Service', () => {
.set('Content-Type', 'application/json')
.send({answer:incorrectImageAnswer, question:question, username:"username", category:"foods"})
expect(responseAnswer.body.correct).toBe("false")
expect(responseAnswer.body.associate).toBe(imgsToAssociatesMap.get(incorrectImageAnswer))
expect(responseAnswer.body.correctImg).toBe([...imgsToAssociatesMap].find(([key, val]) => val == correctAnswerLabel)[0])
});

//Test /imgs/answer endpoint (Timeout)
it('should inform the answer is incorrect and what is the correct answer if a timeout happens', async () => {
//First I ask a question
const response = await request(app).get('/imgs/foods/question');
regex = new RegExp(`Which of the following images corresponds to (\\w+)\\?`);
const match = response.body.question.match(regex);
const correctAnswerLabel = match && match[1];

question = response.body.question
const responseAnswer = await request(app)
.post("/imgs/answer")
.set('Content-Type', 'application/json')
.send({answer:"TimeOut1234;", question:question, username:"username", category:"foods"})
expect(responseAnswer.body.correct).toBe("false")
expect(responseAnswer.body.correctImg).toBe([...imgsToAssociatesMap].find(([key, val]) => val == correctAnswerLabel)[0])
});
});

1 change: 1 addition & 0 deletions users/authservice/auth-service.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ app.use(express.json());

// Connect to MongoDB
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/wiq-en1a-users';

mongoose.connect(mongoUri);

// Function to validate required fields in the request body
23 changes: 23 additions & 0 deletions users/userservice/user-service.js
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ app.use(bodyParser.json());
// Connect to MongoDB
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/wiq-en1a-users';
mongoose.connect(mongoUri);
console.log(mongoUri);



@@ -54,6 +55,27 @@ app.get('/rankings/:filter', async (req, res) => {
})


// Get the ranking info for a specified category and user
app.get('/ranking/user', async (req, res) => {
const username = req.query.username;

try {
// Fetch the user with the specified username
const user = await User.findOne({ username });

// If user not found, return error
if (!user) {
return res.status(400).json("Error: User not found");
}

res.status(200).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});



app.post("/addpoints", async (req, res) => {
const username = req.body.username;
const category = req.body.category;
@@ -95,6 +117,7 @@ app.get('/rankings/:filter', async (req, res) => {

app.post('/adduser', async (req, res) => {
try {
console.log(mongoUri);
// Check if required fields are present in the request body
validateRequiredFields(req, ['username','email', 'password']);

3 changes: 3 additions & 0 deletions webapp/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build/
src/
*.js
18 changes: 14 additions & 4 deletions webapp/e2e/features/login-form.feature
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Feature: Registering a new user
Feature: Login a registered user

Scenario: The user is registered in the site
Given A registered user
When I fill the data in the form and press submit
Then is logged
Given An registered user
When I fill the data in the form to log in
Then is taken to the home page

Scenario: User logs in with invalid credentials
Given a registered user with username "testUser" and password "testpass"
When I fill the login form with username "testUser" and incorrect password "wrongpass"
And I remain on the login page

Scenario: User attempts to login without entering credentials
Given a registered user with username "testUser" and password "testpass"
When I attempt to log in without entering any credentials
And I remain on the login page
17 changes: 17 additions & 0 deletions webapp/e2e/features/userprofile-form.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Feature: View and Change User Quiz Rankings

Scenario: Viewing Global Rankings
Given the user navigates to their profile
When they select the "Global" category
Then they see their performance statistics for global quizzes

Scenario: Switching Category to Flags
Given the user is on their profile page
When they click on the "Flags" category
Then they view their performance metrics for flag-related quizzes

Scenario: Switching Category to Food
Given the user is on their profile page
When they click on the "Food" category
Then they view their performance metrics for food-related quizzes

132 changes: 76 additions & 56 deletions webapp/e2e/steps/login-form.steps.js
Original file line number Diff line number Diff line change
@@ -3,69 +3,89 @@ const { defineFeature, loadFeature }=require('jest-cucumber');
const setDefaultOptions = require('expect-puppeteer').setDefaultOptions
const feature = loadFeature('./features/login-form.feature');

const axios = require('axios');
const MockAdapter = require('axios-mock-adapter');
const mockAxios = new MockAdapter(axios);

let page;
let browser;

defineFeature(feature, test => {

beforeAll(async () => {
browser = process.env.GITHUB_ACTIONS
? await puppeteer.launch()
: await puppeteer.launch({ headless: false, slowMo:40 });
page = await browser.newPage();
//Way of setting up the timeout
setDefaultOptions({ timeout: 10000 })

await page
.goto("http://localhost:3000", {
waitUntil: "networkidle0",
})
.catch(() => {});


});

beforeEach(async () => {
// Reset any state or actions before each test
await page.reload({ waitUntil: 'networkidle0' });
beforeAll(async () => {

browser = process.env.GITHUB_ACTIONS
? await puppeteer.launch()
: await puppeteer.launch({ headless: false, slowMo: 30 });
page = await browser.newPage();
//Way of setting up the timeout
setDefaultOptions({ timeout: 10000 })

await page
.goto("http://localhost:3000/login", {
waitUntil: "networkidle0",
})
.catch(() => {});
});

test('The user is registered in the site', ({given,when,then}) => {

let username;
let password;

given('An registered user', async () => {
username = "testUser"
password = "testpass";

});

test('The user is registered in the site', ({given,when,then}) => {

let username;
let password;
let email

given('A registered user', async () => {
username = "t1"
password = "t1pass"

await expect(page).toClick("button", { text: "Create account" });
});

when('I fill the data in the form and press submit', async () => {
await expect(page).toClick('a', { text: 'Already have an account? Log in here.' });

when('I fill the data in the form to log in', async () => {
await expect(page).toFill('input[name="username"]', username);
await expect(page).toFill('input[name="password"]', password);

mockAxios.onPost('http://localhost:8000/login').reply(200, { username:"t1",email:"t1email",createdAt: '2024-01-01T12:34:56Z',token: 'testToken'});


await expect(page).toClick('button', { text: 'Login' })
});

then('is logged', async () => {
await expect(page).toMatchElement("div", { text: "Welcome back, " + username + "!" });
});
})

afterAll(async ()=>{
browser.close()
})

});
await expect(page).toClick("button", { text: "Log In" });
});

then('is taken to the home page', async () => {
await page.waitForNavigation({ waitUntil: "networkidle0" });
await expect(page).toMatchElement("h1", { text: "Welcome back, " + username + "!" });
await expect(page).toClick("button", { text: "Log out" });
});

});

test('User logs in with invalid credentials', ({ given, when, then }) => {
given('a registered user with username "testUser" and password "testpass"', async () => {
// No specific action needed since the user is already registered
});

when('I fill the login form with username "testUser" and incorrect password "wrongpass"', async () => {
await expect(page).toFill('input[name="username"]', 'testUser');
await expect(page).toFill('input[name="password"]', 'wrongpass');
await expect(page).toClick("button", { text: "Log In" });
});


then('I remain on the login page', async () => {
await expect(page).toMatchElement("h1", { text: "Access WIQ" });
});
});

test('User attempts to login without entering credentials', ({ given, when, then }) => {
given('a registered user with username "testUser" and password "testpass"', async () => {
// No specific action needed since the user is already registered
});

when('I attempt to log in without entering any credentials', async () => {
await expect(page).toFill('input[name="username"]', '');
await expect(page).toFill('input[name="password"]', '');
await expect(page).toClick("button", { text: "Log In" });
});


then('I remain on the login page', async () => {
await expect(page).toMatchElement("h1", { text: "Access WIQ" });
});
});

afterAll(async ()=>{
browser.close()
})

});
42 changes: 14 additions & 28 deletions webapp/e2e/steps/register-form.steps.js
Original file line number Diff line number Diff line change
@@ -3,68 +3,54 @@ const { defineFeature, loadFeature }=require('jest-cucumber');
const setDefaultOptions = require('expect-puppeteer').setDefaultOptions
const feature = loadFeature('./features/register-form.feature');

const axios = require('axios');
const MockAdapter = require('axios-mock-adapter');
const mockAxios = new MockAdapter(axios);

let page;
let browser;

defineFeature(feature, test => {

beforeAll(async () => {

browser = process.env.GITHUB_ACTIONS
? await puppeteer.launch()
: await puppeteer.launch({ headless: false, slowMo:5 });
: await puppeteer.launch({ headless: false, slowMo: 30 });
page = await browser.newPage();
//Way of setting up the timeout
setDefaultOptions({ timeout: 10000 })

await page
.goto("http://localhost:3000", {
.goto("http://localhost:3000/login", {
waitUntil: "networkidle0",
})
.catch(() => {});


});

beforeEach(async () => {
// Reset any state or actions before each test
await page.reload({ waitUntil: 'networkidle0' });
});

test('The user is not registered in the site', ({given,when,then}) => {

let username;
let password;
let email

given('An unregistered user', async () => {
username = "t1"
email = "t1email"
password = "t1pass"

await expect(page).toClick("button", { text: "Create account" });
username = "newUser"
password = "newUser"
await expect(page).toClick("a", { text: "Create account" });
});

when('I fill the data in the form and press submit', async () => {

await expect(page).toFill('input[name="username"]', username);
await expect(page).toFill('input[name="email"]', email);
await expect(page).toFill('input[name="email"]', username + "@" + "gmail.com");
await expect(page).toFill('input[name="password"]', password);
await expect(page).toFill('input[name="cpassword"]', password);

// mockAxios.onPost('http://localhost:8000/adduser').reply(200, { username: "t1", email: "t1email", password: "t1pass" });
mockAxios.onPost('http://localhost:8000/adduser').reply(200, { username:"t1",email:"t1email",password: 't1pass'});


await expect(page).toClick('button', { text: 'Register' })
await expect(page).toFill('input[name="cpassword"]', password);
await expect(page).toClick("button", { text: "Register" });
});

then('is taken to login', async () => {
//await expect(page).toMatchElement("div", { text: "Login" });
await page.waitForNavigation({ waitUntil: "networkidle0" });
await expect(page).toMatchElement("h1", { text: "Access WIQ" });
});
})

});

afterAll(async ()=>{
browser.close()
107 changes: 107 additions & 0 deletions webapp/e2e/steps/userprofile-form.steps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const puppeteer = require('puppeteer');
const { defineFeature, loadFeature }=require('jest-cucumber');
const setDefaultOptions = require('expect-puppeteer').setDefaultOptions
const feature = loadFeature('./features/userprofile-form.feature');


let page;
let browser;

defineFeature(feature, test => {

beforeAll(async () => {

browser = process.env.GITHUB_ACTIONS
? await puppeteer.launch()
: await puppeteer.launch({ headless: false, slowMo: 20 });
page = await browser.newPage();
//Way of setting up the timeout
setDefaultOptions({ timeout: 10000 })

await page
.goto("http://localhost:3000/login", {
waitUntil: "networkidle0",
})
.catch(() => {});
});

test('Viewing Global Rankings', ({given,when,then}) => {

let username;
let password;

given('the user navigates to their profile', async () => {
username = "testUser"
password = "testpass";
await expect(page).toClick("a", { text: "Log In" });
await expect(page).toFill('input[name="username"]', username);
await expect(page).toFill('input[name="password"]', password);
await expect(page).toClick("button", { text: "Log In" });
await expect(page).toClick("button", { text: "My stats" });
});

when('they select the "Global" category', async () => {

await expect(page).toMatchElement("h2", { text: "Username: " + username });
await expect(page).toClick("button", { text: "Flags" });
await expect(page).toClick("button", { text: "Global" });
});

then('they see their performance statistics for global quizzes', async () => {
await expect(page).toMatchElement('.ranking h3', { text: "global Ranking" });
await expect(page).toMatchElement(".ranking p:nth-child(1)", { text: "Total Answered Questions: " + 1 });
});

});

test('Switching Category to Flags', ({given,when,then}) => {

let username;
let password;

given('the user is on their profile page', async () => {
username = "testUser"
password = "testpass";
await expect(page).toMatchElement("h2", { text: "Username: " + username });
});

when('they click on the "Flags" category', async () => {

await expect(page).toClick("button", { text: "Flags" });
});

then('they view their performance metrics for flag-related quizzes', async () => {
await expect(page).toMatchElement('.ranking h3', { text: "flags Ranking" });
await expect(page).toMatchElement(".ranking p:nth-child(1)", { text: "Total Answered Questions: " + 1 });
});

});

test('Switching Category to Food', ({given,when,then}) => {

let username;
let password;

given('the user is on their profile page', async () => {
username = "testUser"
password = "testpass";
await expect(page).toMatchElement("h2", { text: "Username: " + username });
});

when('they click on the "Food" category', async () => {

await expect(page).toClick("button", { text: "Food" });
});

then('they view their performance metrics for food-related quizzes', async () => {
await expect(page).toMatchElement('.ranking h3', { text: "foods Ranking" });
await expect(page).toMatchElement(".ranking p:nth-child(1)", { text: "Total Answered Questions: " + 0 });
});

});

afterAll(async ()=>{
browser.close()
})

});
28 changes: 28 additions & 0 deletions webapp/e2e/test-environment-setup.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const { MongoMemoryServer } = require('mongodb-memory-server');
const User = require('../../users/userservice/user-model');
const axios = require('axios');


let mongoserver;
@@ -9,11 +11,37 @@ let gatewayservice;
async function startServer() {
console.log('Starting MongoDB memory server...');
mongoserver = await MongoMemoryServer.create();

const mongoUri = mongoserver.getUri();
process.env.MONGODB_URI = mongoUri;
userservice = await require("../../users/userservice/user-service");
authservice = await require("../../users/authservice/auth-service");
gatewayservice = await require("../../gatewayservice/gateway-service");

// Add test user
await addUser('testUser', 'test@email', 'testpass');

}

async function addUser(username, email, password) {
try {
const response = await axios.post('http://localhost:8001/adduser', {
username: username,
email: email,
password: password
});

const pints = await axios.post('http://localhost:8001/addpoints', {
username: username,
category: "flags",
correct: "true"
});

} catch (error) {
console.error('Error adding user:', error.response.data);
}
}



startServer();
21,174 changes: 21,089 additions & 85 deletions webapp/package-lock.json

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions webapp/package.json
Original file line number Diff line number Diff line change
@@ -3,9 +3,11 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.3",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@material-tailwind/react": "^2.1.9",
"@mui/icons-material": "^5.15.15",
"@mui/material": "^5.15.15",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
@@ -22,7 +24,7 @@
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "react-scripts build ",
"prod": "serve -s build",
"test": "react-scripts test --transformIgnorePatterns 'node_modules/(?!axios)/'",
"test:e2e": "start-server-and-test 'node e2e/test-environment-setup.js' http://localhost:8000/health prod 3000 \"cd e2e && jest\"",
Binary file added webapp/public/WIQProvFondo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added webapp/public/WIQProvSin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified webapp/public/favicon.ico
Binary file not shown.
Binary file added webapp/public/profile_img.webp
Binary file not shown.
11 changes: 8 additions & 3 deletions webapp/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React from 'react';
import {BrowserRouter, Routes, Route} from 'react-router-dom'
import AddUser from './components/AddUser';
import AddUser from './components/auth/AddUser';
import Navbar from './components/Navbar';
import Login from './components/Login';
import Login from './components/auth/Login';
import RankingsLayout from './components/ranking/RankingLayout';
import Game from './components/Game';
import Game from './components/game/Game';
import MainPage from './components/MainPage';
import Squads from './components/Squads';
import UserProfile from './components/user/UserProfile';


function App() {
// const isAuthenticated = useIsAuthenticated() // True if user has logged in
@@ -23,6 +26,8 @@ function App() {
<Route path='/register' element={<AddUser />} />
<Route path='/rankings' element={<RankingsLayout />} />
<Route path='/play' element={<Game />} />
<Route path='/squads' element={<Squads />} />
<Route path='/userprofile' element={<UserProfile />} />
</Routes>
</BrowserRouter>
)
96 changes: 0 additions & 96 deletions webapp/src/components/AddUser.jsx

This file was deleted.

77 changes: 0 additions & 77 deletions webapp/src/components/Game.jsx

This file was deleted.

35 changes: 0 additions & 35 deletions webapp/src/components/Game.test.js

This file was deleted.

49 changes: 34 additions & 15 deletions webapp/src/components/MainPage.jsx
Original file line number Diff line number Diff line change
@@ -10,31 +10,50 @@ const MainPage = () => {
const auth = useAuthUser();
const navigate = useNavigate();
useEffect(() => {
if(isAuthenticated()===false) {

if (isAuthenticated() === false) {
logged = false;
}else{
} else {
logged = true;
}
}

}
, [logged]);
, [logged]);

return (
<div className="flex flex-col items-center justify-center mt-16">
<div className="flex flex-col items-center justify-center text-center bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900" style={{height: "92.9vh"}}>
{logged ?
<div>
<h1 className="text-6xl font-bold text-zinc-700">Welcome back, {auth.username}!</h1>
<hr className=" my-8"/>
<button className="px-8 py-4 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold rounded-full transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => navigate("/play")}>
Start Playing
</button>
</div>:

<h1 className="text-6xl font-bold mb-8 text-white">Welcome back, {auth.username}!</h1>


<div className="flex flex-col items-center justify-center">

<div className="basis-1 flex flex-col items-center justify-center">
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg " onClick={() => navigate("/play")}>
Start Playing
</button>
</div>
<div className="flex flex-row mt-5 " >
<button className=" flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => navigate("/squads")}>
Squads
</button>
<div className="mx-1"/>

<button className=" flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => navigate("/userProfile")}>
My stats
</button>
</div>
</div>
</div> :
<div>
<h1 className="text-6xl font-bold text-zinc-700">Welcome to WIQ, Please log in to play!</h1>
<button className="px-8 py-4 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold rounded-full transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => navigate("/register")}>
<h1 className="text-6xl font-bold mb-8 text-white">Welcome to WIQ, Please log in to play!</h1>
<div className="basis-1 flex flex-col items-center justify-center">
<button className=" flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => navigate("/register")}>
Create account
</button>
</div>
</div>
}
</div >
32 changes: 30 additions & 2 deletions webapp/src/components/MainPage.test.js
Original file line number Diff line number Diff line change
@@ -18,13 +18,41 @@ describe('MainPage', () => {
useAuthUser.mockReturnValue({ username: 'testUser' });
const { getByText } = render(<MainPage />);
expect(getByText('Welcome back, testUser!')).toBeInTheDocument();
expect(getByText('Start Playing')).toBeInTheDocument();
expect(getByText('Squads')).toBeInTheDocument();
expect(getByText('My stats')).toBeInTheDocument();
});

it('renders welcome message for unauthenticated user', () => {
useIsAuthenticated.mockReturnValue(() => false);
const { getByText } = render(<MainPage />);
expect(getByText('Welcome to WIQ, Please log in to play!')).toBeInTheDocument();
});


it('navigates to /play when Start Playing button is clicked', () => {
useIsAuthenticated.mockReturnValue(() => true);
useAuthUser.mockReturnValue({ username: 'testUser' });
const { getByText } = render(<MainPage />);
fireEvent.click(getByText('Start Playing'));
expect(mock).toHaveBeenCalledWith('/play');
});
it('navigates to /squads when Squads button is clicked', () => {
useIsAuthenticated.mockReturnValue(() => true);
useAuthUser.mockReturnValue({ username: 'testUser' });
const { getByText } = render(<MainPage />);
fireEvent.click(getByText('Squads'));
expect(mock).toHaveBeenCalledWith('/squads');
});
it('navigates to /userProfile when My stats button is clicked', () => {
useIsAuthenticated.mockReturnValue(() => true);
useAuthUser.mockReturnValue({ username: 'testUser' });
const { getByText } = render(<MainPage />);
fireEvent.click(getByText('My stats'));
expect(mock).toHaveBeenCalledWith('/userProfile');
});
it('navigates to /register when Create account button is clicked', () => {
useIsAuthenticated.mockReturnValue(() => false);
const { getByText } = render(<MainPage />);
fireEvent.click(getByText('Create account'));
expect(mock).toHaveBeenCalledWith('/register');
});
});
87 changes: 69 additions & 18 deletions webapp/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -2,7 +2,14 @@ import { Link } from 'react-router-dom';
import useIsAuthenticated from 'react-auth-kit/hooks/useIsAuthenticated';
import useSignOut from 'react-auth-kit/hooks/useSignOut';
import { useNavigate } from 'react-router-dom';
function Navbar() {
import React from 'react'
import {
Collapse,
IconButton,
} from "@material-tailwind/react";
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import ClearRoundedIcon from '@mui/icons-material/ClearRounded';
function NavbarDefault() {
const isAuthenticated = useIsAuthenticated();
const signOut = useSignOut();
const navigate = useNavigate();
@@ -11,24 +18,68 @@ function Navbar() {
navigate('/login');
}

const [openNav, setOpenNav] = React.useState(false);

React.useEffect(() => {
window.addEventListener(
"resize",
() => window.innerWidth >= 960 && setOpenNav(false),
);
}, []);
const navList = (
// <div class="hidden w-full md:block md:w-auto" >
<ul className="font-medium flex flex-col p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-#111827 md:flex-row md:space-x-8 rtl:space-x-reverse md:mt-0 md:border-0 md:bg-#111827 bg-#111827 md:bg-#111827 border-gray-700">
<li>
<Link to="/play" className="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 text-white md:hover:text-blue-500 hover:bg-gray-700 hover:text-white md:hover:bg-transparent" aria-current="page">Play</Link>
</li>
<li>
<Link to="/squads" className="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 text-white md:hover:text-blue-500 hover:bg-gray-700 hover:text-white md:hover:bg-transparent">Squads</Link>
</li>
<li>
<Link to="/rankings" className="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 text-white md:hover:text-blue-500 hover:bg-gray-700 hover:text-white md:hover:bg-transparent">Rankings</Link>
</li>
{isAuthenticated() ? (
<li><Link className="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:p-0 md:hover:text-blue-500 text-white hover:bg-gray-700 hover:text-white md:hover:bg-transparent border-gray-700" to="/userprofile">My Profile</Link></li>
) : ""}

</ul>
//</div>

);

return (
<header className="lg:px-16 px-4 bg-white flex flex-wrap items-center py-4 shadow-md">
<div className="flex-1 flex justify-between items-center">
<Link to="/" className="text-xl font-bold">WIQ</Link>
</div>
<div className="hidden md:flex md:items-center md:w-auto w-full" id="menu">
<nav>
<ul className="md:flex items-center justify-between text-base text-gray-700 pt-4 md:pt-0">
<li><Link className="md:p-4 py-3 px-0 block font-bold text-gray-600 hover:text-gray-900" to='/play' >Play</Link></li>
<li><Link className="md:p-4 py-3 px-0 block font-bold text-gray-600 hover:text-gray-900" to="/rankings">Rankings</Link></li>
{isAuthenticated() ? <button class="px-8 py-4 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold rounded-full transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={() => Logout()}>
Logout
</button> : <li><Link className="md:p-4 py-3 px-0 block font-bold text-sky-500 hover:text-sky-800" to="/login">Sign in</Link></li>}
</ul>
</nav>
</div>
</header>

<nav className="bg-#111827 border-gray-200 bg-gray-900">
<div className="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<div className="flex items-center space-x-3 rtl:space-x-reverse transition-transform transform-gpu cursor-pointer hover:-translate-y-1 hover:shadow-lg">
<img src="/WIQProvFondo.png" className="h-8" alt="WIQ Logo"></img>
<Link to="/" className="self-center text-2xl font-semibold whitespace-nowrap text-white ">WIQ</Link>
</div>
<div className="hidden lg:block ">{navList}</div>
<div className="grid grid-cols-2 content-center justify-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
{isAuthenticated() ? <button type="button" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 text-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-800" onClick={() => Logout()}>Log out</button> : <Link to="/login" type="button" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 text-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-800" >Log In</Link>}

<IconButton
variant="text"
className="ml-auto h-6 w-6 text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden justify-center content-center"
ripple={false}
onClick={() => setOpenNav(!openNav)}
>{openNav ? (
<ClearRoundedIcon sx={{ color: "white" }}/>
) : (
<MenuRoundedIcon sx={{ color: "white" }}/>

)}
</IconButton>
</div>
</div>
<Collapse open={openNav}>
<div className="container mx-auto my-auto justify-center content-center">
{navList}
</div>
</Collapse>
</nav >
)
}

export default Navbar
export default NavbarDefault
63 changes: 63 additions & 0 deletions webapp/src/components/Navbar.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Navbar from './Navbar';
import useIsAuthenticated from 'react-auth-kit/hooks/useIsAuthenticated';
import useSignOut from 'react-auth-kit/hooks/useSignOut';
import { getByText, getAllByText } from '@testing-library/dom';

jest.mock('react-auth-kit/hooks/useIsAuthenticated');
jest.mock('react-auth-kit/hooks/useSignOut');

describe('Navbar', () => {

beforeEach(() => {
// Reset mock function calls before each test
jest.clearAllMocks();
});

it('renders authenticated user links and logout button', () => {
useIsAuthenticated.mockReturnValue(() => true);
const { getByText } = render(
<MemoryRouter>
<Navbar />
</MemoryRouter>
);

expect(getByText('WIQ')).toBeInTheDocument();
//New Imlementation throws Type Error as they are strings
// expect(getAllByText('Play')).toBeInTheDocument();
// expect(getAllByText('Rankings')).toBeInTheDocument();
// expect(getAllByText('My Profile')).toBeInTheDocument();
expect(getByText('Log out')).toBeInTheDocument();
});

it('renders unauthenticated user links and sign-in link', () => {
useIsAuthenticated.mockReturnValue(() => false);
const { getByText } = render(
<MemoryRouter>
<Navbar />
</MemoryRouter>
);

expect(getByText('WIQ')).toBeInTheDocument();
//New Imlementation throws Type Error as they are strings
// expect(getAllByText('Play')).toBeInTheDocument();
// expect(getAllByText('Rankings')).toBeInTheDocument();
expect(getByText('Log In')).toBeInTheDocument();
});
it('calls signOut when Log out button is clicked', () => {
useIsAuthenticated.mockReturnValue(() => true);
useSignOut.mockReturnValue(() => {});
const { getByText } = render(
<MemoryRouter>
<Navbar />
</MemoryRouter>
);

fireEvent.click(getByText('Log out'));
expect(useSignOut).toHaveBeenCalled();
});
it

});
101 changes: 0 additions & 101 deletions webapp/src/components/Question.jsx

This file was deleted.

66 changes: 0 additions & 66 deletions webapp/src/components/Question.test.js

This file was deleted.

12 changes: 12 additions & 0 deletions webapp/src/components/Squads.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';

function Squads() {
return (
<div class="content-align text-center justify-center bg-[#4c2185]" style={{height: "92.9vh"}}>
<h1 className="text-6xl font-bold mb-8 text-white">Squads</h1>
<h2 className="text-4xl font-body mb-8 text-white">Coming soon...</h2>
</div>
);
}

export default Squads;
106 changes: 106 additions & 0 deletions webapp/src/components/auth/AddUser.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// src/components/AddUser.js
import React, { useState } from 'react';
import axios from 'axios';
import { Container, Typography, TextField, Button, Snackbar } from '@mui/material';
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
const apiEndpoint = process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000';

const AddUser = () => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [cpassword, setcPassword] = useState('');
const [error, setError] = useState('');
const [openSnackbar, setOpenSnackbar] = useState(false);
const navigate = useNavigate();

const addUser = async () => {
try {
if (password !== cpassword) {
setError("Passwords do not match");
return;
}
await axios.post(`${apiEndpoint}/adduser`, { username, email, password });
setOpenSnackbar(true);
navigate('/login');

} catch (error) {
if (error.response === undefined) {
setError("There was a problem...");
}
else {
setError(error.response.data.error);
}
}
};

const handleCloseSnackbar = () => {
setOpenSnackbar(false);
};

return (
<div className="content-center justify-center bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900" style={{ height: "92.9vh" }}>
<Container component="main" maxWidth="xs" sx={{ marginTop: 4 }} className='bg-white rounded-xl content-center justify-center'>
<h1 className='py-5 text-4xl font-bold text-[#111827]'>
Register
</h1>
<TextField
name="username"
margin="normal"
fullWidth
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
name="email"
type='email'
margin="normal"
fullWidth
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
name="password"
margin="normal"
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<TextField
name="cpassword"

margin="normal"
fullWidth
label="Confirm Password"
type="password"
value={cpassword}
onChange={(e) => setcPassword(e.target.value)}
/>
<div className='py-5 content-center justify-center'>
<Button variant="contained" color="primary" className="flex items-center justify-center px-8 py-4 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={addUser}>
Register
</Button>
</div>
<Typography component="div" align="center" sx={{ marginTop: 2, marginBottom: 3 }}>
<Link to="/login">Already have an account? Log in here.</Link>
</Typography>

<Snackbar open={openSnackbar} autoHideDuration={6000} onClose={handleCloseSnackbar} message="User added successfully" />
{
error && (
<Snackbar open={!!error} autoHideDuration={6000} onClose={() => setError('')} message={`Error: ${error}`} />
)
}
</Container>
</div>


);
};

export default AddUser;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fireEvent, act, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import AddUser from './AddUser';
@@ -13,15 +13,27 @@ const mockAxios = new MockAdapter(axios);


describe('AddUser component', () => {
let usernameInput = 0;
let emailInput = 0;
let passwordInput = 0;
let cpasswordInput = 0;
let addUserButton = 0;

beforeEach(() => {
mockAxios.reset();
});

it('renders correctly', () => {
render(
<BrowserRouter>
<AddUser />
</BrowserRouter>);
usernameInput = screen.getByLabelText(/Username/i);
emailInput = screen.getByLabelText(/Email/i);
passwordInput = screen.getByLabelText("Password");
cpasswordInput = screen.getByLabelText(/Confirm Password/i);
addUserButton = screen.getByRole('button', { name: /Register/i });
});

it('renders correctly', () => {


expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
@@ -30,16 +42,10 @@ describe('AddUser component', () => {
});

it('should add user successfully', async () => {
render(<BrowserRouter>
<AddUser />
</BrowserRouter>);



const usernameInput = screen.getByLabelText(/Username/i);
const emailInput = screen.getByLabelText(/Email/i);
const passwordInput = screen.getByLabelText("Password");
const cpasswordInput = screen.getByLabelText(/Confirm Password/i);
const addUserButton = screen.getByRole('button', { name: /Register/i });


// Mock the axios.post request to simulate a successful response
mockAxios.onPost('http://localhost:8000/adduser').reply(200);
@@ -60,13 +66,9 @@ describe('AddUser component', () => {
});

it('should handle wrong passwords when adding user', async () => {
render(<BrowserRouter>
<AddUser />
</BrowserRouter>);

const usernameInput = screen.getByLabelText(/Username/i);
const passwordInput = screen.getByLabelText("Password");
const addUserButton = screen.getByRole('button', { name: /Register/i });



// Mock the axios.post request to simulate an error response
mockAxios.onPost('http://localhost:8000/adduser').reply(500, { error: 'Internal Server Error' });
@@ -86,13 +88,9 @@ describe('AddUser component', () => {
});

it('should handle error when adding user', async () => {
render(<BrowserRouter>
<AddUser />
</BrowserRouter>);


const usernameInput = screen.getByLabelText(/Username/i);
const passwordInput = screen.getByLabelText("Password");
const addUserButton = screen.getByRole('button', { name: /Register/i });


// Mock the axios.post request to simulate an error response
mockAxios.onPost('http://localhost:8000/adduser').reply(500, { error: 'Internal Server Error' });
@@ -108,5 +106,15 @@ describe('AddUser component', () => {
await waitFor(() => {
expect(screen.getByText("Error: Passwords do not match")).toBeInTheDocument();
});

//Close the snackbar by clicking outside
await act(async ()=>{
fireEvent.click(screen.getAllByText("Username")[1])
})

//Snackbar has to have been closed
await waitFor(() => {
expect(screen.queryByText("Error: Passwords do not match")).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// src/components/Login.js
import React, { useState } from 'react';
import axios from 'axios';
import { Container, Typography, TextField, Button, Snackbar } from '@mui/material';

import useSignIn from 'react-auth-kit/hooks/useSignIn';
import { Container, Typography, TextField, Button, Snackbar } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
const Login = () => {
@@ -17,8 +18,8 @@ const Login = () => {

const loginUser = async () => {
try {
const response = await axios.post(`${apiEndpoint}/login`, { username, password }).then((res) => {
await axios.post(`${apiEndpoint}/login`, { username, password }).then((res) => {

if (res.status === 200) {
if (signIn({
auth: {
@@ -34,21 +35,22 @@ const Login = () => {
})) { // Only if you are using refreshToken feature
// Redirect or do-something
navigate('/');
//window.location.href = '/';
} else {
//Throw error

throw new Error('Error while signing in');
}

}
}
});

setOpenSnackbar(true);
} catch (error) {
if(error.response===undefined){
if (error.response === undefined) {
setError("Error: There was a problem...");
}
else{
else {
setError(error.response.data.error);
}

@@ -60,46 +62,45 @@ const Login = () => {
};

return (
<Container component="main" maxWidth="xs" sx={{ marginTop: 4 }}>
<div>
<Typography component="h1" variant="h5">
Login
</Typography>
<div className="content-center justify-center bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900" style={{ height: "92.9vh" }}>
<Container component="main" maxWidth="xs" sx={{ marginTop: 4 }} className='bg-white rounded-xl content-center justify-center'>
<h1 className='py-5 text-4xl font-bold text-[#111827]'>
Access WIQ
</h1>
<TextField
name = "username"
name="username"
margin="normal"
fullWidth
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
name = "password"

name="password"
margin="normal"
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button variant="contained" color="primary" onClick={loginUser}>
Login
</Button>

<Typography component="div" align="center" sx={{ marginTop: 2 }}>
<Link to="/register">Don't have an account? Register here.</Link>
<div className='py-5 content-center justify-center'>
<Button variant="contained" className="flex items-center justify-center px-8 py-4 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg" onClick={loginUser}>
Log In
</Button>
</div>
<Typography component="div" align="center" sx={{ marginTop: 2, marginBottom: 3 }}>
<Link to="/register">New around here? Create account.</Link>
</Typography>





<Snackbar open={openSnackbar} autoHideDuration={6000} onClose={handleCloseSnackbar} message="Login successful" />
{error && (
<Snackbar open={!!error} autoHideDuration={6000} onClose={() => setError('')} message={`Error: ${error}`} />
)}
</div>

</Container>
</Container>
</div>
);
};

Original file line number Diff line number Diff line change
@@ -9,71 +9,58 @@ import createStore from 'react-auth-kit/createStore';
const mockAxios = new MockAdapter(axios);

describe('Login component', () => {
let usernameInput = 0;
let passwordInput = 0;
let loginButton = 0;
const store = createStore({
authName: '_auth',
authType: 'cookie',
cookieDomain: window.location.hostname,
cookieSecure: window.location.protocol === 'https:',
});
beforeEach(() => {
mockAxios.reset();
});

it('should log in successfully', async () => {
const store = createStore({
authName: '_auth',
authType: 'cookie',
cookieDomain: window.location.hostname,
cookieSecure: window.location.protocol === 'https:',
});


render(
<AuthProvider
store={store}
>
<BrowserRouter>
<Login/>
<Login />
</BrowserRouter>
</AuthProvider>);

const usernameInput = screen.getByLabelText(/Username/i);
const passwordInput = screen.getByLabelText(/Password/i);
const loginButton = screen.getByRole('button', { name: /Login/i });

usernameInput = screen.getByLabelText(/Username/i);
passwordInput = screen.getByLabelText(/Password/i);
loginButton = screen.getByRole('button', { name: /Log In/i });
});

it('should log in successfully', async () => {

const mock = jest.fn();
jest.mock('react-router-dom', () => ({
useNavigate: () => mock,
}));
// Mock the axios.post request to simulate a successful response
mockAxios.onPost('http://localhost:8000/login').reply(200, { username:"testUser",email:"test@test.com",createdAt: '2024-01-01T12:34:56Z',token: 'testToken'});
mockAxios.onPost('http://localhost:8000/login').reply(200, { username: "testUser", email: "test@test.com", createdAt: '2024-01-01T12:34:56Z', token: 'testToken' });

// Simulate user input
await act(async () => {
fireEvent.change(usernameInput, { target: { value: 'testUser' } });
fireEvent.change(passwordInput, { target: { value: 'testPassword' } });
fireEvent.click(loginButton);
});


const linkElement = screen.getByText(/Error: Error: There was a problem.../i);
expect(linkElement).toBeInTheDocument();


});

it('should handle error when logging in', async () => {
const store = createStore({
authName: '_auth',
authType: 'cookie',
cookieDomain: window.location.hostname,
cookieSecure: window.location.protocol === 'https:',
});

render(
<AuthProvider
store={store}
>
<BrowserRouter>
<Login/>
</BrowserRouter>
</AuthProvider>);

const usernameInput = screen.getByLabelText(/Username/i);
const passwordInput = screen.getByLabelText(/Password/i);
const loginButton = screen.getByRole('button', { name: /Login/i });


// Mock the axios.post request to simulate an error response
mockAxios.onPost('http://localhost:8000/login').reply(401, { error: 'Unauthorized' });
@@ -92,5 +79,5 @@ describe('Login component', () => {

});


});
93 changes: 93 additions & 0 deletions webapp/src/components/game/Game.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import useIsAuthenticated from "react-auth-kit/hooks/useIsAuthenticated";
import useAuthUser from "react-auth-kit/hooks/useAuthUser";
import Question from "./Question";

const Game = () => {
const [flagGameStarted, setFlagGameStarted] = useState(false);
const [cityGameStarted, setCityGameStarted] = useState(false);
const [monumentGameStarted, setMonumentGameStarted] = useState(false);
const [touristAttractionGameStarted, setTouristAttractionGameStarted] = useState(false);
const [foodGameStarted, setFoodGameStarted] = useState(false);
const isAuthenticated = useIsAuthenticated();
const navigate = useNavigate();
const auth = useAuthUser();
const startFlagsGame = () => {
setFlagGameStarted(!flagGameStarted);
};
const startCitiesGame = () => {
setCityGameStarted(!cityGameStarted);
};
const startMonumentsGame = () => {
setMonumentGameStarted(!monumentGameStarted);
};
const startTouristAttractionsGame = () => {
setTouristAttractionGameStarted(!touristAttractionGameStarted);
};
const startFoodsGame = () => {
setFoodGameStarted(!foodGameStarted);
};
useEffect(() => {
if (!isAuthenticated()) {
navigate("/login");
}
}, [isAuthenticated, navigate]);

return (
<div class="area">
<ul class="circles">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<div className="bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900 min-h-screen">
{isAuthenticated() ? (flagGameStarted || cityGameStarted || monumentGameStarted
|| touristAttractionGameStarted || foodGameStarted) ? (
<div className="flex justify-center content-center pt-10 h-auto">
{flagGameStarted && <Question type="imgs" category="flags" />}
{cityGameStarted && <Question type="imgs" category="cities" />}
{monumentGameStarted && <Question type="imgs" category="monuments" />}
{touristAttractionGameStarted && <Question type="imgs" category="tourist_attractions" />}
{foodGameStarted && <Question type="imgs" category="foods" />}
</div>
) : (
<div className="flex flex-col items-center justify-center">
<h1 className="text-6xl font-bold text-center text-white pt-5">{auth.username}, Let's Play! Guess the...</h1>
<div className="grid grid-cols-1 p-7 gap-5">
<button onClick={startFlagsGame} className="w-auto flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10
transition-transform transform-gpu hover:scale-105">
Flag
</button>
<button onClick={startCitiesGame} className="flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10
transition-transform transform-gpu hover:scale-105">
City
</button>
<button onClick={startMonumentsGame} className="w-auto flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10
transition-transform transform-gpu hover:scale-105">
Monument
</button>
<button onClick={startTouristAttractionsGame} className="flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10
transition-transform transform-gpu hover:scale-105">
Tourist attraction
</button>
<button onClick={startFoodsGame} className="w-auto flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10
transition-transform transform-gpu hover:scale-105">
Food
</button>
</div>
</div>
) : ""}
</div>
</div>
)
};

export default Game;
76 changes: 76 additions & 0 deletions webapp/src/components/game/Game.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import { fireEvent, act, render, screen, waitFor } from '@testing-library/react';
import Game from './Game';
import { jest } from '@jest/globals';
import useIsAuthenticated from 'react-auth-kit/hooks/useIsAuthenticated';
import useAuthUser from 'react-auth-kit/hooks/useAuthUser';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
const mockAxios = new MockAdapter(axios);

const mock = jest.fn();

jest.mock('react-auth-kit/hooks/useIsAuthenticated');
jest.mock('react-auth-kit/hooks/useAuthUser');
jest.mock('react-router-dom', () => ({
useNavigate: () => mock,
}));

mockAxios.onGet(/\/imgs\/([^\/]+)\/question/).reply(config => {
const category = config.url.match(/\/imgs\/([^\/]+)\/question/)[1];
return [200, {
question: `${category} question`,
images: ["img1", "img2", "img3", "img4"]
}];
});

describe('Game page', () => {
it('should render play message for authenticated user', async () => {
useIsAuthenticated.mockReturnValue(() => true);
useAuthUser.mockReturnValue({ username: 'testUser' });

render(<Game />);

const playmsg = screen.getByText(/Let's Play! Guess the.../i);
expect(playmsg).toBeInTheDocument();
});

it('shouldnt render play message for unauthenticated user', async () => {
useIsAuthenticated.mockReturnValue(() => false);
try {
const playmsg = screen.getByText(/Let's Play! Guess the.../i);
throw new Error('Unauthenticated user was able to see Game page');
} catch (err) {}
});

it('should render the corresponding question depending on the option clicked', async () => {
useIsAuthenticated.mockReturnValue(() => true);
useAuthUser.mockReturnValue({ username: 'testUser' });

let gameOptions = ["Flag","City","Monument","Tourist attraction","Food"]
let gameCategories = ["flags","cities","monuments","tourist_attractions","foods"]

for(let i=0;i<gameOptions.length;i++){
render(<Game />);
//Click game option
await act(async ()=>{
fireEvent.click(screen.getByText(gameOptions[i]))
})

//Should have rendered question category
await waitFor(()=>{
expect(screen.getByText(gameCategories[i]+" question")).toBeInTheDocument()
})
}
});

it('should navigate out of the game if the user is not authenticated', async () => {
useIsAuthenticated.mockReturnValue(() => false);
useAuthUser.mockReturnValue({ username: 'testUser' });

render(<Game />);
await waitFor(()=>{
expect(screen.queryByText("Let's Play!")).not.toBeInTheDocument();
})
});
});
67 changes: 67 additions & 0 deletions webapp/src/components/game/ImgGameReport.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useEffect } from "react";
import CheckIcon from '@mui/icons-material/Check';
import ClearIcon from '@mui/icons-material/Clear';
import { useNavigate } from "react-router-dom";
const ImgGameReport = (props) => {
const answers = props.answers;
const navigate = useNavigate();
const loadRankings = () => {
navigate("/rankings");
}
return (
<div >
<div class=" bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900">
<div class=" justify-center mx-auto max-w-3xl px-6 py-12">
<div class="justify-center text-center">
<h1 class="text-4xl font-bold text-white mb-4">Game Over!</h1>
<p class="text-xl text-white mb-8">
You answered {props.score} {props.score === 1 ? "question" : "questions"} correctly
</p>

<div class="sm:flex lg:justify-center">
<div class="rounded-md shadow">
<a href=""
onClick={props.restartGame}
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg">
Restart Game
</a>
</div>



<div class="mt-3 sm:mt-0 sm:ml-3">
<a href=""
onClick={loadRankings}
class="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg">
See Rankings
</a>
</div>
</div>
</div>
</div>
</div>
<div class="mt-16 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 p-4">


{answers.map((answer) => {
return (
<div class="flex flex-col rounded-2xl bg-[#ffffff] shadow-xl" key={answer.associate}>
<figure class="flex justify-center items-center rounded-2xl">
<img src={answer.associate} alt="Card Preview" class="rounded-t-2xl"></img>
</figure>
<div class="flex flex-col p-8">
<div class="text-2xl font-bold text-center text-[#374151] pb-6">{answer.question}</div>
<div class=" text-base text-center text-[#374151]">
You answered the question {answer.correct === "true" ? "correctly" : "wrongly"}
{answer.correct === "true" ? <CheckIcon fontSize="large" style={{ color: 'green' }} /> : <ClearIcon fontSize="large" style={{ color: 'red' }} />}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

export default ImgGameReport;
193 changes: 193 additions & 0 deletions webapp/src/components/game/Question.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import useAuthUser from "react-auth-kit/hooks/useAuthUser";
import ImgGameReport from "./ImgGameReport";

const Question = (props) => {
const apiEndpoint =
process.env.REACT_APP_API_ENDPOINT || "http://localhost:8000";
const [currentQuestion, setCurrentQuestion] = useState(0);
const [questions, setQuestions] = useState([]);
const [loading, setLoading] = useState(true);
const [renderedImages, setRenderedImages] = useState(0);
const [counter, setCounter] = useState(0);
const [score, setScore] = useState(0);
const auth = useAuthUser();
const questionsPerGame = 10;
const imagesPerQuestion = 4;

//Game Report
const [finished, setFinished] = useState(false);
const [answers, setAnswers] = useState([]);

useEffect(() => {
fetchQuestions();
}, []);

useEffect(() => {
const interval = setInterval(() => {
if (renderedImages === imagesPerQuestion) {
setCounter((prevCounter) => prevCounter + 0.4);
}
}, 40);

return () => clearInterval(interval);
}, [renderedImages]);

useEffect(() => {
async function answ() {
await answerQuestion("TimeOut1234;", questions[currentQuestion].question);
}

if (counter >= 100 && !loading) {
answ();
}
}, [counter]);

const fetchQuestions = async () => {
try {
setRenderedImages(0);
let promises = [];
let questions = [];
for (let i = 0; i < questionsPerGame; i++) {
let question = axios.get(
`${apiEndpoint}/${props.type}/${props.category}/question`
);
promises.push(question);
}
let responses = await Promise.all(promises);
for (let i = 0; i < questionsPerGame; i++) {
let question = responses.pop().data;
questions.push(question);
}
setQuestions(questions);
setLoading(false);
} catch (error) {
console.error("Error fetching question:", error);
}
};

const answerQuestion = async (answer, question) => {
if (counter == 0) {
return;
}
try {
setLoading(true);
setRenderedImages(0);

const result = await axios.post(
`${apiEndpoint}/${props.type}/answer`,
{
answer: answer,
question: question,
username: auth.username,
category: props.category,
},
{ headers: { "Content-Type": "application/json" } }
);

if (result.data.correct === "true") {
setScore(score + 1);
setAnswers(
answers.concat({
question: question,
correct: result.data.correct,
associate: answer,
})
);
} else {
setAnswers(
answers.concat({
question: question,
correct: result.data.correct,
associate: result.data.correctImg,
})
);
}

if (currentQuestion >= questionsPerGame - 1) {
//Infinte Game
// fetchQuestions()
// setCurrentQuestion(0)
// setCounter(0);
setFinished(true);
setLoading(false);
return;
}
setCurrentQuestion((question) => question + 1);
setCounter(0);
setLoading(false);
} catch (error) {
//console.log(error);
}
};

const restartGame = async () => {
setLoading(true);
setScore(0);
setAnswers([]);
setCurrentQuestion(0);
setCounter(0);
setFinished(false);
await fetchQuestions();
setLoading(false);
};

return finished ? (
<div className="bg-white">
<ImgGameReport
score={score}
answers={answers}
restartGame={restartGame}
/>
</div>
) : (
<div className="bg-slate-100 shadow-lg rounded-md p-4 mx-auto max-w-2xl ">
{loading ? (
<>
<h1 className="font-bold text-2xl text-gray-800 pl-8">
<div class="flex justify-center items-center h-fit">
<div class="rounded-full h-20 w-20 bg-violet-800 animate-ping"></div>
</div>
</h1>
</>
) : (
<div>
<div className="pl-8 mt-8 p-2">Score: {score}</div>
<h1 className="font-bold text-3xl text-gray-800 pl-8">
{questions[currentQuestion].question}
</h1>
<div class="relative h-5 rounded-full overflow-hidden bg-gray-300 mt-20 mx-10">
<div
class="absolute top-0 bottom-0 left-0 rounded-full bg-gradient-to-r from-pink-500 to-purple-500"
style={{ width: counter + "%" }}
data-testid="time-bar"
></div>
</div>
<div className="grid grid-cols-2 mt-10 item">
{questions[currentQuestion].images.map((image) => (
<button className="transition-transform transform-gpu hover:scale-105 rounded-xl mx-8 my-8 max-h-52 max-w-80">
<img
src={image}
alt="Loading ..."
className="rounded-lg object-contain shadow-md"
onClick={async () =>
await answerQuestion(
image,
questions[currentQuestion].question
)
}
onLoad={() =>
setRenderedImages((renderedImages) => renderedImages + 1)
}
></img>
</button>
))}
</div>
</div>
)}
</div>
);
};

export default Question;
211 changes: 211 additions & 0 deletions webapp/src/components/game/Question.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import React from 'react';
import { render, fireEvent, screen, waitFor, act, getByRole, getAllByRole } from '@testing-library/react';
import Question from './Question';
import useAuthUser from 'react-auth-kit/hooks/useAuthUser';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
const mockAxios = new MockAdapter(axios);

const mock = jest.fn();

jest.mock('react-auth-kit/hooks/useAuthUser');
jest.mock('react-router-dom', () => ({
useNavigate: () => mock,
}));

async function loadImages(){
await act(async () => {
fireEvent.load(screen.getAllByRole("img")[0])
fireEvent.load(screen.getAllByRole("img")[1])
fireEvent.load(screen.getAllByRole("img")[2])
fireEvent.load(screen.getAllByRole("img")[3])
});
}

async function waitForTimeBarStart(){
await waitFor(() => {
const time_bar = screen.getByTestId('time-bar');
expect(time_bar).toBeInTheDocument();
const widthStyle = time_bar.style.width;
const widthValue = parseFloat(widthStyle);
expect(widthValue).toBeGreaterThan(0);
});
}

describe('Question page', () => {
beforeEach(() => {
mockAxios.reset();
});

it('should render a question of flag images if type is image and category is flags', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/flags/question').reply(200,
{
question: "Which of the following flags belongs to Spain?",
images:["https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_England.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Poland.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Germany.svg"]
});

render(<Question type="imgs" category="flags"/>);

await waitFor(() => {
expect(screen.getByText(/Which of the following flags belongs to/i)).toBeInTheDocument();
expect(screen.getByText(/Score/i)).toBeInTheDocument();
expect(screen.getByText(/Score/i).textContent).toBe("Score: 0")
let imgs = []
imgs = screen.getAllByRole("button")
expect(imgs.length).toBe(4)
});
});

it('should render a question of food images if type is image and category is foods', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/foods/question').reply(200,
{
question: "Which of the following images corresponds to Tortilla?",
images:["TortillaImage","PaellaImage","CachopoImage","ChocoImage"]
});

render(<Question type="imgs" category="foods"/>);

await waitFor(() => {
expect(screen.getByText(/Which of the following images corresponds to/i)).toBeInTheDocument();
expect(screen.getByText(/Score/i)).toBeInTheDocument();
expect(screen.getByText(/Score/i).textContent).toBe("Score: 0")
let imgs = []
imgs = screen.getAllByRole("button")
expect(imgs.length).toBe(4)
});
});

it('should handle a fetching question error', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/foods/question').networkError();

render(<Question type="imgs" category="foods"/>);
});

it('should update the score if the answer is correct', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/flags/question').reply(200,
{
question: "Which of the following flags belongs to Spain?",
images:["https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_England.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Poland.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Germany.svg"]
});

mockAxios.onPost('http://localhost:8000/imgs/answer').reply(200,
{
correct: "true"
});

render(<Question type="imgs" category="flags"/>);

await waitFor(() => {
expect(screen.getByText(/Score/i).textContent).toBe("Score: 0")
expect(screen.getByText(/Which of the following/i)).toBeInTheDocument();
});

await loadImages()
await waitForTimeBarStart()

await act(async () => {
fireEvent.click(screen.getAllByRole("img")[0]);
});

await waitFor(() => {
expect(screen.getByText(/Score/i).textContent).toBe("Score: 1")
})
});

it('should not update the score if the answer is incorrect', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/flags/question').reply(200,
{
question: "Which of the following flags belongs to Spain?",
images:["https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_England.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Poland.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Germany.svg"]
});

mockAxios.onPost('http://localhost:8000/imgs/answer').reply(200,
{
correct: "false",
correctImg: "https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
});

render(<Question type="imgs" category="flags"/>);

await waitFor(() => {
expect(screen.getByText(/Score/i).textContent).toBe("Score: 0")
expect(screen.getByText(/Which of the following/i)).toBeInTheDocument();
});

await act(async () => {
fireEvent.click(screen.getAllByRole("img")[2]);
});

await waitFor(() => {
expect(screen.getByText(/Score/i).textContent).toBe("Score: 0")
})
});

it('should finish the game and render the report, from which game can be restarted', async () => {
useAuthUser.mockReturnValue({ username: 'testUser' });

mockAxios.onGet('http://localhost:8000/imgs/flags/question').reply(200,
{
question: "Which of the following flags belongs to Spain?",
images:["https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_England.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Poland.svg"
,"https://commons.wikimedia.org/wiki/File:Flag_of_Germany.svg"]
});

mockAxios.onPost('http://localhost:8000/imgs/answer').reply(200,
{
correct: "false",
correctImg: "https://commons.wikimedia.org/wiki/File:Flag_of_Spain.svg"
});

render(<Question type="imgs" category="flags"/>);

await waitFor(() => {
expect(screen.getByText(/Which of the following/i)).toBeInTheDocument();
});

const questionsPerGame = 10;
for(let i=0;i<questionsPerGame;i++){
await loadImages()
await waitForTimeBarStart()
await act(async () => {
fireEvent.click(screen.getAllByRole("img")[2]);
});
}

await waitFor(() => {
expect(screen.queryByText("Game Over!")).toBeInTheDocument()
})

await act(async ()=>{
fireEvent.click(screen.getByText("Restart Game"))
})

await waitFor(() => {
expect(screen.queryByText(/Which of the following/i)).toBeInTheDocument()
})
});
});



36 changes: 18 additions & 18 deletions webapp/src/components/ranking/RankingLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,54 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import RankingsTable from "./RankingTable";

const RankingsLayout = () => {
const [filter, setFilter] = useState('global'); // default ranking = global

const handleFilterClick = (filter) => {
setFilter(filter);

}

return (
<div className="flex h-screen">
<div id="sidebar" className="md:flex flex-col w-48 bg-[#504185]">
<div className="flex items-center justify-center h-16 bg-[#322653]">
<span className="text-xl font-bold text-white">Rankings</span>
<div className="flex flex-col bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900" style={{height: "92.9vh"}}>
<div id="sidebar" className="sm:flex flex-col w-auto bg-[#4c2185]">
<div className="flex items-center justify-center h-16 bg-[#4c2185]">
<span className="text-4xl font-bold text-white">Rankings</span>
</div>
<div id="categories" className="flex flex-col flex-1 overflow-y-auto">
<nav className="flex-1 px-2 py-4 bg-gradient-to-b
from-[#3a2f60] from-10% to-[#504185] to-60%">
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<div id="categories" className=" align-center content-center bg-[#4c2185]">
<nav className="grid grid-cols-3 gap-3 lg:grid-cols-6 px-2 py-4 bg-[#4c2185]">
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('global')}>
Global
</button>
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('flags')}>
Flags
</button>
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('cities')}>
Cities
</button>
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('monuments')}>
Monuments
</button>
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('foods')}>
Foods
</button>
<button className="flex items-center px-4 py-2 text-gray-100 rounded-lg hover:bg-[#73689b]"
<button className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10 transition-transform transform-gpu hover:-translate-y-1 hover:shadow-lg"
onClick={() => handleFilterClick('tourist_attractions')}>
Attractions
</button>
</nav>
</div>
</div>

<div className="flex flex-col flex-1 overflow-y-auto">
<div className="flex items-center px-4 h-16">
<h1 className="text-4xl font-bold uppercase text-[#4d3b7f]">{filter}</h1>
<div className="content-center align-center px-4 py-3 bg-[#4c2185]">
<h1 className="text-4xl text-center font-bold uppercase text-white">{filter==='tourist_attractions'?'Attractions':filter} Ranking</h1>
</div>
<div className="flex flex-col flex-1 overflow-y-auto bg-[#4c2185]">

<div className="p-4" id="table">
<RankingsTable filter={filter} />
</div>
136 changes: 86 additions & 50 deletions webapp/src/components/ranking/RankingTable.jsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,96 @@
import React, { useState, useEffect } from "react";
import axios from "axios";

const apiEndpoint = process.env.REACT_APP_API_ENDPOINT ||'http://localhost:8000';
const apiEndpoint = process.env.REACT_APP_API_ENDPOINT || 'http://localhost:8000';

const RankingsTable = ({ filter }) => {
const [users, setUsers] = useState([]);

useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${apiEndpoint}/rankings/${filter}`);
setUsers(response.data);
} catch (error) {
console.error('Error fetching data:', error);
}
};

fetchData();
}, [filter]);

return (
<div data-testid='ranking-table' className="flex flex-col">
<div className="overflow-x-auto sm:mx-6 lg:mx-8">
<div className="py-2 inline-block min-w-full sm:px-6 lg:px-8">
<div className="overflow-hidden">
<table className="min-w-full">
<thead className="border-b">
<tr>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left"></th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">User</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">Points</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">Questions</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">Correct</th>
<th scope="col" className="text-sm font-medium text-gray-900 px-6 py-4 text-left">Wrong</th>
</tr>
</thead>
<tbody>
{
users.map(user => (
<tr className="border-b">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{user.position}</td>
<td className="text-sm text-gray-900 font-light px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-gray-900 font-light px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-gray-900 font-light px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-gray-900 font-light px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-gray-900 font-light px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr>
))
}
</tbody>
</table>
</div>
</div>
const [users, setUsers] = useState([]);

useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(`${apiEndpoint}/rankings/${filter}`);
setUsers(response.data);
} catch (error) {
console.error('Error fetching data:', error);
}
};

fetchData();
}, [filter]);

return (
<div data-testid='ranking-table' className="flex flex-col ">
<div className="overflow-x-auto sm:mx-6 lg:mx-8">
<div className="py-2 inline-block min-w-full sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-md">
<table className="min-w-full">
<thead className="border-b bg-[#322653]">
<tr>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left"></th>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left">User</th>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left">Points</th>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left">Questions</th>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left">Correct</th>
<th scope="col" className="text-sm font-medium text-white px-6 py-4 text-left">Wrong</th>
</tr>
</thead>
<tbody>
{
users.map(user => (
user.position === 1 ? <tr className="border-b bg-amber-500">
<td className="px-6 py-4 whitespace-nowrap text-lg font-bold text-white">{user.position}</td>
<td className="text-lg text-white font-bold px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr> :
user.position === 2 ? <tr className="border-b bg-[#b0bec5]">
<td className="px-6 py-4 whitespace-nowrap text-lg font-bold text-white">{user.position}</td>
<td className="text-lg text-white font-bold px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr> :
user.position === 3 ? <tr className="border-b bg-amber-800">
<td className="px-6 py-4 whitespace-nowrap text-lg font-bold text-white">{user.position}</td>
<td className="text-lg text-white font-bold px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr> :
user.position % 2 === 0 ?
<tr className="border-b bg-indigo-600">
<td className="px-6 py-4 whitespace-nowrap text-base font-bold text-white">{user.position}</td>
<td className="text-m text-white px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-white px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr>
:
<tr className="border-b bg-indigo-100">
<td className="px-6 py-4 whitespace-nowrap text-base font-medium text-indigo-700 ">{user.position}</td>
<td className="text-m text-indigo-700 font-light px-6 py-4 whitespace-nowrap">{user.name}</td>
<td className="text-sm text-indigo-700 font-light px-6 py-4 whitespace-nowrap">{user.points}</td>
<td className="text-sm text-indigo-700 font-light px-6 py-4 whitespace-nowrap">{user.questions}</td>
<td className="text-sm text-indigo-700 font-light px-6 py-4 whitespace-nowrap">{user.correct}</td>
<td className="text-sm text-indigo-700 font-light px-6 py-4 whitespace-nowrap">{user.wrong}</td>
</tr>


))
}
</tbody>
</table>
</div>
</div>
)
</div>
</div>
)
}

export default RankingsTable;
183 changes: 183 additions & 0 deletions webapp/src/components/user/UserProfile.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, { useEffect, useState, useCallback } from "react";
import axios from "axios";
import useAuthUser from "react-auth-kit/hooks/useAuthUser";

const UserProfile = () => {
const auth = useAuthUser();
const [user, setUser] = useState([]);
const [isLoading, setIsLoading] = useState(true);

const apiEndpoint =
process.env.REACT_APP_API_ENDPOINT || "http://localhost:8000";

useEffect(() => {
const fetchData = async () => {
try {
const res = await axios.get(`${apiEndpoint}/ranking/user`, {
params: {
username: auth.username,
},
});
setUser(res.data);
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
}
};

fetchData();
}, [user]);

return isLoading ? (
<div class="area">
<ul class="circles">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<div
class="flex flex-col bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900 min-h-screen"
></div>
</div>
) : (
<div class="area">
<ul class="circles">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<div
class="flex flex-col bg-gradient-to-br from-purple-900 via-indigo-900 to-blue-900 min-h-screen"
>
<div className="items-center text-center text-gray-100 px-16 pb-8">
<h2 className="my-8 text-2xl font-bold text-white md:text-4xl">
{user.username}
</h2>
<p>Email: {user.email}</p>
<p>Joined: {new Date(auth.createdAt).toLocaleDateString()}</p>
</div>

<div class="mx-auto grid justify-center gap-4 sm:grid-cols-2 md:max-w-[64rem] md:grid-cols-3">
<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Global</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.global.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.global.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.global.wrong}
</p>
</div>
</div>
</div>

<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Flags</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.flags.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.flags.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.flags.wrong}
</p>
</div>
</div>
</div>

<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Cities</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.cities.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.cities.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.cities.wrong}
</p>
</div>
</div>
</div>

<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Food</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.foods.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.foods.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.foods.wrong}
</p>
</div>
</div>
</div>

<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Attractions</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.tourist_attractions.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.tourist_attractions.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.tourist_attractions.wrong}
</p>
</div>
</div>
</div>

<div class="relative overflow-hidden rounded-lg border bg-white p-2">
<div class="flex h-[180px] flex-col justify-between rounded-md p-6">
<div class="space-y-2">
<h3 class="font-bold">Monuments</h3>
<p class="text-sm text-muted-foreground">
Total questions: {user.ranking.monuments.questions}
</p>
<p class="text-sm text-muted-foreground">
Correct questions: {user.ranking.monuments.correct}
</p>
<p class="text-sm text-muted-foreground">
Wrong questions: {user.ranking.monuments.wrong}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

export default UserProfile;
71 changes: 71 additions & 0 deletions webapp/src/components/user/UserProfile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import UserProfile from './UserProfile';
import axios from 'axios';
import useAuthUser from 'react-auth-kit/hooks/useAuthUser';

jest.mock('axios');
jest.mock('react-auth-kit/hooks/useAuthUser', () => jest.fn());

describe('UserProfile', () => {
beforeEach(() => {
useAuthUser.mockReturnValue({
username: 'testUser',
email: 'test@example.com',
createdAt: '2024-01-01T00:00:00Z',
});
});

it('renders user details', async () => {
axios.get.mockResolvedValueOnce({
data: {
username: 'testUser',
email: 'test@example.com',
ranking: {
global: {
questions: 10,
correct: 7,
wrong: 3,
},
cities: {
questions: 0,
correct: 0,
wrong: 0,
},
flags: {
questions: 0,
correct: 0,
wrong: 0,
},
monuments: {
questions: 0,
correct: 0,
wrong: 0,
},
tourist_attractions: {
questions: 0,
correct: 0,
wrong: 0,
},
foods: {
questions: 0,
correct: 0,
wrong: 0,
},
}
},
});

const { getByText } = render(<UserProfile />);

await waitFor(() => {
expect(getByText('testUser')).toBeInTheDocument();
expect(getByText('Email: test@example.com')).toBeInTheDocument();
expect(getByText('Joined: 1/1/2024')).toBeInTheDocument();
expect(getByText('Global')).toBeInTheDocument();
expect(getByText('Total questions: 10')).toBeInTheDocument();
expect(getByText('Correct questions: 7')).toBeInTheDocument();
expect(getByText('Wrong questions: 3')).toBeInTheDocument();
});
});
});
164 changes: 164 additions & 0 deletions webapp/src/index.css
Original file line number Diff line number Diff line change
@@ -15,3 +15,167 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

/*Start Animations*/
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0;
}
to {
top: 0;
opacity: 1;
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0;
}
to {
top: 0;
opacity: 1;
}
}
@-webkit-keyframes zoomIn {
0% {
opacity: 0;
-webkit-transform: scale3d(0.3, 0.3, 0.3);
transform: scale3d(0.3, 0.3, 0.3);
}
50% {
opacity: 1;
}
}
@keyframes zoomIn {
0% {
opacity: 0;
-webkit-transform: scale3d(0.3, 0.3, 0.3);
transform: scale3d(0.3, 0.3, 0.3);
}
50% {
opacity: 1;
}
}
/*End Animations*/
/*
-- Start BackGround Animation
*/
.area {
width: 100%;
height: 92.9vh;
position: absolute;
z-index: -1;
}

.circles {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 96%;
overflow: hidden;
}

.circles li {
position: absolute;
display: block;
list-style: none;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
animation: animate 25s linear infinite;
bottom: -150px;
}

.circles li:nth-child(1) {
left: 25%;
width: 80px;
height: 80px;
animation-delay: 0s;
}

.circles li:nth-child(2) {
left: 10%;
width: 20px;
height: 20px;
animation-delay: 2s;
animation-duration: 12s;
}

.circles li:nth-child(3) {
left: 70%;
width: 20px;
height: 20px;
animation-delay: 4s;
}

.circles li:nth-child(4) {
left: 40%;
width: 60px;
height: 60px;
animation-delay: 0s;
animation-duration: 18s;
}

.circles li:nth-child(5) {
left: 65%;
width: 20px;
height: 20px;
animation-delay: 0s;
}

.circles li:nth-child(6) {
left: 75%;
width: 110px;
height: 110px;
animation-delay: 3s;
}

.circles li:nth-child(7) {
left: 35%;
width: 150px;
height: 150px;
animation-delay: 7s;
}

.circles li:nth-child(8) {
left: 50%;
width: 25px;
height: 25px;
animation-delay: 15s;
animation-duration: 45s;
}

.circles li:nth-child(9) {
left: 20%;
width: 15px;
height: 15px;
animation-delay: 2s;
animation-duration: 35s;
}

.circles li:nth-child(10) {
left: 85%;
width: 150px;
height: 150px;
animation-delay: 0s;
animation-duration: 11s;
}

@keyframes animate {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
border-radius: 0;
}

100% {
transform: translateY(-1000px) rotate(720deg);
opacity: 0;
border-radius: 50%;
}
}
/*
-- End BackGround Animation
*/

0 comments on commit 1530142

Please sign in to comment.