diff --git a/.env b/.env deleted file mode 100644 index bdb195fd..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -teamname="wiq_en2b" \ No newline at end of file diff --git a/README.md b/README.md index 59a397b3..97d016bf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# šŸ§ šŸ¤” [wiq_en2b](http://98.66.168.12:3000) ā“šŸ“š -The aplication is deployed [here](http://98.66.168.12:3000). +# šŸ§ šŸ¤” KiWiq šŸ„ā“šŸ“š + WIQ is a quiz game project inspired by the engaging and thought-provoking show "Saber y Ganar." We aim to create a platform that not only challenges your knowledge but also sparks curiosity and the thrill of discovery. @@ -13,8 +13,11 @@ We aim to create a platform that not only challenges your knowledge but also spa ## Features šŸ† Adaptable difficulty: You can adjust the difficulty to push your limits. + šŸŒ Multiplayer: Compete with friends and strangers to prove you are the best. +šŸŒ Localized: Available in Spanish and English. + ## Contributors Contributor | Contact -- | -- @@ -33,14 +36,16 @@ Gonzalo SuĆ”rez Losada | jacoco-maven-plugin 0.8.11 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.2.0 + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + runtime + diff --git a/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java b/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java index 8ea27236..391c90f5 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java @@ -1,8 +1,12 @@ package lab.en2b.quizapi.auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lab.en2b.quizapi.auth.dtos.*; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -13,23 +17,54 @@ public class AuthController { private final AuthService authService; + @Operation(summary = "Registers an user", description = "Registers an user and returns the login dto if it was 200") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved, returns the login dto"), + @ApiResponse(responseCode = "400", description = "Invalid email format or credentials (username or email) already in use", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "401", description = "Invalid email or password, check for them to be correct", content = @io.swagger.v3.oas.annotations.media.Content), + + }) @PostMapping("/register") - public ResponseEntity registerUser(@Valid @RequestBody RegisterDto registerRequest){ + public ResponseEntity registerUser(@Valid @RequestBody RegisterDto registerRequest){ authService.register(registerRequest); - return ResponseEntity.ok("User registered successfully!"); + JwtResponseDto dto = + authService.login(new LoginDto(registerRequest.getEmail(), registerRequest.getPassword())); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + dto.getToken()); + return ResponseEntity.ok().headers(headers).body(dto); } + @Operation(summary = "Logs in an user", description = "Given a login dto, logs in that user") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "400", description = "Invalid email format", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "401", description = "Invalid email or password, check for them to be correct", content = @io.swagger.v3.oas.annotations.media.Content), + + }) @PostMapping("/login") public ResponseEntity loginUser(@Valid @RequestBody LoginDto loginRequest){ - return ResponseEntity.ok(authService.login(loginRequest)); + JwtResponseDto dto = authService.login(loginRequest); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + dto.getToken()); + return ResponseEntity.ok().headers(headers).body(dto); } + @Operation(summary = "Logs the current user out") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "403", description = "You are not logged in, so there's no log out to do", content = @io.swagger.v3.oas.annotations.media.Content), + }) @GetMapping("/logout") public ResponseEntity logoutUser(Authentication authentication){ authService.logOut(authentication); return ResponseEntity.noContent().build(); } + @Operation(summary = "Gets a refresh token dto", description = "Asks for a new token and returns a refresh token dto") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "403", description = "You are not logged in, so there's no token to refresh", content = @io.swagger.v3.oas.annotations.media.Content), + }) @PostMapping("/refresh-token") public ResponseEntity refreshToken(@Valid @RequestBody RefreshTokenDto refreshTokenRequest){ return ResponseEntity.ok(authService.refreshToken(refreshTokenRequest)); diff --git a/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java b/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java index 42aaefa6..6678b886 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java @@ -60,6 +60,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.GET,"/auth/logout").authenticated() .requestMatchers(HttpMethod.POST,"/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/swagger/**").permitAll() .anyRequest().authenticated()) .csrf(AbstractHttpConfigurer::disable) .authenticationManager(authenticationManager) diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java index 6e3c4792..9a987adf 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java @@ -1,6 +1,7 @@ package lab.en2b.quizapi.auth.dtos; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; import java.util.List; @@ -11,12 +12,23 @@ @Builder @EqualsAndHashCode public class JwtResponseDto { + @Schema(description = "Token returned when login in", example = "eyJhbGciOiJIUzI1NiJ9fasdfatertyrtyJzdWIiOafsdfasDSASFDdfatCI6MTYzNzQwNjQwMfasd6546caytywafsd") private String token; + @JsonProperty("refresh_token") + @Schema(description = "Refresh token returned when login in", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYzNzQwNjQwMH0.7") private String refreshToken; + @JsonProperty("user_id") + @Schema(description = "Id of the user that just logged in", example = "1") private Long userId; + + @Schema(description = "Username of the user that just logged in", example = "username") private String username; + + @Schema(description = "Email of the user that just logged in", example = "example@email.com") private String email; + + @Schema(description = "Roles for the user that just logged in", example = "[\"ROLE_USER\"]") private List roles; } diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java index 15a639e9..82c0cc31 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.auth.dtos; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; @@ -14,8 +15,11 @@ public class LoginDto { @NonNull @NotBlank @Email + @Schema(description = "Email used for login" ,example = "example@email.com") private String email; + @NonNull @NotBlank + @Schema(description = "Password used for login" , example = "password") private String password; } diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java index da978fb4..84b5c0d4 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java @@ -1,6 +1,7 @@ package lab.en2b.quizapi.auth.dtos; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Data; @@ -14,5 +15,6 @@ public class RefreshTokenDto { @JsonProperty("refresh_token") @NonNull @NotEmpty + @Schema(description = "Refresh token to use for refreshing JWT", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYzNzQwNjQwMH0.7") private String refreshToken; } diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java index b7d1323a..5133cc61 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java @@ -1,6 +1,7 @@ package lab.en2b.quizapi.auth.dtos; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @Getter @@ -11,7 +12,10 @@ @Builder public class RefreshTokenResponseDto { + @Schema(description = "Token returned when refreshing", example = "eyJhbGciOiJIUzI1NiJ9fasdfatertyrtyJzdWIiOafsdfasDSASFDdfatCI6MTYzNzQwNjQwMfasd6546caytywafsd") private String token; + @JsonProperty("refresh_token") + @Schema(description = "Token used for refreshing", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTYzNzQwNjQwMH0.7") private String refreshToken; } diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java index 94e474ef..05abfd5f 100644 --- a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.auth.dtos; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; @@ -14,11 +15,14 @@ public class RegisterDto { @NotBlank @NonNull @Email + @Schema(description = "Email used for registering", example = "example@email.com" ) private String email; @NonNull @NotBlank + @Schema(description = "Username used for registering", example = "example user" ) private String username; @NonNull @NotBlank + @Schema(description = "Password used for registering", example = "password" ) private String password; } diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/User.java b/api/src/main/java/lab/en2b/quizapi/commons/user/User.java index 593572bd..86fe52fa 100644 --- a/api/src/main/java/lab/en2b/quizapi/commons/user/User.java +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/User.java @@ -7,9 +7,11 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lab.en2b.quizapi.commons.exceptions.TokenRefreshException; +import lab.en2b.quizapi.game.Game; import lombok.*; import java.time.Instant; +import java.util.List; @Entity @Table( name = "users", @@ -56,6 +58,9 @@ public class User { @JsonProperty("role") private String role; + @OneToMany(mappedBy = "user") + private List games; + public String obtainRefreshIfValid() { if(getRefreshExpiration() == null || getRefreshExpiration().compareTo(Instant.now()) < 0){ throw new TokenRefreshException( "Invalid refresh token. Please make a new login request"); diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java index 780f15cf..0af0c424 100644 --- a/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java @@ -9,9 +9,9 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - Boolean existsByUsername(String username); + boolean existsByUsername(String username); - Boolean existsByEmail(String email); + boolean existsByEmail(String email); Optional findByRefreshToken(String refreshToken); } diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java new file mode 100644 index 00000000..14e860f6 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java @@ -0,0 +1,12 @@ +package lab.en2b.quizapi.commons.user; + +import lombok.*; +@AllArgsConstructor +@Data +@Builder +@EqualsAndHashCode +public class UserResponseDto { + private Long id; + private String username; + private String email; +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java index 50ae66d0..3b3936bc 100644 --- a/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java @@ -5,6 +5,7 @@ import lab.en2b.quizapi.commons.exceptions.InvalidAuthenticationException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -26,8 +27,10 @@ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundExcep return UserDetailsImpl.build(userRepository.findByEmail(email).orElseThrow(() -> new InvalidAuthenticationException("Invalid email or password provided!"))); } public void createUser(RegisterDto registerRequest, String roleName){ - if (userRepository.existsByEmail(registerRequest.getEmail()) || userRepository.existsByUsername(registerRequest.getUsername())) { + if (userRepository.existsByEmail(registerRequest.getEmail()) ) { throw new IllegalArgumentException("Error: email is already in use!"); + }else if( userRepository.existsByUsername(registerRequest.getUsername())){ + throw new IllegalArgumentException("Error: username is already in use!"); } userRepository.save(User.builder() @@ -56,4 +59,9 @@ public void deleteRefreshToken(Long id) { user.setRefreshExpiration(null); userRepository.save(user); } + + public User getUserByAuthentication(Authentication authentication) { + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + return userRepository.findByEmail(userDetails.getEmail()).orElseThrow(); + } } diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/mappers/UserResponseDtoMapper.java b/api/src/main/java/lab/en2b/quizapi/commons/user/mappers/UserResponseDtoMapper.java new file mode 100644 index 00000000..ac9b0fc0 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/mappers/UserResponseDtoMapper.java @@ -0,0 +1,19 @@ +package lab.en2b.quizapi.commons.user.mappers; + +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.commons.user.UserResponseDto; +import org.springframework.stereotype.Service; + +import java.util.function.Function; + +@Service +public class UserResponseDtoMapper implements Function { + @Override + public UserResponseDto apply(User user) { + return UserResponseDto.builder() + .id(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .build(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/utils/InsertDataUtils.java b/api/src/main/java/lab/en2b/quizapi/commons/utils/InsertDataUtils.java deleted file mode 100644 index 94bab5fb..00000000 --- a/api/src/main/java/lab/en2b/quizapi/commons/utils/InsertDataUtils.java +++ /dev/null @@ -1,96 +0,0 @@ -package lab.en2b.quizapi.commons.utils; - -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lab.en2b.quizapi.questions.answer.Answer; -import lab.en2b.quizapi.questions.answer.AnswerCategory; -import lab.en2b.quizapi.questions.answer.AnswerRepository; -import lab.en2b.quizapi.questions.question.Question; -import lab.en2b.quizapi.questions.question.QuestionCategory; -import lab.en2b.quizapi.questions.question.QuestionRepository; -import lab.en2b.quizapi.questions.question.QuestionType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -@RequiredArgsConstructor -//@Service -public class InsertDataUtils { - - private final QuestionRepository questionRepository; - private final AnswerRepository answerRepository; - /** - * Method for testing purposes in charge of creating dummy questions - */ - @PostConstruct - public void initDummy(){ - //Creation of the questions - Question q1 = Question.builder() - .content("What's the capital of Spain?") - .type(QuestionType.TEXT) - .questionCategory(QuestionCategory.GEOGRAPHY) - .language("en") - .answerCategory(AnswerCategory.CITY) - .build(); - - Question q2 = Question.builder() - .content("What's the capital of Germany?") - .type(QuestionType.TEXT) - .questionCategory(QuestionCategory.GEOGRAPHY) - .language("en") - .answerCategory(AnswerCategory.CITY) - .build(); - - Question q3 = Question.builder() - .content("What's the capital of Italy?") - .type(QuestionType.TEXT) - .questionCategory(QuestionCategory.GEOGRAPHY) - .language("en") - .answerCategory(AnswerCategory.CITY) - .build(); - - // Creation of the answers - Answer a1 = new Answer(); - a1.setText("Madrid"); - a1.setCategory(AnswerCategory.CITY); - - Answer a2 = new Answer(); - a2.setText("London"); - a2.setCategory(AnswerCategory.CITY); - - Answer a3 = new Answer(); - a3.setText("Berlin"); - a3.setCategory(AnswerCategory.CITY); - - Answer a4 = new Answer(); - a4.setText("Rome"); - a4.setCategory(AnswerCategory.CITY); - - answerRepository.save(a1); - answerRepository.save(a2); - answerRepository.save(a3); - answerRepository.save(a4); - - List answers = new ArrayList<>(); - answers.add(a1);answers.add(a2);answers.add(a3);answers.add(a4); - q1.setAnswers(answers);q2.setAnswers(answers);q3.setAnswers(answers); - q1.setCorrectAnswer(a1); - q2.setCorrectAnswer(a3); - q3.setCorrectAnswer(a4); - - - questionRepository.save(q1); - questionRepository.save(q2); - questionRepository.save(q3); - - } - - @PreDestroy - public void cleanUp(){ - questionRepository.deleteAll(); - answerRepository.deleteAll(); - } - -} diff --git a/api/src/main/java/lab/en2b/quizapi/game/Game.java b/api/src/main/java/lab/en2b/quizapi/game/Game.java new file mode 100644 index 00000000..1cb2567e --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/Game.java @@ -0,0 +1,114 @@ +package lab.en2b.quizapi.game; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.questions.answer.Answer; +import lab.en2b.quizapi.questions.question.Question; +import lab.en2b.quizapi.questions.question.QuestionRepository; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "games") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class Game { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Setter(AccessLevel.NONE) + private Long id; + + private int rounds = 9; + private int actualRound = 0; + + private int correctlyAnsweredQuestions = 0; + private String language; + private LocalDateTime roundStartTime; + @NonNull + private Integer roundDuration; + private boolean currentQuestionAnswered; + + @ManyToOne + @NotNull + @JoinColumn(name = "user_id") + private User user; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name="games_questions", + joinColumns= + @JoinColumn(name="game_id", referencedColumnName="id"), + inverseJoinColumns= + @JoinColumn(name="question_id", referencedColumnName="id") + ) + private List questions; + + public void newRound(Question question){ + if(getActualRound() != 0){ + if (isGameOver()) + throw new IllegalStateException("You can't start a round for a finished game!"); + if(!currentRoundIsOver()) + throw new IllegalStateException("You can't start a new round when the current round is not over yet!"); + } + + setCurrentQuestionAnswered(false); + getQuestions().add(question); + increaseRound(); + setRoundStartTime(LocalDateTime.now()); + } + + private void increaseRound(){ + setActualRound(getActualRound() + 1); + } + + public boolean isGameOver(){ + return getActualRound() > getRounds(); + } + + public Question getCurrentQuestion() { + if(getQuestions().isEmpty()) + throw new IllegalStateException("The game hasn't started yet!"); + if(currentRoundIsOver()) + throw new IllegalStateException("The current round is over!"); + if(isGameOver()) + throw new IllegalStateException("The game is over!"); + return getQuestions().get(getQuestions().size()-1); + } + + private boolean currentRoundIsOver(){ + return currentQuestionAnswered || roundTimeHasExpired(); + } + + private boolean roundTimeHasExpired(){ + return LocalDateTime.now().isAfter(getRoundStartTime().plusSeconds(getRoundDuration())); + } + + public void answerQuestion(Long answerId, QuestionRepository questionRepository){ + if(currentRoundIsOver()) + throw new IllegalStateException("You can't answer a question when the current round is over!"); + if (isGameOver()) + throw new IllegalStateException("You can't answer a question when the game is over!"); + Question q = getCurrentQuestion(); + if (q.getAnswers().stream().map(Answer::getId).noneMatch(i -> i.equals(answerId))) + throw new IllegalArgumentException("The answer you provided is not one of the options"); + if(q.isCorrectAnswer(answerId)){ + setCorrectlyAnsweredQuestions(getCorrectlyAnsweredQuestions() + 1); + } + setCurrentQuestionAnswered(true); + } + public void setLanguage(String language){ + if(!isLanguageSupported(language)) + throw new IllegalArgumentException("The language you provided is not supported"); + this.language = language; + } + + private boolean isLanguageSupported(String language) { + return language.equals("en") || language.equals("es"); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/GameController.java b/api/src/main/java/lab/en2b/quizapi/game/GameController.java new file mode 100644 index 00000000..0e2516e4 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/GameController.java @@ -0,0 +1,47 @@ +package lab.en2b.quizapi.game; + +import lab.en2b.quizapi.game.dtos.GameAnswerDto; +import lab.en2b.quizapi.game.dtos.GameResponseDto; +import lab.en2b.quizapi.questions.question.dtos.QuestionResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/games") +@RequiredArgsConstructor +public class GameController { + private final GameService gameService; + @PostMapping("/new") + public ResponseEntity newGame(Authentication authentication){ + return ResponseEntity.ok(gameService.newGame(authentication)); + } + + @PostMapping("/{id}/startRound") + public ResponseEntity startRound(@PathVariable Long id, Authentication authentication){ + return ResponseEntity.ok(gameService.startRound(id, authentication)); + } + + @GetMapping("/{id}/question") + public ResponseEntity getCurrentQuestion(@PathVariable Long id, Authentication authentication){ + return ResponseEntity.ok(gameService.getCurrentQuestion(id, authentication)); + } + + @PostMapping("/{id}/answer") + public ResponseEntity answerQuestion(@PathVariable Long id, @RequestBody GameAnswerDto dto, Authentication authentication){ + return ResponseEntity.ok(gameService.answerQuestion(id, dto, authentication)); + } + + @PutMapping("/{id}/language") + public ResponseEntity changeLanguage(@PathVariable Long id, @RequestParam String language, Authentication authentication){ + return ResponseEntity.ok(gameService.changeLanguage(id, language, authentication)); + } + + @GetMapping("/{id}/details") + public ResponseEntity getGameDetails(@PathVariable Long id, Authentication authentication){ + return ResponseEntity.ok(gameService.getGameDetails(id, authentication)); + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java b/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java new file mode 100644 index 00000000..ec63787d --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java @@ -0,0 +1,13 @@ +package lab.en2b.quizapi.game; + + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface GameRepository extends JpaRepository { + + @Query(value = "SELECT * FROM Games g WHERE id=?1 AND user_id=?2", nativeQuery = true) + Optional findByIdForUser(Long gameId, Long userId); +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/GameService.java b/api/src/main/java/lab/en2b/quizapi/game/GameService.java new file mode 100644 index 00000000..28963d4a --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/GameService.java @@ -0,0 +1,65 @@ +package lab.en2b.quizapi.game; + +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.commons.user.UserService; +import lab.en2b.quizapi.game.dtos.GameAnswerDto; +import lab.en2b.quizapi.game.dtos.GameResponseDto; +import lab.en2b.quizapi.game.mappers.GameResponseDtoMapper; +import lab.en2b.quizapi.questions.answer.AnswerRepository; +import lab.en2b.quizapi.questions.answer.dtos.AnswerDto; +import lab.en2b.quizapi.questions.question.QuestionRepository; +import lab.en2b.quizapi.questions.question.QuestionService; +import lab.en2b.quizapi.questions.question.dtos.QuestionResponseDto; +import lab.en2b.quizapi.questions.question.mappers.QuestionResponseDtoMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; + +@Service +@RequiredArgsConstructor +public class GameService { + private final GameRepository gameRepository; + private final GameResponseDtoMapper gameResponseDtoMapper; + private final UserService userService; + private final QuestionRepository questionRepository; + private final QuestionResponseDtoMapper questionResponseDtoMapper; + public GameResponseDto newGame(Authentication authentication) { + return gameResponseDtoMapper.apply(gameRepository.save(Game.builder() + .user(userService.getUserByAuthentication(authentication)) + .questions(new ArrayList<>()) + .rounds(9) + .correctlyAnsweredQuestions(0) + .roundDuration(30) + .language("en") + .build())); + } + + public GameResponseDto startRound(Long id, Authentication authentication) { + Game game = gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow(); + game.newRound(questionRepository.findRandomQuestion(game.getLanguage())); + return gameResponseDtoMapper.apply(gameRepository.save(game)); + } + + public QuestionResponseDto getCurrentQuestion(Long id, Authentication authentication){ + Game game = gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow(); + return questionResponseDtoMapper.apply(game.getCurrentQuestion()); + } + + public GameResponseDto answerQuestion(Long id, GameAnswerDto dto, Authentication authentication){ + Game game = gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow(); + game.answerQuestion(dto.getAnswerId(), questionRepository); + return gameResponseDtoMapper.apply(game); + } + + public GameResponseDto changeLanguage(Long id, String language, Authentication authentication) { + Game game = gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow(); + game.setLanguage(language); + return gameResponseDtoMapper.apply(gameRepository.save(game)); + } + + public GameResponseDto getGameDetails(Long id, Authentication authentication) { + return gameResponseDtoMapper.apply(gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow()); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/dtos/GameAnswerDto.java b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameAnswerDto.java new file mode 100644 index 00000000..d1bc5d5e --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameAnswerDto.java @@ -0,0 +1,19 @@ +package lab.en2b.quizapi.game.dtos; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.*; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Setter +public class GameAnswerDto { + @NonNull + @NotNull + @PositiveOrZero + @JsonProperty("answer_id") + private Long answerId; +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/dtos/GameResponseDto.java b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameResponseDto.java new file mode 100644 index 00000000..192e0bc9 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameResponseDto.java @@ -0,0 +1,35 @@ +package lab.en2b.quizapi.game.dtos; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lab.en2b.quizapi.commons.user.UserResponseDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@AllArgsConstructor +@Data +@Builder +@EqualsAndHashCode +public class GameResponseDto { + private Long id; + + private UserResponseDto user; + + private int rounds; + + private int actualRound; + + @JsonProperty("correctly_answered_questions") + private int correctlyAnsweredQuestions; + + @JsonProperty("round_start_time") + private LocalDateTime roundStartTime; + + @JsonProperty("round_duration") + private int roundDuration; + + private boolean isGameOver; +} diff --git a/api/src/main/java/lab/en2b/quizapi/game/mappers/GameResponseDtoMapper.java b/api/src/main/java/lab/en2b/quizapi/game/mappers/GameResponseDtoMapper.java new file mode 100644 index 00000000..fed24354 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/game/mappers/GameResponseDtoMapper.java @@ -0,0 +1,28 @@ +package lab.en2b.quizapi.game.mappers; + +import lab.en2b.quizapi.commons.user.mappers.UserResponseDtoMapper; +import lab.en2b.quizapi.game.Game; +import lab.en2b.quizapi.game.dtos.GameResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +public class GameResponseDtoMapper implements Function{ + private final UserResponseDtoMapper userResponseDtoMapper; + @Override + public GameResponseDto apply(Game game) { + return GameResponseDto.builder() + .id(game.getId()) + .user(userResponseDtoMapper.apply(game.getUser())) + .rounds(game.getRounds()) + .correctlyAnsweredQuestions(game.getCorrectlyAnsweredQuestions()) + .actualRound(game.getActualRound()) + .roundDuration(game.getRoundDuration()) + .roundStartTime(game.getRoundStartTime()) + .isGameOver(game.isGameOver()) + .build(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/questions/answer/Answer.java b/api/src/main/java/lab/en2b/quizapi/questions/answer/Answer.java index f1a70eb2..1c8bd8ad 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/answer/Answer.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/answer/Answer.java @@ -19,7 +19,10 @@ public class Answer { @Setter(AccessLevel.NONE) private Long id; private String text; + @Enumerated(EnumType.STRING) private AnswerCategory category; + private String language; + @OneToMany(mappedBy = "correctAnswer", fetch = FetchType.EAGER) private List questions; diff --git a/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerCategory.java b/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerCategory.java index 13412a21..962fd23b 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerCategory.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerCategory.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.questions.answer; public enum AnswerCategory { - CITY, COUNTRY, PERSON, EVENT, DATE, OTHER + OTHER, CAPITAL_CITY, COUNTRY, SONG, STADIUM, DATE, PERSON, EVENT } + diff --git a/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerRepository.java b/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerRepository.java index a84efb62..004f092c 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerRepository.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/answer/AnswerRepository.java @@ -1,6 +1,13 @@ package lab.en2b.quizapi.questions.answer; +import lab.en2b.quizapi.questions.question.Question; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; public interface AnswerRepository extends JpaRepository { + + @Query(value = "SELECT * FROM answers WHERE category=?1 AND language=?2 ORDER BY RANDOM() LIMIT ?3", nativeQuery = true) + List findDistractors(String answerCategory, String lang, int numDistractors); } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerDto.java b/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerDto.java index 9f2dfdbe..96153497 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerDto.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerDto.java @@ -1,6 +1,7 @@ package lab.en2b.quizapi.questions.answer.dtos; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; import lombok.*; @@ -15,5 +16,6 @@ public class AnswerDto { @NonNull @NotNull @PositiveOrZero + @Schema(description = "Token returned when login in",example = "0") private Long answerId; } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerResponseDto.java b/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerResponseDto.java index 0f6e9c4c..ab79f487 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/answer/dtos/AnswerResponseDto.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.questions.answer.dtos; +import io.swagger.v3.oas.annotations.media.Schema; import lab.en2b.quizapi.questions.answer.AnswerCategory; import lombok.*; @@ -10,7 +11,13 @@ @Builder @EqualsAndHashCode public class AnswerResponseDto { + + @Schema(description = "Id for the answer",example = "1") private Long id; + + @Schema(description = "Text for the answer",example = "Paris") private String text; + + @Schema(description = "Category for the answer",example = "CITY") private AnswerCategory category; } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/Question.java b/api/src/main/java/lab/en2b/quizapi/questions/question/Question.java index 09484751..8ff15be0 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/Question.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/Question.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import lab.en2b.quizapi.game.Game; import lab.en2b.quizapi.questions.answer.Answer; import lab.en2b.quizapi.questions.answer.AnswerCategory; import lombok.*; @@ -35,12 +36,25 @@ public class Question { @NotNull @JoinColumn(name = "correct_answer_id") private Answer correctAnswer; + @Enumerated(EnumType.STRING) @Column(name = "question_category") private QuestionCategory questionCategory; @Column(name = "answer_category") private AnswerCategory answerCategory; private String language; + @Enumerated(EnumType.STRING) private QuestionType type; + @ManyToMany(mappedBy = "questions") + private List games; + public boolean isCorrectAnswer(Long answerId){ + return correctAnswer.getId().equals(answerId); + } + public AnswerCategory getAnswerCategory() { + return correctAnswer.getCategory(); + } + public String getLanguage(){ + return correctAnswer.getLanguage(); + } } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java index c5cd4a80..e81ca5ac 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java @@ -1,5 +1,8 @@ package lab.en2b.quizapi.questions.question; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import jakarta.validation.constraints.PositiveOrZero; import lab.en2b.quizapi.questions.answer.dtos.AnswerDto; @@ -15,16 +18,34 @@ public class QuestionController { private final QuestionService questionService; + @Operation(summary = "Sends an answer", description = "Sends the answer dto for a given question id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "403", description = "You are not logged in", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "404", description = "Not found - There is not a question with that id", content = @io.swagger.v3.oas.annotations.media.Content) + }) @PostMapping("/{questionId}/answer") private ResponseEntity answerQuestion(@PathVariable @PositiveOrZero Long questionId, @Valid @RequestBody AnswerDto answerDto){ return ResponseEntity.ok(questionService.answerQuestion(questionId,answerDto)); } + @Operation(summary = "Gets a random question", description = "Gets a random question in the language") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "403", description = "You are not logged in", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "404", description = "Language does not exist or it is misspelled", content = @io.swagger.v3.oas.annotations.media.Content) + }) @GetMapping("/new") private ResponseEntity generateQuestion(@RequestParam(required = false) String lang){ return ResponseEntity.ok(questionService.getRandomQuestion(lang)); } + @Operation(summary = "Gets a question", description = "Gets a question given a question id") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "403", description = "You are not logged in", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "404", description = "Not found - There is not a question with that id", content = @io.swagger.v3.oas.annotations.media.Content) + }) @GetMapping("/{id}") private ResponseEntity getQuestionById(@PathVariable @PositiveOrZero Long id){ return ResponseEntity.ok(questionService.getQuestionById(id)); diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionHelper.java b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionHelper.java new file mode 100644 index 00000000..62e38fa4 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionHelper.java @@ -0,0 +1,28 @@ +package lab.en2b.quizapi.questions.question; + +import lab.en2b.quizapi.questions.answer.Answer; +import lab.en2b.quizapi.questions.answer.AnswerCategory; +import lab.en2b.quizapi.questions.answer.AnswerRepository; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + + +@Component +public class QuestionHelper { + public static List getDistractors(AnswerRepository answerRepository, Question question){ + List distractors = new ArrayList<>(); + + AnswerCategory cat = question.getAnswerCategory(); + switch (cat){ // Write the case only for the exceptions + case COUNTRY: + // Implement more cases + break; + default: + distractors = answerRepository.findDistractors(cat.toString(), question.getLanguage(), 1); + } + + return distractors; + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionRepository.java b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionRepository.java index eb4bef47..35c61d92 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionRepository.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionRepository.java @@ -4,6 +4,6 @@ import org.springframework.data.jpa.repository.Query; public interface QuestionRepository extends JpaRepository { - @Query(value = "SELECT * FROM questions WHERE language=?1 ORDER BY RANDOM() LIMIT 1", nativeQuery = true) + @Query(value = "SELECT q.* FROM questions q INNER JOIN answers a ON q.correct_answer_id=a.id WHERE a.language = ?1 ORDER BY RANDOM() LIMIT 1", nativeQuery = true) Question findRandomQuestion(String lang); } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionService.java b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionService.java index 5126e7d1..347185be 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionService.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionService.java @@ -3,6 +3,7 @@ import jakarta.annotation.PostConstruct; import lab.en2b.quizapi.questions.answer.Answer; import lab.en2b.quizapi.questions.answer.AnswerCategory; +import lab.en2b.quizapi.questions.answer.AnswerRepository; import lab.en2b.quizapi.questions.answer.dtos.AnswerDto; import lab.en2b.quizapi.questions.question.dtos.AnswerCheckResponseDto; import lab.en2b.quizapi.questions.question.dtos.QuestionResponseDto; @@ -17,6 +18,7 @@ @RequiredArgsConstructor public class QuestionService { + private final AnswerRepository answerRepository; private final QuestionRepository questionRepository; private final QuestionResponseDtoMapper questionResponseDtoMapper; @@ -37,10 +39,29 @@ public QuestionResponseDto getRandomQuestion(String lang) { if (lang==null || lang.isBlank()) { lang = "en"; } - return questionResponseDtoMapper.apply(questionRepository.findRandomQuestion(lang)); + Question q = questionRepository.findRandomQuestion(lang); + loadAnswers(q); + + return questionResponseDtoMapper.apply(q); } public QuestionResponseDto getQuestionById(Long id) { return questionResponseDtoMapper.apply(questionRepository.findById(id).orElseThrow()); } + + + /** + * Load the answers for a question (The distractors and the correct one) + * @param question The question to load the answers for + */ + public void loadAnswers(Question question) { + // Create the new answers list with the distractors + List answers = new ArrayList<>(QuestionHelper.getDistractors(answerRepository, question)); + + // Add the correct answer in a random position + int randomIndex = (int) (Math.random() * (answers.size() + 1)); + answers.add(randomIndex, question.getCorrectAnswer()); + + question.setAnswers(answers); + } } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/AnswerCheckResponseDto.java b/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/AnswerCheckResponseDto.java index 89c91c8d..4ead74f4 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/AnswerCheckResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/AnswerCheckResponseDto.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.questions.question.dtos; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @NoArgsConstructor @@ -8,5 +9,7 @@ @Setter @EqualsAndHashCode public class AnswerCheckResponseDto { + + @Schema(description = "Whether the answer was correct or not", example = "true") private boolean wasCorrect; } diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/QuestionResponseDto.java b/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/QuestionResponseDto.java index 11d985dd..263b36fd 100644 --- a/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/QuestionResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/dtos/QuestionResponseDto.java @@ -1,5 +1,6 @@ package lab.en2b.quizapi.questions.question.dtos; +import io.swagger.v3.oas.annotations.media.Schema; import lab.en2b.quizapi.questions.answer.AnswerCategory; import lab.en2b.quizapi.questions.answer.dtos.AnswerResponseDto; import lab.en2b.quizapi.questions.question.QuestionCategory; @@ -15,11 +16,27 @@ @Builder @EqualsAndHashCode public class QuestionResponseDto { + + @Schema(description = "Id for the question", example = "1") private Long id; + + @Schema(description = "Content of the question", example = "What is the capital of France?") private String content; + + @Schema(description = "Answers for the question", + example = "[{\"id\":1,\"text\":\"Paris\",\"category\":\"CITY\"},{\"id\":2,\"text\":\"London\",\"category\":\"CITY\"}" + + ",{\"id\":3,\"text\":\"Berlin\",\"category\":\"CITY\"},{\"id\":4,\"text\":\"Madrid\",\"category\":\"CITY\"}]") private List answers; + + @Schema(description = "Category for the question",example = "GEOGRAPHY") private QuestionCategory questionCategory; + + @Schema(description = "Answer category for the question",example = "CITY") private AnswerCategory answerCategory; + + @Schema(description = "Language for the question",example = "en") private String language; + + @Schema(description = "Type of the question",example = "MULTIPLE_CHOICE") private QuestionType type; } diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index ac2a0a0e..788a68b8 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -4,4 +4,9 @@ spring.jpa.hibernate.ddl-auto=update spring.datasource.url=${DATABASE_URL} spring.datasource.username=${DATABASE_USER} spring.datasource.password=${DATABASE_PASSWORD} -server.http2.enabled=true \ No newline at end of file +server.http2.enabled=true +springdoc.swagger-ui.path=/swagger/swagger-ui.html +springdoc.api-docs.path=/swagger/api-docs + +management.endpoints.web.exposure.include=prometheus +management.endpoint.prometheus.enabled=true \ No newline at end of file diff --git a/api/src/test/java/lab/en2b/quizapi/auth/AuthControllerTest.java b/api/src/test/java/lab/en2b/quizapi/auth/AuthControllerTest.java index cd785477..1871688f 100644 --- a/api/src/test/java/lab/en2b/quizapi/auth/AuthControllerTest.java +++ b/api/src/test/java/lab/en2b/quizapi/auth/AuthControllerTest.java @@ -9,6 +9,7 @@ import lab.en2b.quizapi.auth.jwt.JwtUtils; import lab.en2b.quizapi.commons.user.UserService; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -17,6 +18,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultMatcher; +import java.util.Arrays; +import java.util.List; + import static lab.en2b.quizapi.commons.utils.TestUtils.asJsonString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -41,7 +45,14 @@ public class AuthControllerTest { final RefreshTokenResponseDto defaultRefreshTokenResponseDto = RefreshTokenResponseDto.builder().build(); @Test void registerUserShouldReturn200() throws Exception { - testRegister(asJsonString( new RegisterDto("test@email.com","test","testing")) + Mockito.when(authService.login(new LoginDto("test@email.com", "testing"))) + .thenReturn(new JwtResponseDto("token", + "refreshToken", + 0L, + "test", + "test@email.com", + List.of("user"))); + testRegister(asJsonString(new RegisterDto("test@email.com","test","testing")) ,status().isOk()); } @Test diff --git a/api/src/test/java/lab/en2b/quizapi/auth/AuthServiceTest.java b/api/src/test/java/lab/en2b/quizapi/auth/AuthServiceTest.java index 760fdb72..3f83e084 100644 --- a/api/src/test/java/lab/en2b/quizapi/auth/AuthServiceTest.java +++ b/api/src/test/java/lab/en2b/quizapi/auth/AuthServiceTest.java @@ -21,8 +21,7 @@ import java.util.List; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -87,6 +86,23 @@ void testRegister(){ } + @Test + void testRegisterAlreadyExistingUser(){ + + when(userRepository.existsByEmail(any())).thenReturn(false); + when(userRepository.existsByUsername(any())).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> authService.register(new RegisterDto("test","username","password"))); + } + + @Test + void testRegisterAlreadyExistingEmail(){ + + when(userRepository.existsByEmail(any())).thenReturn(true); + + assertThrows(IllegalArgumentException.class, () -> authService.register(new RegisterDto("test","username","password"))); + } + @Test void testRefreshToken(){ diff --git a/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java b/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java new file mode 100644 index 00000000..a5e07da1 --- /dev/null +++ b/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java @@ -0,0 +1,164 @@ +package lab.en2b.quizapi.game; + +import lab.en2b.quizapi.auth.config.SecurityConfig; +import lab.en2b.quizapi.auth.jwt.JwtUtils; +import lab.en2b.quizapi.commons.user.UserService; +import lab.en2b.quizapi.game.dtos.GameAnswerDto; +import lab.en2b.quizapi.questions.answer.dtos.AnswerDto; +import lab.en2b.quizapi.questions.question.QuestionController; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static lab.en2b.quizapi.commons.utils.TestUtils.asJsonString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(GameController.class) +@AutoConfigureMockMvc +@Import(SecurityConfig.class) +public class GameControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + JwtUtils jwtUtils; + + @MockBean + UserService userService; + + @MockBean + GameService gameService; + + @Test + void newQuestionShouldReturn403() throws Exception{ + mockMvc.perform(post("/games/new") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void newQuestionShouldReturn200() throws Exception{ + mockMvc.perform(post("/games/new") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void startRoundShouldReturn403() throws Exception{ + mockMvc.perform(post("/games/1/startRound") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void startRoundShouldReturn200() throws Exception{ + mockMvc.perform(post("/games/1/startRound") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void getCurrentQuestionShouldReturn403() throws Exception{ + mockMvc.perform(get("/games/1/question") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void getCurrentQuestionShouldReturn200() throws Exception{ + mockMvc.perform(get("/games/1/question") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void answerQuestionShouldReturn403() throws Exception{ + mockMvc.perform(post("/games/1/answer") + .contentType("application/json") + .content(asJsonString(new GameAnswerDto(1L))) + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void answerQuestionShouldReturn200() throws Exception{ + mockMvc.perform(post("/games/1/answer") + .content(asJsonString(new GameAnswerDto(1L))) + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void answerQuestionShouldReturn400() throws Exception{ + mockMvc.perform(post("/games/1/answer") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + void changeLanguageShouldReturn403() throws Exception{ + mockMvc.perform(put("/games/1/language?language=en") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void changeLanguageShouldReturn200() throws Exception{ + mockMvc.perform(put("/games/1/language?language=en") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void changeLanguageShouldReturn400() throws Exception{ + mockMvc.perform(put("/games/1/language") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + void getGameDetailsShouldReturn403() throws Exception{ + mockMvc.perform(get("/games/1/details") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + void getGameDetailsShouldReturn200() throws Exception{ + mockMvc.perform(get("/games/1/details") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + +} diff --git a/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java b/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java new file mode 100644 index 00000000..3ccd75f5 --- /dev/null +++ b/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java @@ -0,0 +1,336 @@ +package lab.en2b.quizapi.game; + +import ch.qos.logback.core.util.TimeUtil; +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.commons.user.UserResponseDto; +import lab.en2b.quizapi.commons.user.UserService; +import lab.en2b.quizapi.commons.user.mappers.UserResponseDtoMapper; +import lab.en2b.quizapi.game.dtos.GameAnswerDto; +import lab.en2b.quizapi.game.dtos.GameResponseDto; +import lab.en2b.quizapi.game.mappers.GameResponseDtoMapper; +import lab.en2b.quizapi.questions.answer.Answer; +import lab.en2b.quizapi.questions.answer.AnswerCategory; +import lab.en2b.quizapi.questions.answer.mappers.AnswerResponseDtoMapper; +import lab.en2b.quizapi.questions.question.*; +import lab.en2b.quizapi.questions.question.dtos.QuestionResponseDto; +import lab.en2b.quizapi.questions.question.mappers.QuestionResponseDtoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, SpringExtension.class}) +public class GameServiceTest { + + @InjectMocks + private GameService gameService; + + @Mock + private UserService userService; + + @Mock + private GameRepository gameRepository; + + @Mock + private QuestionRepository questionRepository; + + private User defaultUser; + private Question defaultQuestion; + private QuestionResponseDto defaultQuestionResponseDto; + private GameResponseDto defaultGameResponseDto; + + private UserResponseDto defaultUserResponseDto; + + private QuestionResponseDtoMapper questionResponseDtoMapper; + + @Mock + private Authentication authentication; + + private Game defaultGame; + + private Answer defaultCorrectAnswer; + + @BeforeEach + void setUp() { + this.questionResponseDtoMapper = new QuestionResponseDtoMapper(); + this.gameService = new GameService(gameRepository,new GameResponseDtoMapper(new UserResponseDtoMapper()), userService, questionRepository, questionResponseDtoMapper); + this.defaultUser = User.builder() + .id(1L) + .email("test@email.com") + .username("test") + .role("user") + .password("password") + .refreshToken("token") + .refreshExpiration(Instant.ofEpochSecond(TimeUtil.computeStartOfNextSecond(System.currentTimeMillis()+ 1000))) + .build(); + + this.defaultQuestion = Question.builder() + .id(1L) + .content("What is the capital of France?") + .answers(new ArrayList<>()) + .language("en") + .questionCategory(QuestionCategory.GEOGRAPHY) + .answerCategory(AnswerCategory.CAPITAL_CITY) + .type(QuestionType.TEXT) + .build(); + + defaultCorrectAnswer = Answer.builder() + .id(1L) + .text("Paris") + .category(AnswerCategory.CAPITAL_CITY) + .questions(List.of(defaultQuestion)) + .questionsWithThisAnswer(List.of(defaultQuestion)) + .language("en") + .build(); + + Answer defaultIncorrectAnswer = Answer.builder() + .id(2L) + .text("Tokio") + .category(AnswerCategory.CAPITAL_CITY) + .questions(List.of(defaultQuestion)) + .questionsWithThisAnswer(List.of(defaultQuestion)) + .build(); + + defaultQuestion.setCorrectAnswer(defaultCorrectAnswer); + defaultQuestion.getAnswers().add(defaultCorrectAnswer); + defaultQuestion.getAnswers().add(defaultIncorrectAnswer); + + this.defaultUserResponseDto = UserResponseDto.builder() + .id(1L) + .email("test@email.com") + .username("test") + .build(); + + this.defaultQuestionResponseDto = QuestionResponseDto.builder() + .id(1L) + .content("What is the capital of France?") + .answers(new ArrayList<>()) + .language("en") + .questionCategory(QuestionCategory.GEOGRAPHY) + .answerCategory(AnswerCategory.CAPITAL_CITY) + .type(QuestionType.TEXT) + .build(); + defaultQuestionResponseDto.getAnswers().add(new AnswerResponseDtoMapper().apply(defaultCorrectAnswer)); + defaultQuestionResponseDto.getAnswers().add(new AnswerResponseDtoMapper().apply(defaultIncorrectAnswer)); + LocalDateTime now = LocalDateTime.now(); + this.defaultGameResponseDto = GameResponseDto.builder() + .user(defaultUserResponseDto) + .rounds(9) + .correctlyAnsweredQuestions(0) + .roundDuration(30) + .build(); + this.defaultGame = Game.builder() + .id(1L) + .user(defaultUser) + .questions(new ArrayList<>()) + .rounds(9) + .correctlyAnsweredQuestions(0) + .language("en") + .roundDuration(30) + .build(); + } + + @Test + public void newGame(){ + Authentication authentication = mock(Authentication.class); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + GameResponseDto gameDto = gameService.newGame(authentication); + + assertEquals(defaultGameResponseDto, gameDto); + } + + @Test + public void startRound(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + GameResponseDto gameDto = gameService.startRound(1L, authentication); + GameResponseDto result = defaultGameResponseDto; + result.setActualRound(1); + result.setId(1L); + result.setRoundStartTime(defaultGame.getRoundStartTime()); + assertEquals(result, gameDto); + } + + @Test + public void startRoundGameOver(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + defaultGame.setActualRound(10); + assertThrows(IllegalStateException.class, () -> gameService.startRound(1L,authentication)); + } + + @Test + public void startRoundWhenRoundNotFinished(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + gameService.startRound(1L,authentication); + assertThrows(IllegalStateException.class, () -> gameService.startRound(1L,authentication)); + } + + @Test + public void getCurrentQuestion() { + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + gameService.startRound(1L,authentication); + QuestionResponseDto questionDto = gameService.getCurrentQuestion(1L,authentication); + assertEquals(defaultQuestionResponseDto, questionDto); + } + + @Test + public void getCurrentQuestionRoundNotStarted() { + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + assertThrows(IllegalStateException.class, () -> gameService.getCurrentQuestion(1L,authentication)); + } + + @Test + public void getCurrentQuestionRoundFinished() { + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + gameService.startRound(1L,authentication); + defaultGame.setRoundStartTime(LocalDateTime.now().minusSeconds(100)); + assertThrows(IllegalStateException.class, () -> gameService.getCurrentQuestion(1L,authentication)); + } + + @Test + public void getCurrentQuestionGameFinished() { + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.startRound(1L,authentication); + defaultGame.setActualRound(10); + assertThrows(IllegalStateException.class, () -> gameService.getCurrentQuestion(1L,authentication)); + } + + @Test + public void answerQuestionCorrectly(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + gameService.answerQuestion(1L, new GameAnswerDto(1L), authentication); + gameService.getGameDetails(1L, authentication); + assertEquals(defaultGame.getCorrectlyAnsweredQuestions(), 1); + assertTrue(defaultGame.isCurrentQuestionAnswered()); + } + + @Test + public void answerQuestionIncorrectly(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + gameService.answerQuestion(1L, new GameAnswerDto(2L), authentication); + gameService.getGameDetails(1L, authentication); + assertEquals(defaultGame.getCorrectlyAnsweredQuestions(), 0); + assertTrue(defaultGame.isCurrentQuestionAnswered()); + } + + @Test + public void answerQuestionWhenGameHasFinished(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + defaultGame.setActualRound(30); + assertThrows(IllegalStateException.class, () -> gameService.answerQuestion(1L, new GameAnswerDto(1L), authentication)); + } + + @Test + public void answerQuestionWhenRoundHasFinished(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + defaultGame.setRoundStartTime(LocalDateTime.now().minusSeconds(100)); + assertThrows(IllegalStateException.class, () -> gameService.answerQuestion(1L, new GameAnswerDto(1L), authentication)); + } + + @Test + public void answerQuestionInvalidId(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(questionRepository.findRandomQuestion(any())).thenReturn(defaultQuestion); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + assertThrows(IllegalArgumentException.class, () -> gameService.answerQuestion(1L, new GameAnswerDto(3L), authentication)); + } + + @Test + public void changeLanguage(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + GameResponseDto gameDto = gameService.newGame(authentication); + gameService.startRound(1L, authentication); + gameService.changeLanguage(1L, "es", authentication); + assertEquals(defaultGameResponseDto, gameDto); + } + + @Test + public void changeLanguageInvalidLanguage(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + gameService.newGame(authentication); + assertThrows(IllegalArgumentException.class, () -> gameService.changeLanguage(1L, "patata", authentication)); + } + + @Test + public void getGameDetails(){ + when(gameRepository.findByIdForUser(any(), any())).thenReturn(Optional.of(defaultGame)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + GameResponseDto gameDto = gameService.newGame(authentication); + gameService.startRound(1L, authentication); + gameService.getGameDetails(1L, authentication); + assertEquals(defaultGameResponseDto, gameDto); + } + + @Test + public void getGameDetailsInvalidId(){ + when(gameRepository.findByIdForUser(1L, 1L)).thenReturn(Optional.of(defaultGame)); + when(userService.getUserByAuthentication(authentication)).thenReturn(defaultUser); + when(gameRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + gameService.newGame(authentication); + gameService.startRound(1L, authentication); + assertThrows(NoSuchElementException.class, () -> gameService.getGameDetails(2L, authentication)); + } + +} diff --git a/api/src/test/java/lab/en2b/quizapi/questions/QuestionServiceTest.java b/api/src/test/java/lab/en2b/quizapi/questions/QuestionServiceTest.java index ccdc3b65..347790a4 100644 --- a/api/src/test/java/lab/en2b/quizapi/questions/QuestionServiceTest.java +++ b/api/src/test/java/lab/en2b/quizapi/questions/QuestionServiceTest.java @@ -2,6 +2,7 @@ import lab.en2b.quizapi.questions.answer.Answer; import lab.en2b.quizapi.questions.answer.AnswerCategory; +import lab.en2b.quizapi.questions.answer.AnswerRepository; import lab.en2b.quizapi.questions.answer.dtos.AnswerDto; import lab.en2b.quizapi.questions.answer.dtos.AnswerResponseDto; import lab.en2b.quizapi.questions.question.*; @@ -33,6 +34,8 @@ public class QuestionServiceTest { @Mock QuestionRepository questionRepository; + @Mock + AnswerRepository answerRepository; Question defaultQuestion; QuestionResponseDto defaultResponseDto; @@ -42,22 +45,19 @@ public class QuestionServiceTest { @BeforeEach void setUp() { - this.questionService = new QuestionService(questionRepository,new QuestionResponseDtoMapper()); + this.questionService = new QuestionService(answerRepository, questionRepository, new QuestionResponseDtoMapper()); defaultQuestion = Question.builder() .id(1L) - .content("What is the capital of France?") .answers(new ArrayList<>()) - .language("en") .questionCategory(QuestionCategory.GEOGRAPHY) - .answerCategory(AnswerCategory.CITY) .type(QuestionType.TEXT) .build(); defaultCorrectAnswer = Answer.builder() .id(1L) .text("Paris") - .category(AnswerCategory.CITY) + .category(AnswerCategory.CAPITAL_CITY) .questions(List.of(defaultQuestion)) .questionsWithThisAnswer(List.of(defaultQuestion)) .build(); @@ -65,7 +65,7 @@ void setUp() { defaultIncorrectAnswer = Answer.builder() .id(2L) .text("Tokio") - .category(AnswerCategory.CITY) + .category(AnswerCategory.CAPITAL_CITY) .questions(List.of(defaultQuestion)) .questionsWithThisAnswer(List.of(defaultQuestion)) .build(); @@ -77,12 +77,12 @@ void setUp() { List answersDto = new ArrayList<>(); answersDto.add(AnswerResponseDto.builder() .id(1L) - .category(AnswerCategory.CITY) + .category(AnswerCategory.CAPITAL_CITY) .text("Paris") .build()); answersDto.add(AnswerResponseDto.builder() .id(2L) - .category(AnswerCategory.CITY) + .category(AnswerCategory.CAPITAL_CITY) .text("Tokio") .build()); defaultResponseDto = QuestionResponseDto.builder() @@ -91,7 +91,7 @@ void setUp() { .answers(answersDto) .language("en") .questionCategory(QuestionCategory.GEOGRAPHY) - .answerCategory(AnswerCategory.CITY) + .answerCategory(AnswerCategory.CAPITAL_CITY) .type(QuestionType.TEXT) .build(); } @@ -101,7 +101,7 @@ void testGetRandomQuestion() { when(questionRepository.findRandomQuestion("en")).thenReturn(defaultQuestion); QuestionResponseDto response = questionService.getRandomQuestion(""); - assertEquals(response, defaultResponseDto); + assertEquals(response.getId(), defaultResponseDto.getId()); } @Test @@ -109,7 +109,7 @@ void testGetQuestionById(){ when(questionRepository.findById(any())).thenReturn(Optional.of(defaultQuestion)); QuestionResponseDto response = questionService.getQuestionById(1L); - assertEquals(response, defaultResponseDto); + assertEquals(response.getId(), defaultResponseDto.getId()); } @Test diff --git a/docker-compose.yml b/docker-compose.yml index 2b431d97..902d8f0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: - JWT_SECRET=${JWT_SECRET} networks: - mynetwork + depends_on: + - WIQ_DB ports: - "8080:8080" @@ -49,6 +51,8 @@ services: - DATABASE_PASSWORD=${DATABASE_PASSWORD} networks: - mynetwork + depends_on: + - WIQ_DB webapp: container_name: webapp-${teamname:-defaultASW} @@ -62,10 +66,46 @@ services: - REACT_APP_API_ENDPOINT=${API_URI} ports: - "3000:3000" + + prometheus: + image: prom/prometheus + container_name: prometheus-${teamname:-defaultASW} + profiles: ["dev"] + networks: + - mynetwork + volumes: + - ./quiz-api/monitoring/prometheus:/etc/prometheus + - prometheus_data:/prometheus + ports: + - "9090:9090" + depends_on: + - api + + grafana: + image: grafana/grafana + container_name: grafana-${teamname:-defaultASW} + profiles: [ "dev" ] + networks: + - mynetwork + volumes: + - grafana_data:/var/lib/grafana + - ./quiz-api/monitoring/grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SERVER_HTTP_PORT=9091 + - GF_AUTH_DISABLE_LOGIN_FORM=true + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + ports: + - "9091:9091" + depends_on: + - prometheus volumes: postgres_data: + prometheus_data: + grafana_data: + networks: mynetwork: diff --git a/docs/diagrams/BusinessContextDiagram.puml b/docs/diagrams/BusinessContextDiagram.puml index 2a2fbdf7..5387b081 100644 --- a/docs/diagrams/BusinessContextDiagram.puml +++ b/docs/diagrams/BusinessContextDiagram.puml @@ -3,16 +3,22 @@ !include title Context Diagram for the WIQ System -LAYOUT_WITH_LEGEND() +AddElementTag("Person", $bgColor="#salmon", $fontColor="#white") +AddElementTag("Internal system", $bgColor="#darksalmon", $fontColor="#white") +AddElementTag("External system", $bgColor="#peachpuff", $fontColor="#963b17") + +AddRelTag("backup", $textColor="orange", $lineColor="orange", $lineStyle = DashedLine()) + 'Containers -Person(player, Player,"An authenticated player that wants to play WIQ games") +Person(player, Player,"An authenticated player that wants to play WIQ games", $tags="Person") -Container(wiq, "WIQ Application","", "Application that allows the users to play WIQ games") +Container(wiq, "WIQ Application","", "Application that allows the users to play WIQ games", $tags="Internal system") -System_Ext(wikidata,"WikiData API","Contains the information used for the question generation") +System_Ext(wikidata,"WikiData API","Contains the information used for the question generation", $tags="External system") 'RELATIONS Rel(player,wiq,"Plays games") Rel(wiq,wikidata,"Asks for data for question generation") +SHOW_LEGEND() @enduml \ No newline at end of file diff --git a/docs/diagrams/DeploymentDiagram.puml b/docs/diagrams/deployment/DeploymentDiagram.puml similarity index 85% rename from docs/diagrams/DeploymentDiagram.puml rename to docs/diagrams/deployment/DeploymentDiagram.puml index eaa1314c..8faacfe1 100644 --- a/docs/diagrams/DeploymentDiagram.puml +++ b/docs/diagrams/deployment/DeploymentDiagram.puml @@ -18,6 +18,9 @@ node "Server Hosting WIQ" #PeachPuff { node "PostgreSQL Docker" { database "WIQ Database" } + node "Question generator" { + component "Question_Generator.jar" + } } node "User Computer" #DarkSalmon{ frame "Web Client" @@ -29,4 +32,5 @@ node "WikiData Server" #DarkSalmon { "WIQ React Application" ..> "WIQ_API.jar" : "HTTPS" "WIQ_API.jar" ..> "WIQ Database" : "JPA" "WIQ API" ..> "WikiData REST API" : "HTTPS, SPARQL" +"Question_Generator.jar" ..> "WIQ Database" : "JPA @enduml \ No newline at end of file diff --git a/docs/diagrams/sequence/SequenceDiagramGame.puml b/docs/diagrams/sequence/SequenceDiagramGame.puml new file mode 100644 index 00000000..5b5a2c0a --- /dev/null +++ b/docs/diagrams/sequence/SequenceDiagramGame.puml @@ -0,0 +1,46 @@ +@startuml Game's life cycle +title Game Sequence Diagram +actor Client #darksalmon +participant API #darksalmon +database DB #salmon + +skinparam Style strictuml +skinparam SequenceMessageAlignment center + +Client -> API : start game +activate API #darksalmon +API -> DB : store game info +activate DB #salmon +DB --> API : game info stored +deactivate DB +API --> Client : return game info +deactivate API +loop #PeachPuff Round + Client -> API : start round + activate API #darksalmon + API -> DB : ask for question + activate DB #salmon + DB --> API : return question + deactivate DB + API --> Client : return question with all answers + deactivate API + Client -> API : send chosen answer + activate API #darksalmon + API -> API : check answer is correct + API -> DB : update game info + activate DB #salmon + DB --> API : game info updated + deactivate DB + API --> Client : inform user if guessed right + + +end loop One question + +API -> DB : [if last round] update ranking +activate DB #salmon +DB --> API : ranking updated +deactivate DB +API --> Client : inform user if guessed right +deactivate API + +@enduml \ No newline at end of file diff --git a/docs/diagrams/sequence/SequenceDiagramLogIn.puml b/docs/diagrams/sequence/SequenceDiagramLogIn.puml new file mode 100644 index 00000000..a30ad948 --- /dev/null +++ b/docs/diagrams/sequence/SequenceDiagramLogIn.puml @@ -0,0 +1,33 @@ +@startuml login +title Login Sequence Diagram +actor Client #darksalmon +participant API #darksalmon +database DB #salmon +skinparam Style strictuml +skinparam SequenceMessageAlignment center + +Client -> API : enters credentials +activate API #darksalmon +API -> DB : check credentials correct +alt #PeachPuff credentials correct + activate DB #salmon + DB --> API : returns result + deactivate DB + API -> API : generate jwt and refresh tokens + API -> DB : save tokens + activate DB #salmon + DB --> API : saves tokens + deactivate DB + API --> Client : return jwt dto + deactivate API +else credentials incorrect + activate DB #salmon + DB --> API : returns error + deactivate DB + activate API #darksalmon + API --> Client : return 403 error + deactivate API +end + + +@enduml \ No newline at end of file diff --git a/docs/diagrams/sequence/SequenceDiagramQuestionGeneration.puml b/docs/diagrams/sequence/SequenceDiagramQuestionGeneration.puml new file mode 100644 index 00000000..b8b4a167 --- /dev/null +++ b/docs/diagrams/sequence/SequenceDiagramQuestionGeneration.puml @@ -0,0 +1,24 @@ +@startuml question generator +title Question Generator Sequence Diagram +participant QuestionGenerator #darksalmon +participant WikiDataQS #darksalmon +database DB #salmon +skinparam Style strictuml +skinparam SequenceMessageAlignment center + +loop #PeachPuff Generate question templates +activate QuestionGenerator #darksalmon +QuestionGenerator -> WikiDataQS : request query template +activate WikiDataQS #darksalmon +QuestionGenerator <-- WikiDataQS : returns query answer +deactivate WikiDataQS +QuestionGenerator -> QuestionGenerator : process query answer +QuestionGenerator -> DB : store answers +activate DB #salmon +QuestionGenerator -> DB : store questions +DB --> QuestionGenerator : info saved +deactivate DB +end loop + +deactivate QuestionGenerator +@enduml \ No newline at end of file diff --git a/docs/diagrams/sequence/SequenceDiagramSignUp.puml b/docs/diagrams/sequence/SequenceDiagramSignUp.puml new file mode 100644 index 00000000..8d9fe1e2 --- /dev/null +++ b/docs/diagrams/sequence/SequenceDiagramSignUp.puml @@ -0,0 +1,32 @@ +@startuml sign up +title Sign Up Sequence Diagram +actor Client #darksalmon +participant API #darksalmon +database DB #salmon +skinparam Style strictuml +skinparam SequenceMessageAlignment center + +Client -> API : account details +activate API #darksalmon +API -> API : validate details +API -> DB : check email is unused +alt #PeachPuff email is unused + activate DB #salmon + DB --> API : email is unused + deactivate DB + + API -> DB : register user + activate DB #salmon + DB --> API : user registered + deactivate DB + API --> Client : return confirmation + deactivate API +else email is used + activate DB #salmon + DB --> API : email is used + deactivate DB + activate API #darksalmon + API --> Client : 400 error + deactivate API +end +@enduml \ No newline at end of file diff --git a/docs/images/BusinessContext.png b/docs/images/BusinessContext.png deleted file mode 100644 index 8d332809..00000000 Binary files a/docs/images/BusinessContext.png and /dev/null differ diff --git a/docs/images/DeploymentDiagram.png b/docs/images/DeploymentDiagram.png deleted file mode 100644 index 7453c347..00000000 Binary files a/docs/images/DeploymentDiagram.png and /dev/null differ diff --git a/docs/images/icon-image.png b/docs/images/icon-image.png new file mode 100644 index 00000000..3189ca71 Binary files /dev/null and b/docs/images/icon-image.png differ diff --git a/docs/index.adoc b/docs/index.adoc index 185b5ddc..81414fc9 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -6,9 +6,9 @@ // configure EN settings for asciidoc include::src/config.adoc[] -= image:arc42-logo.png[arc42] WIQ_en2b += image:icon-image.png[arc42] WIQ_en2b :revnumber: 8.2 EN -:revdate: January 2023 +:revdate: March 2024 :revremark: (based upon AsciiDoc version) // toc-title definition MUST follow document title without blank line! :toc-title: Table of Contents @@ -29,22 +29,6 @@ ifdef::backend-html5[] endif::backend-html5[] -include::src/about-arc42.adoc[] - -// horizontal line -*** - -[role="arc42help"] -**** -[NOTE] -==== -This version of the template contains some help and explanations. -It is used for familiarization with arc42 and the understanding of the concepts. -For documentation of your own system you use better the _plain_ version. -==== -**** - - // numbering from here on :numbered: diff --git a/docs/src/03_system_scope_and_context.adoc b/docs/src/03_system_scope_and_context.adoc index 4144bdae..36fc857d 100644 --- a/docs/src/03_system_scope_and_context.adoc +++ b/docs/src/03_system_scope_and_context.adoc @@ -3,7 +3,10 @@ ifndef::imagesdir[:imagesdir: ../images] == System Scope and Context === Business Context -image::BusinessContext.png[align="center",title="Business Context",link="BusinessContext.png] +[plantuml,"Business context diagram",png, align="center", title="Overall view of the business context"] +---- +include::../diagrams/BusinessContextDiagram.puml[] +---- The WIQ application will communicate with the WikiData API through REST HTTP calls using SPARQL for the queries. It will ask the API for information that will later be used for generating the questions that will be shown to the player. This information will come in the form of text, images or audio. diff --git a/docs/src/05_building_block_view.adoc b/docs/src/05_building_block_view.adoc index b32a758d..cf27c401 100644 --- a/docs/src/05_building_block_view.adoc +++ b/docs/src/05_building_block_view.adoc @@ -8,14 +8,11 @@ ifndef::imagesdir[:imagesdir: ../images] === Whitebox Overall System -[role="arc42help"] -**** -This is the overall view of the application. The diagram is composed of 3 elements that will interact between each other. -**** - _**Overview Diagram**_ - -image::BusinessContext.png[align="center", title="Overall view of the business context"] +[plantuml,"Business context diagram",png, align="center", title="Overall view of the business context"] +---- +include::../diagrams/BusinessContextDiagram.puml[] +---- Motivation:: This will be the general sketch of the elements interacting inside the application, including the external elements that will include the application. @@ -35,20 +32,6 @@ This part will be more detailed later, since the structure of the different inte === Level 2 -[role="arc42help"] -**** -Here is an specification of the inner structure of the WIQ Application. -**** - -==== White Box _WIQ Application_ - -image::ContainerDiagram.png[align="center", title="Container for the WIQ System"] - -[role="arc42help"] -**** -This diagram describes the internal organization of the WIQ Application. -**** - Motivation:: An inner view on the WIQ Application and its components inside. How the WIQ application will be structured inside and its main components. diff --git a/docs/src/06_runtime_view.adoc b/docs/src/06_runtime_view.adoc index 44dc3513..871bd8fd 100644 --- a/docs/src/06_runtime_view.adoc +++ b/docs/src/06_runtime_view.adoc @@ -4,93 +4,29 @@ ifndef::imagesdir[:imagesdir: ../images] == Runtime View Here we can see what the main workflow of main parts of the project. -=== General Game's life cycle -* This diagram shows how should the application behave for an expected normal case scenario. - -[plantuml,"Game's life cycle diagram",png] ----- -@startuml Game's life cycle - -actor Client - -skinparam Style strictuml -skinparam SequenceMessageAlignment center - -Client --> API : start game -loop One question - API --> QuestionRetriever : ask for question - QuestionRetriever --> QuestionDB : querying question - - QuestionDB --> QuestionRetriever : return first question and all answers - QuestionRetriever --> API : return first question and all answers - - API --> Client : return first question and all answers - Client -> API : send chosen answer - API --> API : check answer is correct - API --> Client : inform user guessed right -end loop One question - - -API --> Client : inform user guessed right last question -API --> API : update ranking -API --> Client : show ranking - -@enduml ----- - === Sign Up [plantuml,"Sign up diagram",png] ---- -@startuml sign up - -actor Client - -skinparam Style strictuml -skinparam SequenceMessageAlignment center - -Client --> API : enters credentials -API --> BD : check credentials unused -API --> BD : register user -BD --> Client : return user token - -@enduml +include::../diagrams/sequence/SequenceDiagramSignUp.puml[] ---- === Login [plantuml,"Login diagram",png] ---- -@startuml login - -actor Client -skinparam Style strictuml -skinparam SequenceMessageAlignment center +include::../diagrams/sequence/SequenceDiagramLogIn.puml[] +---- -Client --> API : enters credentials -API --> BD : check credentials correct -BD --> API : return user token -API --> BD : save SWT -API --> Client : return user token +=== General Game's life cycle +* This diagram shows how should the application behave for an expected normal case scenario. -@enduml +[plantuml,"Game's sequence diagram",png] +---- +include::../diagrams/sequence/SequenceDiagramGame.puml[] ---- === Question Generation [plantuml,"Question generation diagram",png] ---- -@startuml question generator -skinparam Style strictuml -skinparam SequenceMessageAlignment center - -QuestionGenerator --> WikiDataQS : querying correct answer -QuestionGenerator <-- WikiDataQS : return correct answer - -QuestionGenerator --> WikiDataQS : querying wrong answers -QuestionGenerator <-- WikiDataQS : return wrong answers - -QuestionGenerator --> QuestionDB : store question -QuestionGenerator --> QuestionDB : store correct answer -QuestionGenerator --> QuestionDB : store wrong answers - -@enduml +include::../diagrams/sequence/SequenceDiagramQuestionGeneration.puml[] ---- diff --git a/docs/src/07_deployment_view.adoc b/docs/src/07_deployment_view.adoc index 1b555d1d..f9a446c1 100644 --- a/docs/src/07_deployment_view.adoc +++ b/docs/src/07_deployment_view.adoc @@ -3,8 +3,10 @@ ifndef::imagesdir[:imagesdir: ../images] [[section-deployment-view]] == Deployment View - -image::DeploymentDiagram.png[align="center",title="Deployment Diagram",link="DeploymentDiagram.png] +[plantuml,"Deployment diagram",png] +---- +include::../diagrams/deployment/DeploymentDiagram.puml[] +---- === Infrastructure Level 1 @@ -22,6 +24,7 @@ Mapping of Building Blocks to Infrastructure:: * The API/backend is contained within the `api` subfolder. ** The database will be generated on deployment. ** Wikidata is an external component, so although it is an important part of the deployment architecture, it is something we do not have access to changing. + * The question generator is a separate component that will be deployed on the server only at the beginning of the system. It is contained within the `questiongenerator` subfolder. === Infrastructure Level 2 @@ -38,3 +41,6 @@ Our main idea is that the server will be a self-contained .jar file with all the ===== Database The database will contain the data used by the system. Therefore, it will contain user data, as well as the data related to the questions and their answers. The databases to store the questions (and therefore the answers) and the user data might be different, though. + +===== Question Generator +The question generator will be run only at the beginning of the application. It will connect with Wikidata using SPARQL to generate questions and answers and store them in the database. This question generation will generate all the questions used by the application at once. diff --git a/questiongenerator/pom.xml b/questiongenerator/pom.xml index 8317bb33..7838c738 100644 --- a/questiongenerator/pom.xml +++ b/questiongenerator/pom.xml @@ -91,6 +91,14 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + 11 + + \ No newline at end of file diff --git a/questiongenerator/src/main/java/Main.java b/questiongenerator/src/main/java/Main.java index 0c29a503..a01ad310 100644 --- a/questiongenerator/src/main/java/Main.java +++ b/questiongenerator/src/main/java/Main.java @@ -1,8 +1,16 @@ import templates.CountryCapitalQuestion; +import templates.SongQuestion; +import templates.StadiumQuestion; public class Main { public static void main(String[] args) { - new CountryCapitalQuestion("es"); new CountryCapitalQuestion("en"); + new CountryCapitalQuestion("es"); + + //new SongQuestion("en"); + //new SongQuestion("es"); + + //new StadiumQuestion("en"); + //new StadiumQuestion("es"); } } \ No newline at end of file diff --git a/questiongenerator/src/main/java/model/Answer.java b/questiongenerator/src/main/java/model/Answer.java index b59aff14..4d7d4957 100644 --- a/questiongenerator/src/main/java/model/Answer.java +++ b/questiongenerator/src/main/java/model/Answer.java @@ -12,18 +12,25 @@ public class Answer implements Storable { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String text; + @Enumerated(EnumType.STRING) private AnswerCategory category; + private String language; @OneToMany(mappedBy = "correctAnswer", fetch = FetchType.EAGER) private List questions; @ManyToMany(mappedBy = "answers", fetch = FetchType.EAGER) private List questionsWithThisAnswer; - public Answer(String text, AnswerCategory category) { + public Answer(String text, AnswerCategory category, String language) { this.text = text; this.category = category; + this.language = language; } public Answer() { } + + public AnswerCategory getCategory() { + return category; + } } diff --git a/questiongenerator/src/main/java/model/AnswerCategory.java b/questiongenerator/src/main/java/model/AnswerCategory.java index 9296e711..f4f2a62f 100644 --- a/questiongenerator/src/main/java/model/AnswerCategory.java +++ b/questiongenerator/src/main/java/model/AnswerCategory.java @@ -1,5 +1,22 @@ package model; public enum AnswerCategory { - CITY, COUNTRY, PERSON, EVENT, DATE, OTHER + OTHER(1), + CAPITAL_CITY(2), + COUNTRY(3), + SONG(4), + STADIUM(5), + DATE(6), + PERSON(7), + EVENT(8); + + private final int value; + + AnswerCategory(int value) { + this.value = value; + } + + public int getValue() { + return value; + } } diff --git a/questiongenerator/src/main/java/model/Question.java b/questiongenerator/src/main/java/model/Question.java index a627b4f2..dc9c3ae8 100644 --- a/questiongenerator/src/main/java/model/Question.java +++ b/questiongenerator/src/main/java/model/Question.java @@ -12,7 +12,6 @@ public class Question implements Storable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private String content; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name="questions_answers", joinColumns= @@ -25,21 +24,19 @@ public class Question implements Storable { @JoinColumn(name = "correct_answer_id") private Answer correctAnswer; @Column(name = "question_category") + @Enumerated(EnumType.STRING) private QuestionCategory questionCategory; - @Column(name = "answer_category") - private AnswerCategory answerCategory; - private String language; + @Enumerated(EnumType.STRING) private QuestionType type; + private String content; public Question() { } - public Question(String content, Answer correctAnswer, QuestionCategory questionCategory, AnswerCategory answerCategory, String language, QuestionType type) { - this.content = content; + public Question(Answer correctAnswer, String content, QuestionCategory questionCategory, QuestionType type) { this.correctAnswer = correctAnswer; + this.content = content; this.questionCategory = questionCategory; - this.answerCategory = answerCategory; - this.language = language; this.type = type; this.answers = new ArrayList<>(); this.answers.add(correctAnswer); @@ -48,4 +45,8 @@ public Question(String content, Answer correctAnswer, QuestionCategory questionC public List getAnswers() { return answers; } + + public AnswerCategory getAnswerCategory() { + return correctAnswer.getCategory(); + } } diff --git a/questiongenerator/src/main/java/templates/CountryCapitalQuestion.java b/questiongenerator/src/main/java/templates/CountryCapitalQuestion.java index 0ea0b97b..6d965a1f 100644 --- a/questiongenerator/src/main/java/templates/CountryCapitalQuestion.java +++ b/questiongenerator/src/main/java/templates/CountryCapitalQuestion.java @@ -54,28 +54,20 @@ protected void processResults() { continue; //Saving the answer - Answer a = new Answer(capitalLabel, AnswerCategory.CITY); + Answer a = new Answer(capitalLabel, AnswerCategory.CAPITAL_CITY, langCode); answers.add(a); //Saving the question - String content; - if (langCode.equals("en")) - content = "What is the capital of " + countryLabel + "?"; + if (langCode.equals("es")) + questions.add(new Question(a, "ĀæCuĆ”l es la capital de " + countryLabel + "?", QuestionCategory.GEOGRAPHY, QuestionType.TEXT)); else - content = "ĀæCuĆ”l es la capital de " + countryLabel + "?"; - questions.add(new Question(content, a, QuestionCategory.GEOGRAPHY, AnswerCategory.CITY, langCode, QuestionType.TEXT)); + questions.add(new Question(a, "What is the capital of " + countryLabel + "?", QuestionCategory.GEOGRAPHY, QuestionType.TEXT)); } - addRandomAnswers(answers, questions); + repository.saveAll(new ArrayList<>(answers)); repository.saveAll(new ArrayList<>(questions)); } - private void addRandomAnswers(List answers, List questions) { - for(Question q : questions) { - q.getAnswers().add(answers.get((int) (Math.random() * (answers.size()-1)))); - } - } - /** * Auxiliar method for @processResults. It returns whether a country must be skipped as an entry in DB or not * diff --git a/questiongenerator/src/main/java/templates/QuestionTemplate.java b/questiongenerator/src/main/java/templates/QuestionTemplate.java index a0664c6e..85e9ebbc 100644 --- a/questiongenerator/src/main/java/templates/QuestionTemplate.java +++ b/questiongenerator/src/main/java/templates/QuestionTemplate.java @@ -29,10 +29,14 @@ public abstract class QuestionTemplate { * For reference in future implementations: look at CountryCapitalQuestion */ public QuestionTemplate(String langCode) { - this.langCode = langCode; - setQuery(); - call(); - processResults(); + try { + this.langCode = langCode; + setQuery(); + call(); + processResults(); + } catch (Exception e) { + System.err.println("Error while processing the question: " + e.getMessage()); + } } /** diff --git a/questiongenerator/src/main/java/templates/SongQuestion.java b/questiongenerator/src/main/java/templates/SongQuestion.java new file mode 100644 index 00000000..6be82263 --- /dev/null +++ b/questiongenerator/src/main/java/templates/SongQuestion.java @@ -0,0 +1,79 @@ +package templates; + +import model.*; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class SongQuestion extends QuestionTemplate { + + List songLabels; + + public SongQuestion(String langCode) { + super(langCode); + } + + @Override + public void setQuery() { + this.sparqlQuery = + "SELECT DISTINCT ?songLabel ?link " + + "WHERE {" + + " {" + + " SELECT DISTINCT ?songLabel (SAMPLE(?link) AS ?link)" + + " WHERE {" + + " ?song wdt:P31 wd:Q105543609; " + + " rdfs:label ?songLabel; " + + " wdt:P1651 ?link. " + + " FILTER(LANG(?songLabel) = \"es\" || LANG(?songLabel) = \"en\") " + + " }" + + " GROUP BY ?songLabel" + + " }" + + "}" + + "LIMIT 100"; + } + + @Override + public void processResults() { + songLabels=new ArrayList<>(); + List questions = new ArrayList<>(); + List answers = new ArrayList<>(); + + for (int i = 0; i < results.length(); i++) { + + JSONObject result = results.getJSONObject(i); + + + JSONObject songLabelObject = result.getJSONObject("songLabel"); + String songLabel = songLabelObject.getString("value"); + + JSONObject linkObject = result.getJSONObject("link"); + String link = linkObject.getString("value"); + + if (needToSkip(songLabel)) + continue; + + String musicVideoLink = "https://www.youtube.com/watch?v=" + link; + + Answer a = new Answer(songLabel, AnswerCategory.SONG, langCode); + answers.add(a); + + if (langCode.equals("es")) + questions.add(new Question(a, "ĀæCuĆ”l es esta canciĆ³n? " + musicVideoLink, QuestionCategory.MUSIC, QuestionType.AUDIO)); + else + questions.add(new Question(a, "Which song is this? " + musicVideoLink, QuestionCategory.MUSIC, QuestionType.AUDIO)); + } + + repository.saveAll(new ArrayList<>(answers)); + repository.saveAll(new ArrayList<>(questions)); + } + + private boolean needToSkip(String songLabel) { + if (songLabels.contains(songLabel)) { + return true; + } + songLabels.add(songLabel); + + return false; + } +} diff --git a/questiongenerator/src/main/java/templates/StadiumQuestion.java b/questiongenerator/src/main/java/templates/StadiumQuestion.java new file mode 100644 index 00000000..9b7b83dd --- /dev/null +++ b/questiongenerator/src/main/java/templates/StadiumQuestion.java @@ -0,0 +1,87 @@ +package templates; + +import model.*; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +public class StadiumQuestion extends QuestionTemplate { + + List stadiumLabels; + + public StadiumQuestion(String langCode) { + super(langCode); + } + + @Override + protected void setQuery() { + this.sparqlQuery = + "SELECT ?stadiumLabel ?image " + + "WHERE { " + + " ?stadium wdt:P31/wdt:P279* wd:Q483110; " + + " wdt:P18 ?image; " + + " wdt:P1083 ?capacity; " + + " wdt:P17 ?country. " + + " ?country wdt:P361 wd:Q46. " + + " SERVICE wikibase:label { bd:serviceParam wikibase:language \"" + langCode + "\". } } " + + "GROUP BY ?stadium ?stadiumLabel ?image ?capacity ?countryLabel " + + "HAVING (?capacity > 25000) " + + "LIMIT 100"; + } + + @Override + public void processResults() { + stadiumLabels = new ArrayList<>(); + List questions = new ArrayList<>(); + List answers = new ArrayList<>(); + + for (int i = 0; i < results.length(); i++) { + + JSONObject result = results.getJSONObject(i); + + + JSONObject stadiumLabelObject = result.getJSONObject("stadiumLabel"); + String stadiumLabel = stadiumLabelObject.getString("value"); + + JSONObject imageObject = result.getJSONObject("image"); + String imageLink = imageObject.getString("value"); + + if (needToSkip(stadiumLabel)) + continue; + + + Answer a = new Answer(stadiumLabel, AnswerCategory.STADIUM, langCode); + answers.add(a); + + if (langCode.equals("es")) + questions.add(new Question(a, "ĀæCuĆ”l es este estadio? " + imageLink, QuestionCategory.SPORTS, QuestionType.IMAGE)); + else + questions.add(new Question(a, "Which stadium is this? " + imageLink, QuestionCategory.SPORTS, QuestionType.IMAGE)); + } + + repository.saveAll(new ArrayList<>(answers)); + repository.saveAll(new ArrayList<>(questions)); + } + + private boolean needToSkip(String stadiumLabel) { + if (stadiumLabels.contains(stadiumLabel)) { + return true; + } + stadiumLabels.add(stadiumLabel); + + boolean isEntityName = true; // Check if it is like Q232334 + if (stadiumLabel.startsWith("Q") ){ + for (int i=1; i=16.0.0" } }, + "node_modules/@fontsource-variable/outfit": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.0.12.tgz", + "integrity": "sha512-f14wHgrD8GZnSbF7pK0F+Al3NYSz6oGaFbewvA4lVJRZcoQ6MQFu6BG6PY/elMrnx8WuklL8AncIVqPsrRIf0A==" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -5678,6 +5687,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -6742,6 +6756,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/@types/js-cookie": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", + "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7335,6 +7354,11 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -8945,6 +8969,17 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.2.tgz", + "integrity": "sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -9687,6 +9722,14 @@ "postcss": "^8.4" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/css-loader": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", @@ -11961,6 +12004,16 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-loops": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.3.tgz", + "integrity": "sha512-8EZzEP0eKkEEVX+drtd9mtuQ+/QrlfW/5MlwcwK5Nds6EkZ/tRzEexkzUY2mIssnAyVLT+TKHuRXmFNNXYUd6g==" + }, + "node_modules/fast-shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", + "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==" + }, "node_modules/fast-url-parser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", @@ -11976,6 +12029,11 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, + "node_modules/fastest-stable-stringify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==" + }, "node_modules/fastq": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", @@ -12184,9 +12242,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -13322,6 +13380,11 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + }, "node_modules/i18next": { "version": "23.8.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.8.2.tgz", @@ -13511,6 +13574,15 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.0.tgz", + "integrity": "sha512-I7GEdScunP1dQ6IM2mQWh6v0mOYdYmH3Bp31UecKdrcUgcURTcctSe1IECdUznSHKSmsHtjrT3CwCPI1pyxfUQ==", + "dependencies": { + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" + } + }, "node_modules/internal-slot": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", @@ -19369,6 +19441,11 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -20271,6 +20348,55 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-css": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.1.tgz", + "integrity": "sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "css-tree": "^1.1.2", + "csstype": "^3.1.2", + "fastest-stable-stringify": "^2.0.2", + "inline-style-prefixer": "^7.0.0", + "rtl-css-js": "^1.16.1", + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/nano-css/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nano-css/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/nano-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nano-css/node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -22998,6 +23124,15 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-clientside-effect": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", @@ -24440,6 +24575,40 @@ } } }, + "node_modules/react-universal-interface": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", + "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==", + "peerDependencies": { + "react": "*", + "tslib": "*" + } + }, + "node_modules/react-use": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.5.0.tgz", + "integrity": "sha512-PbfwSPMwp/hoL847rLnm/qkjg3sTRCvn6YhUZiHaUa3FA6/aNoFX79ul5Xt70O1rK+9GxSVqkY0eTwMdsR/bWg==", + "dependencies": { + "@types/js-cookie": "^2.2.6", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", + "fast-deep-equal": "^3.1.3", + "fast-shallow-equal": "^1.0.0", + "js-cookie": "^2.2.1", + "nano-css": "^5.6.1", + "react-universal-interface": "^0.6.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.1.0", + "set-harmonic-interval": "^1.0.1", + "throttle-debounce": "^3.0.1", + "ts-easing": "^0.2.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -24807,6 +24976,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -25036,6 +25210,14 @@ "node": "6.* || >= 7.*" } }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -25513,6 +25695,17 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -25846,6 +26039,14 @@ "node": ">= 0.4" } }, + "node_modules/set-harmonic-interval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", + "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==", + "engines": { + "node": ">=6.9" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -26346,6 +26547,14 @@ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -26370,6 +26579,33 @@ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, "node_modules/start-server-and-test": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.3.tgz", @@ -27241,6 +27477,14 @@ "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" }, + "node_modules/throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==", + "engines": { + "node": ">=10" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -27377,6 +27621,11 @@ "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" }, + "node_modules/ts-easing": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", + "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -28080,9 +28329,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", diff --git a/webapp/package.json b/webapp/package.json index 1241cb1a..94d37338 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -7,10 +7,12 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", + "@fontsource-variable/outfit": "^5.0.12", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.2", "axios": "^1.6.5", + "chart.js": "^4.4.2", "dotenv": "^16.4.1", "framer-motion": "^11.0.6", "i18next": "^23.8.2", @@ -18,6 +20,7 @@ "i18next-http-backend": "^2.4.3", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-confetti": "^6.1.0", "react-dom": "^18.2.0", "react-i18next": "^14.0.5", @@ -25,6 +28,7 @@ "react-router": "^6.21.3", "react-router-dom": "^6.21.3", "react-scripts": "5.0.1", + "react-use": "^17.5.0", "web-vitals": "^3.5.1" }, "scripts": { diff --git a/webapp/public/background.svg b/webapp/public/background.svg new file mode 100644 index 00000000..e5dbb2c7 --- /dev/null +++ b/webapp/public/background.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/webapp/public/icon-image.png b/webapp/public/icon-image.png new file mode 100644 index 00000000..3189ca71 Binary files /dev/null and b/webapp/public/icon-image.png differ diff --git a/webapp/public/index.html b/webapp/public/index.html index 73bdf8b9..1cbb265b 100644 --- a/webapp/public/index.html +++ b/webapp/public/index.html @@ -2,16 +2,16 @@ - + - + - WIQ_EN2B + KIWIQ diff --git a/webapp/public/kiwiq-icon.ico b/webapp/public/kiwiq-icon.ico new file mode 100644 index 00000000..35bc3b73 Binary files /dev/null and b/webapp/public/kiwiq-icon.ico differ diff --git a/webapp/public/locales/en/translation.json b/webapp/public/locales/en/translation.json index 52cc1edc..88a9a527 100644 --- a/webapp/public/locales/en/translation.json +++ b/webapp/public/locales/en/translation.json @@ -5,7 +5,7 @@ "statistics": { "title": "Statistics", "personal": "My statistics", - "general": "General statistics" + "general": "Leaderboard" }, "play": "Play", "login": "Log in", @@ -22,15 +22,51 @@ "password": "Password", "email": "Email", "confirm_password": "Confirm password", - "welcome": "Welcome to the WIQ-EN2B page", + "welcome": "Are you ready to test your ingenuity?", "account": "Don't have an account?", "clickHere": "Click here" }, "error": { - "login": "You should enter a valid username and password", - "register": "You should enter a valid username, email and password", + "login": "Login error: ", + "register": "Registry error: ", "login-desc": "Please, enter you username and password to log in", "register-desc": "Please, enter you username and password to register", - "login-send": "Your email or password are not found in our database" + "login-send": "Your email or password are not found in our database", + "validation": { + "type": "Validation Error: ", + "message": "Incorrect data" + }, + "conflict": { + "type": "Conflict Error: ", + "message": "User already exists" + }, + "unknown": { + "type": "Unknown Error", + "message": "" + }, + "authorized": { + "type": "Authorization Error: ", + "message": "Incorrect password" + } + }, + "rules": { + "description1": "The WIQ game consists of quick games of 9 rounds. In each round there is one question and two possible answers. The key to earning points lies in choosing the correct answer.", + "description2": "There is only one correct answer.", + "description3": "You have to select a question before time runs out.", + "description4": "To start playing you have to click on the Play button." + + }, + "statistics": { + "position": "Position", + "username": "Username", + "rightAnswers": "Correct answers", + "wrongAnswers": "Wrong answers", + "totalAnswers": "Total answers", + "percentage": "Correct answer rate", + "texts": { + "personalRight": "{{right, number}} correct answers", + "personalWrong": "{{wrong, number}} wrong answers", + "personalRate": "{{rate, number}} %" + } } } \ No newline at end of file diff --git a/webapp/public/locales/es/translation.json b/webapp/public/locales/es/translation.json index ab65fbb1..42b37ac6 100644 --- a/webapp/public/locales/es/translation.json +++ b/webapp/public/locales/es/translation.json @@ -1,15 +1,15 @@ { - "common":{ + "common": { "home": "Inicio", "api_docs": "DocumentaciĆ³n de la API", "statistics": { "title": "EstadĆ­sticas", "personal": "Mis estadĆ­sticas", - "general": "EstadĆ­sticas generales" + "general": "ClasificaciĆ³n" }, "play": "Jugar", "login": "Iniciar sesiĆ³n", - "logout": "Salir", + "logout": "Cerrar sesiĆ³n", "register": "Registrarse", "submit": "Enviar", "rules": "Reglas", @@ -22,15 +22,50 @@ "password": "ContraseƱa", "email": "Correo electrĆ³nico", "confirm_password": "Confirmar contraseƱa", - "welcome": "Bienvenido a la pĆ”gina principal de WIQ-EN2B", + "welcome": "ĀæEstĆ”s listo para poner a prueba tu ingenio?", "account": "ĀæNo tienes una cuenta?", - "clickHere": "Pulsa aquĆ­" + "clickHere": "Haz clic aquĆ­" }, "error": { - "login": "Nombre de usuario o contraseƱa incorrectos", - "register": "Debes introducir datos vĆ”lidos para registrarte", + "login": "Error en el inicio de sesiĆ³n: ", + "register": "Error en el registro: ", "login-desc": "Por favor, introduce tus datos para iniciar sesiĆ³n", "register-desc": "Por favor, introduce tus datos para registrarte", - "login-send": "Tu email o contraseƱa no se encuentran en nuestra base de datos" + "login-send": "Tu email o contraseƱa no se encuentran en nuestra base de datos", + "validation": { + "type": "Error de ValidaciĆ³n: ", + "message": "Datos incorrectos" + }, + "conflict": { + "type": "Error de Conflicto: ", + "message": "El usuario ya existe" + }, + "unknown": { + "type": "Error Desconocido", + "message": "" + }, + "authorized": { + "type": "Error de AutorizaciĆ³n: ", + "message": "ContraseƱa incorrecta" + } + }, + "rules": { + "description1": "El juego de WIQ consiste en juegos rĆ”pidos de 9 rondas. En cada ronda hay una pregunta y dos posibles respuestas. La clave para ganar puntos estĆ” en elegir la respuesta correcta.", + "description2": "Solo hay una respuesta correcta.", + "description3": "Debes seleccionar una pregunta antes de que se acabe el tiempo.", + "description4": "Para comenzar a jugar, debes hacer clic en el botĆ³n Jugar." + }, + "statistics": { + "position": "PosiciĆ³n", + "username": "Nombre de usuario", + "rightAnswers": "Respuestas correctas", + "wrongAnswers": "Respuestas errĆ³neas", + "totalAnswers": "Respuestas totales", + "percentage": "Porcentaje de acierto", + "texts": { + "personalRight": "{{right, number}} respuestas correctas", + "personalWrong": "{{wrong, number}} respuestas incorrectas", + "personalRate": "{{rate, number}} %" + } } -} +} \ No newline at end of file diff --git a/webapp/src/components/ButtonEf.jsx b/webapp/src/components/ButtonEf.jsx index 1dd392d7..8a531937 100644 --- a/webapp/src/components/ButtonEf.jsx +++ b/webapp/src/components/ButtonEf.jsx @@ -1,22 +1,28 @@ import React from 'react'; -import { Button } from "@chakra-ui/react"; +import { Button, useTheme } from "@chakra-ui/react"; import PropTypes from 'prop-types'; -import '../styles/AppView.css'; const ButtonEf = ({ dataTestId, variant, colorScheme, text, onClick }) => { - return ( - - ); + const theme = useTheme(); + + const buttonStyle = { + fontFamily: theme.fonts.heading, + margin: "10px", + }; + return ( + + ); }; ButtonEf.propTypes = { @@ -24,7 +30,7 @@ ButtonEf.propTypes = { variant: PropTypes.string.isRequired, colorScheme: PropTypes.string.isRequired, text: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired }; export default ButtonEf; \ No newline at end of file diff --git a/webapp/src/components/ErrorMessageAlert.jsx b/webapp/src/components/ErrorMessageAlert.jsx new file mode 100644 index 00000000..a97fd812 --- /dev/null +++ b/webapp/src/components/ErrorMessageAlert.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react"; + +const ErrorMessageAlert = ({ errorMessage, t, errorWhere }) => { + return ( + errorMessage && ( + + + + {errorMessage && errorMessage.type === "unknown" + ? t(errorWhere) + : errorMessage.type} + + {errorMessage.message} + + ) + ); +}; + +export default ErrorMessageAlert; diff --git a/webapp/src/components/GoBack.jsx b/webapp/src/components/GoBack.jsx new file mode 100644 index 00000000..bd90ef0f --- /dev/null +++ b/webapp/src/components/GoBack.jsx @@ -0,0 +1,14 @@ +import { Button, Flex } from "@chakra-ui/react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; + +export default function GoBack() { + const {t} = useTranslation(); + const navigate = useNavigate(); + return + + +} \ No newline at end of file diff --git a/webapp/src/components/Router.jsx b/webapp/src/components/Router.jsx index 3b54a9c1..8769eb25 100644 --- a/webapp/src/components/Router.jsx +++ b/webapp/src/components/Router.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Route,createRoutesFromElements } from "react-router-dom"; +import { Route, createRoutesFromElements } from "react-router-dom"; import Root from "../pages/Root"; import Login from "../pages/Login"; @@ -8,15 +8,23 @@ import Rules from "../pages/Rules"; import Signup from "../pages/Signup"; import Game from "../pages/Game"; import Results from "../pages/Results"; +import Statistics from "pages/Statistics"; +import ProtectedRoute from "./utils/ProtectedRoute"; +import Logout from "pages/Logout"; + export default createRoutesFromElements( } /> } /> }/> - }/> - }/> - }/> - }/> + }> + }/> + }/> + }/> + }/> + } /> + } /> + ) diff --git a/webapp/src/components/auth/AuthManager.js b/webapp/src/components/auth/AuthManager.js new file mode 100644 index 00000000..11bf90a4 --- /dev/null +++ b/webapp/src/components/auth/AuthManager.js @@ -0,0 +1,112 @@ +import axios, { HttpStatusCode } from "axios"; + +export default class AuthManager { + + static #instance = null; + #isLoggedIn = false; + #axiosInstance = null; + + constructor() { + if (!AuthManager.#instance) { + AuthManager.#instance = this; + AuthManager.#instance.#axiosInstance = axios.create(); + } + } + + getAxiosInstance() { + return AuthManager.#instance.#axiosInstance; + } + + setLoggedIn(value) { + AuthManager.#instance.#isLoggedIn = value; + } + + async isLoggedIn() { + + if (!AuthManager.#instance.#isLoggedIn) { + if (localStorage.getItem("jwtRefreshToken")) { + await this.#refresh(); + } + } + return AuthManager.#instance.#isLoggedIn; + } + + static getInstance() { + return AuthManager.#instance; + } + + async login(loginData, onSuccess, onError) { + try { + let requestAnswer = await this.getAxiosInstance().post(process.env.REACT_APP_API_ENDPOINT + "/auth/login", loginData); + if (HttpStatusCode.Ok === requestAnswer.status) { + this.#saveToken(requestAnswer); + AuthManager.#instance.setLoggedIn(true); + onSuccess(); + } else { + throw requestAnswer; + } + } catch (error) { + onError(error); + } + } + + reset() { + AuthManager.#instance = null; + AuthManager.#instance = new AuthManager(); + } + + async logout() { + try { + await this.getAxiosInstance().get(process.env.REACT_APP_API_ENDPOINT + "/auth/logout"); + AuthManager.#instance.setLoggedIn(false); + this.getAxiosInstance().defaults.headers.common["authorization"] = undefined; + localStorage.removeItem("jwtRefreshToken"); + } catch (error) { + console.error("Error logging out user: ", error); + } + } + + #saveToken(requestAnswer) { + this.getAxiosInstance().defaults.headers.common["authorization"] = "Bearer " + requestAnswer.data.token;; + localStorage.setItem("jwtRefreshToken", requestAnswer.data.refresh_token); + } + + async #refresh() { + try { + let response = await this.getAxiosInstance().post(process.env.REACT_APP_API_ENDPOINT + "/auth/refresh-token", { + "refresh_token": localStorage.getItem("jwtRefreshToken") + }); + this.#saveToken(response); + AuthManager.#instance.setLoggedIn(true); + } catch (error) { + console.error("Error refreshing token: ", error); + } + } + + async register(registerData, onSuccess, onError) { + try { + let requestAnswer = await this.getAxiosInstance().post(process.env.REACT_APP_API_ENDPOINT + "/auth/register", registerData); + if (HttpStatusCode.Ok === requestAnswer.status) { + this.#saveToken(requestAnswer); + AuthManager.#instance.setLoggedIn(true); + onSuccess(); + } else { + throw requestAnswer; + } + } catch (error) { + let errorType; + switch (error.response ? error.response.status : null) { + case 400: + errorType = { type: "error.validation.type", message: "error.validation.message"}; + break; + case 409: + errorType = { type: "error.conflict.type", message: "error.conflict.message"}; + break; + default: + errorType = { type: "error.unknown.type", message: "error.unknown.message"}; + break; + } + onError(errorType); + } +} +} diff --git a/webapp/src/components/auth/AuthUtils.js b/webapp/src/components/auth/AuthUtils.js deleted file mode 100644 index 9da36bef..00000000 --- a/webapp/src/components/auth/AuthUtils.js +++ /dev/null @@ -1,48 +0,0 @@ -import axios, { HttpStatusCode } from "axios"; - -export function isUserLogged() { - return getLoginData().jwtToken !== null; -} - -export function saveToken(requestAnswer) { - axios.defaults.headers.common["Authorization"] = "Bearer " + requestAnswer.data.token; - sessionStorage.setItem("jwtToken", requestAnswer.data.token); - sessionStorage.setItem("jwtRefreshToken", requestAnswer.data.refresh_token); - sessionStorage.setItem("jwtReceptionMillis", Date.now().toString()); -} - -export function getLoginData() { - return { - "jwtToken": sessionStorage.getItem("jwtToken"), - "jwtRefreshToken": sessionStorage.getItem("jwtRefreshToken"), - "jwtReceptionDate": new Date(sessionStorage.getItem("jwtReceptionMillis")) - }; -} - -export async function login(loginData, onSuccess, onError) { - try { - let requestAnswer = await axios.post(process.env.REACT_APP_API_ENDPOINT + "/auth/login", loginData); - if (HttpStatusCode.Ok === requestAnswer.status) { - saveToken(requestAnswer); - onSuccess(); - } else { - onError(); - } - } catch { - onError(); - } -} - -export async function register(registerData, onSuccess, onError) { - try { - let requestAnswer = await axios.post(process.env.REACT_APP_API_ENDPOINT + "/auth/register", registerData); - if (HttpStatusCode.Ok === requestAnswer.status) { - saveToken(requestAnswer); - onSuccess(); - } else { - onError(); - } - } catch { - onError(); - } -} diff --git a/webapp/src/components/game/Logout.js b/webapp/src/components/game/Logout.js deleted file mode 100644 index e1044e57..00000000 --- a/webapp/src/components/game/Logout.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from "axios"; - -export async function logoutUser() { - try { - await axios.get(process.env.REACT_APP_API_ENDPOINT + "/auth/logout"); - sessionStorage.removeItem("jwtToken"); - sessionStorage.removeItem("jwtRefreshToken"); - } catch (error) { - console.error("Error logging out user: ", error); - } -} diff --git a/webapp/src/components/game/Questions.js b/webapp/src/components/game/Questions.js index 32b6c239..189699f5 100644 --- a/webapp/src/components/game/Questions.js +++ b/webapp/src/components/game/Questions.js @@ -1,8 +1,10 @@ -import axios, { HttpStatusCode } from "axios"; +import { HttpStatusCode } from "axios"; +import AuthManager from "components/auth/AuthManager"; +const authManager = new AuthManager(); export async function getQuestion() { try { - let requestAnswer = await axios.get(process.env.REACT_APP_API_ENDPOINT + "/questions/new"); + let requestAnswer = await authManager.getAxiosInstance().get(process.env.REACT_APP_API_ENDPOINT + "/questions/new"); if (HttpStatusCode.Ok === requestAnswer.status) { return requestAnswer.data; } @@ -13,7 +15,7 @@ export async function getQuestion() { export async function answerQuestion(questionId, aId) { try { - let requestAnswer = await axios.post(process.env.REACT_APP_API_ENDPOINT + "/questions/" + questionId + "/answer", {answer_id:aId}); + let requestAnswer = await authManager.getAxiosInstance().post(process.env.REACT_APP_API_ENDPOINT + "/questions/" + questionId + "/answer", {answer_id:aId}); if (HttpStatusCode.Ok === requestAnswer.status) { return requestAnswer.data; } diff --git a/webapp/src/components/utils/ProtectedRoute.jsx b/webapp/src/components/utils/ProtectedRoute.jsx new file mode 100644 index 00000000..6e96f1e9 --- /dev/null +++ b/webapp/src/components/utils/ProtectedRoute.jsx @@ -0,0 +1,25 @@ +import React, { useEffect, useState } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import AuthManager from "../auth/AuthManager"; +import { CircularProgress } from "@chakra-ui/react"; + +const ProtectedRoutes = () => { + + const navigate = useNavigate(); + const [hasLoaded, setHasLoaded] = useState(false); + + useEffect(() => { + async function protectRoute() { + let isLoggedIn = await AuthManager.getInstance().isLoggedIn(); + setHasLoaded(true); + if (!(isLoggedIn)) { + navigate("/login"); + } + } + + protectRoute(); + }, [navigate]) + return <>{hasLoaded ? : } +} + +export default ProtectedRoutes; diff --git a/webapp/src/index.js b/webapp/src/index.js index fd2d1dc6..ab1335c3 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -6,13 +6,13 @@ import {createBrowserRouter, RouterProvider} from 'react-router-dom'; import router from 'components/Router'; import { ChakraProvider } from '@chakra-ui/react'; import "./i18n"; -import axios from "axios"; +import theme from "./styles/theme"; const root = ReactDOM.createRoot(document.querySelector("body")); const browserRouter = createBrowserRouter(router); -axios.defaults.headers.post["Content-Type"] = "application/json"; + root.render( - + diff --git a/webapp/src/pages/Dashboard.jsx b/webapp/src/pages/Dashboard.jsx index 600f23d7..8e346ffe 100644 --- a/webapp/src/pages/Dashboard.jsx +++ b/webapp/src/pages/Dashboard.jsx @@ -3,8 +3,8 @@ import { Grid, Flex, Heading, Button, Box } from "@chakra-ui/react"; import { Center } from "@chakra-ui/layout"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { logoutUser } from "../components/game/Logout"; // Importa la funciĆ³n logoutUser import ButtonEf from '../components/ButtonEf'; +import AuthManager from "components/auth/AuthManager"; export default function Dashboard() { const navigate = useNavigate(); @@ -12,7 +12,7 @@ export default function Dashboard() { const handleLogout = async () => { try { - await logoutUser(); + await new AuthManager().logout(); navigate("/"); } catch (error) { console.error("Error al cerrar sesiĆ³n:", error); @@ -20,16 +20,14 @@ export default function Dashboard() { }; return ( -
- {t("common.dashboard")} +
+ {t("common.dashboard")} - navigate("/dashboard/rules")}/> - navigate("/dashboard/game")}/> - + navigate("/dashboard/rules")}/> + navigate("/dashboard/game")}/> + navigate("/dashboard/statistics")}/> diff --git a/webapp/src/pages/Game.jsx b/webapp/src/pages/Game.jsx index d3f1ccfd..957c0e55 100644 --- a/webapp/src/pages/Game.jsx +++ b/webapp/src/pages/Game.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react"; -import { Grid, Flex, Heading, Button, Box } from "@chakra-ui/react"; +import React, { useState, useEffect, useCallback } from "react"; +import { Grid, Flex, Heading, Button, Box, Text, Spinner } from "@chakra-ui/react"; import { Center } from "@chakra-ui/layout"; import { useNavigate } from "react-router-dom"; import Confetti from "react-confetti"; @@ -10,19 +10,27 @@ import axios from "axios"; export default function Game() { const navigate = useNavigate(); - const [question, setQuestion] = useState({ id:1, content: "default question", answers: [{id:1, text:"answer1", category:"category1" }, {id:2, text:"answer2", category:"category2" }], questionCategory: "", answerCategory: "", language: "en", type: ""}); + const [question, setQuestion] = useState(null); + const [loading, setLoading] = useState(true); + + const generateQuestion = useCallback(async () => { + const result = await getQuestion(); + if (result !== undefined) { + setQuestion(result); + } else { + navigate("/dashboard"); + } + }, [navigate]); + useEffect(() => { axios.defaults.headers.common["Authorization"] = "Bearer " + sessionStorage.getItem("jwtToken"); const fetchQuestion = async () => { - await generateQuestion(); + setLoading(true); + await generateQuestion(); + setLoading(false); }; fetchQuestion(); - }, []); - - const generateQuestion = async () => { - const result = await getQuestion(); - setQuestion(result); - }; + }, [generateQuestion, navigate]); const [answer, setAnswer] = useState({id:1, text:"answer1", category:"category1" }); const [selectedOption, setSelectedOption] = useState(null); @@ -48,7 +56,7 @@ export default function Game() { setSelectedOption(null); - const nextRoundNumber = roundNumber + 1; // Round N + const nextRoundNumber = roundNumber + 1; if (nextRoundNumber > 9) navigate("/dashboard/game/results", { state: { correctAnswers: correctAnswers + (isCorrect ? 1 : 0) } }); else { @@ -66,23 +74,31 @@ export default function Game() { }, [showConfetti]); return ( -
- {`Round ${roundNumber}`} - - {`Correct answers: ${correctAnswers}`} - - - - {question.content} - +
+ {`Round ${roundNumber}`} + + {`Correct answers: ${correctAnswers}`} + + + {loading ? ( + + ) : ( + <> + {question.content} - answerButtonClick(1)} /> - answerButtonClick(2)} /> + answerButtonClick(1)} /> + answerButtonClick(2)} /> - @@ -90,6 +106,8 @@ export default function Game() { {showConfetti && ( )} + + )}
); diff --git a/webapp/src/pages/Login.jsx b/webapp/src/pages/Login.jsx index 6514a663..44ec51fb 100644 --- a/webapp/src/pages/Login.jsx +++ b/webapp/src/pages/Login.jsx @@ -1,39 +1,24 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { FaLock, FaAddressCard } from "react-icons/fa"; import { Center } from "@chakra-ui/layout"; -import { Heading, Input, InputGroup, Stack, InputLeftElement, chakra, Box, Avatar, FormControl, InputRightElement, IconButton, Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react"; +import { Heading, Input, InputGroup, Stack, InputLeftElement, chakra, Box, Avatar, FormControl, InputRightElement, IconButton} from "@chakra-ui/react"; import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'; import ButtonEf from '../components/ButtonEf'; -import '../styles/AppView.css'; -import { isUserLogged, login } from "../components/auth/AuthUtils"; -import { logoutUser } from "../components/game/Logout"; // Importa la funciĆ³n logoutUser +import ErrorMessageAlert from "../components/ErrorMessageAlert"; +import AuthManager from "components/auth/AuthManager"; export default function Login() { const navigate = useNavigate(); - const navigateToDashboard = () => { - if (isUserLogged()) { + const navigateToDashboard = async () => { + if (await AuthManager.getInstance().isLoggedIn()) { navigate("/dashboard"); } } - useEffect(() => { - const checkUserLoggedIn = async () => { - if (isUserLogged()) { - try { - await logoutUser(); // Cierra sesiĆ³n antes de redirigir al inicio de sesiĆ³n - } catch (error) { - console.error("Error al cerrar sesiĆ³n:", error); - } - } - }; - - checkUserLoggedIn(); - }, []); // Solo se ejecuta al montar el componente - - const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const { t } = useTranslation(); const [showPassword, setShowPassword] = useState(false); @@ -42,33 +27,46 @@ export default function Login() { const ChakraFaCardAlt = chakra(FaAddressCard); const ChakraFaLock = chakra(FaLock); - const sendLogin = async (errorMessage) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const handleEmailChange = (e) => { + setEmail(e.target.value); + setErrorMessage(false); + } + + const handlePasswordChange = (e) => { + setPassword(e.target.value); + setErrorMessage(false); + } + + const sendLogin = async () => { const loginData = { - "email": document.getElementById("user").value, - "password": document.getElementById("password").value + "email": email, + "password": password }; - await login(loginData, navigateToDashboard, () => setHasError(true)); - if (errorMessage) { - setErrorMessage(errorMessage); + try { + await AuthManager.getInstance().login(loginData, navigateToDashboard, setErrorMessage); + } catch { + setErrorMessage("Error desconocido"); + } + } + + const loginOnEnter = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + sendLogin(); } } - const [errorMessage, setErrorMessage] = useState(""); + navigateToDashboard(); return ( -
+
- - {t("common.login")} - { - hasError && - - - {t("error.login")} - {errorMessage ? errorMessage : t("error.login-desc")} - - } + + {t("common.login")} + @@ -76,7 +74,13 @@ export default function Login() { - + @@ -84,13 +88,19 @@ export default function Login() { - + : } data-testid="togglePasswordButton" /> - sendLogin(t("error.login-send"))} /> + diff --git a/webapp/src/pages/Logout.jsx b/webapp/src/pages/Logout.jsx new file mode 100644 index 00000000..3a6a1927 --- /dev/null +++ b/webapp/src/pages/Logout.jsx @@ -0,0 +1,15 @@ +import AuthManager from "components/auth/AuthManager"; +import React from "react"; +import { useNavigate } from "react-router"; + +export default function Logout() { + const navigate = useNavigate(); + const navigateToLogin = () => { + navigate("/login"); + } + + AuthManager.getInstance().logout().then(() => { + navigateToLogin(); + }); + return <> +} \ No newline at end of file diff --git a/webapp/src/pages/Results.jsx b/webapp/src/pages/Results.jsx index fba95864..3abbd0cb 100644 --- a/webapp/src/pages/Results.jsx +++ b/webapp/src/pages/Results.jsx @@ -10,12 +10,12 @@ export default function Results() { const correctAnswers = location.state?.correctAnswers || 0; return ( -
- Results +
+ Results {`Correct answers: ${correctAnswers}`} - diff --git a/webapp/src/pages/Root.jsx b/webapp/src/pages/Root.jsx index 614d6764..5ac0103e 100644 --- a/webapp/src/pages/Root.jsx +++ b/webapp/src/pages/Root.jsx @@ -2,25 +2,35 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { Center } from "@chakra-ui/layout"; -import { Text, Heading, Stack, Link } from "@chakra-ui/react"; +import { Text, Heading, Stack, Link, Image, Box } from "@chakra-ui/react"; import ButtonEf from '../components/ButtonEf'; +import AuthManager from "components/auth/AuthManager"; export default function Root() { const navigate = useNavigate(); const { t } = useTranslation(); - const signup = () => { - navigate("/signup"); + + const navigateToDashboard = async () => { + if (await AuthManager.getInstance().isLoggedIn()) { + navigate("/dashboard"); + } } + navigateToDashboard(); return ( -
- {"WIQ-EN2B"} - {t("session.welcome")} - - navigate("/login")}/> - {t("session.account")} {t("session.clickHere")} - +
+ + kiwiq icon + + + {"KIWIQ"} + {t("session.welcome")} + navigate("/login")}/> + {t("session.account")} navigate("/signup")}>{t("session.clickHere")} + + +
); } \ No newline at end of file diff --git a/webapp/src/pages/Rules.jsx b/webapp/src/pages/Rules.jsx index ea692c44..01302b31 100644 --- a/webapp/src/pages/Rules.jsx +++ b/webapp/src/pages/Rules.jsx @@ -1,27 +1,25 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Center } from "@chakra-ui/layout"; -import { Text, Flex, Heading, Button, Box } from "@chakra-ui/react"; +import { Text, Heading, Box } from "@chakra-ui/react"; +import GoBack from "components/GoBack"; export default function Rules() { - const navigate = useNavigate(); const { t } = useTranslation(); return ( -
- {t("common.rules")} +
+ {t("common.rules")} - - The WIQ game consists of quick games of 9 rounds. In each round there is one question and two possible answers. The key to earning points lies in choosing the correct answer. - There is only one correct answer. - You have to select a question before time runs out. - To start playing you have to click on the Play button. - - - + + {t("rules.description1")} +

+ {t("rules.description2")} +

+ {t("rules.description3")} +

+ {t("rules.description4")} +
); diff --git a/webapp/src/pages/Signup.jsx b/webapp/src/pages/Signup.jsx index fe41a0f8..5798fddd 100644 --- a/webapp/src/pages/Signup.jsx +++ b/webapp/src/pages/Signup.jsx @@ -1,14 +1,13 @@ import { Center } from "@chakra-ui/layout"; -import { Heading, Input, InputGroup, Stack, InputLeftElement, - chakra, Box, Avatar, FormControl, InputRightElement, - FormHelperText, IconButton, Alert, AlertIcon, AlertTitle, AlertDescription } from "@chakra-ui/react"; +import { Heading, Input, InputGroup, Stack, InputLeftElement, chakra, Box, Avatar, FormControl, InputRightElement, FormHelperText, IconButton} from "@chakra-ui/react"; import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons' import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { FaUserAlt, FaLock, FaAddressCard } from "react-icons/fa"; -import { register } from "../components/auth/AuthUtils"; import ButtonEf from '../components/ButtonEf'; +import ErrorMessageAlert from "../components/ErrorMessageAlert"; +import AuthManager from "components/auth/AuthManager"; export default function Signup() { const [email, setEmail] = useState(""); @@ -17,7 +16,7 @@ export default function Signup() { const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const navigate = useNavigate(); const { t } = useTranslation(); @@ -26,60 +25,75 @@ export default function Signup() { const ChakraFaUserAlt = chakra(FaUserAlt); const ChakraFaLock = chakra(FaLock); - const navigateToLogin = () => { - navigate("/login"); - }; - + const navigateToDashboard = async () => { + if (await AuthManager.getInstance().isLoggedIn()) { + navigate("/dashboard"); + } + } const sendRegistration = async () => { const registerData = { "email": email, "username": username, "password": password }; - try { - await register(registerData, navigateToLogin, ()=> setHasError(true)); + await AuthManager.getInstance().register(registerData, navigateToDashboard, setLocalizedErrorMessage); } catch { - setHasError(true); + setErrorMessage("Error desconocido"); } }; + const setLocalizedErrorMessage = (error) => { + switch (error.response ? error.response.status : null) { + case 400: + setErrorMessage({ type: t("error.validation.type"), message: t("error.validation.message")}); + break; + case 401: + setErrorMessage({ type: t("error.authorized.type"), message: t("error.authorized.message")}); + break; + default: + setErrorMessage({ type: t("error.unknown.type"), message: t("error.unknown.message")}); + break; + } + } + const handleEmailChange = (e) => { setEmail(e.target.value); - setHasError(false); + setErrorMessage(false); } const handleUsernameChange = (e) => { setUsername(e.target.value); - setHasError(false); + setErrorMessage(false); } const handlePasswordChange = (e) => { setPassword(e.target.value); - setHasError(false); + setErrorMessage(false); } const handleConfirmPasswordChange = (e) => { setConfirmPassword(e.target.value); - setHasError(false); + setErrorMessage(false); + } + + const registerOnEnter = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + sendRegistration(); + } } + navigateToDashboard(); + return ( -
+
- - + + {t("common.register")} - { - hasError && - - - {t("error.register")} - {t("error.register-desc")} - - } + @@ -146,7 +160,7 @@ export default function Signup() { Las contraseƱas no coinciden )} - + diff --git a/webapp/src/pages/Statistics.jsx b/webapp/src/pages/Statistics.jsx new file mode 100644 index 00000000..1bb1899a --- /dev/null +++ b/webapp/src/pages/Statistics.jsx @@ -0,0 +1,165 @@ +import { Box, Center, Flex, Heading, Stack, StackDivider, Table, Tbody, Text, + Td, Th, Thead, Tr, useMediaQuery, CircularProgress} from "@chakra-ui/react"; +import React, { useState } from "react"; +import { Doughnut } from "react-chartjs-2"; +import { DoughnutController, ArcElement} from "chart.js/auto"; // These imports are necessary +import { useTranslation } from "react-i18next"; +import GoBack from "components/GoBack"; +import AuthManager from "components/auth/AuthManager"; +import { HttpStatusCode } from "axios"; + +const UserVisual = (props) => { + const {t} = useTranslation(); + const topTen = props.topTen; + const userData = props.userData; + const [tooSmall] = useMediaQuery("(max-width: 800px)"); + + const getTopTenData = () => { + return topTen.map((element, counter) => { + return + {counter + 1} + {element.username} + {element.correct} + {element.wrong} + {element.total} + {element.rate} + + }); + } + return <> + + + {t("common.statistics.general")} + + { + topTen.length === 0 ? + Woah, so empty : + + + + + + + + + + + + + {getTopTenData()} + +
{t("statistics.position")}{t("statistics.username")}{t("statistics.rightAnswers")}{t("statistics.wrongAnswers")}{t("statistics.totalAnswers")}{t("statistics.percentage")}
+ } +
+ + }> + {t("common.statistics.personal")} + + + {t("statistics.rightAnswers")} + + + {t("statistics.texts.personalRight", {right: userData.absolute.right})} + + + + + {t("statistics.texts.personalWrong", {wrong: userData.absolute.wrong}) } + + + + + {t("statistics.percentage")} + + + {t("statistics.texts.personalRate", {rate: userData.rate[0]})} + + + + + {} + } + } + }}> + + + +} + +export default function Statistics() { + const {t} = useTranslation(); + const [retrievedData, setRetrievedData] = useState(false); + const [topTen, setTopTen] = useState([]); + const [userData, setUserData] = useState({ + // "rate": [50,50], + // "absolute": { + // "right": 6, + // "wrong": 6 + // } + }); + const [errorMessage, setErrorMessage] = useState(null); + + const getData = async () => { + try { + const request = await new AuthManager().getAxiosInstance() + .get(process.env.REACT_APP_API_ENDPOINT + "/statistics"); + if (request.status === HttpStatusCode.Ok) { + setTopTen(request.data.topTen); + setUserData(request.data.userData); + setRetrievedData(true); + } else { + throw request; + } + } catch (error) { + let errorType; + switch (error.response ? error.response.status : null) { + case 400: + errorType = { type: "error.validation.type", message: "error.validation.message"}; + break; + case 401: + errorType = { type: "error.authorized.type", message: "error.authorized.message"}; + break; + default: + errorType = { type: "error.unknown.type", message: "error.unknown.message"}; + break; + } + } + } + + return ( +
+ + {t("common.statistics.title")} + } minW="30vw" minH="50vh" + p="1rem" backgroundColor="whiteAlpha.900" shadow="2xl" + boxShadow="md" rounded="1rem" justifyContent="center" alignItems={"center"}> + { retrievedData ? + : + + } + + + +
+ ); +} \ No newline at end of file diff --git a/webapp/src/styles/AppView.css b/webapp/src/styles/AppView.css deleted file mode 100644 index cac8ad61..00000000 --- a/webapp/src/styles/AppView.css +++ /dev/null @@ -1,8 +0,0 @@ -.effect1 { - transition: transform 0.3s, background-color 0.3s, color 0.3s; -} - -.effect1:hover { - transform: scale(1.1); - background-color: #0f47ee; -} \ No newline at end of file diff --git a/webapp/src/styles/theme.js b/webapp/src/styles/theme.js new file mode 100644 index 00000000..1bc8fbfb --- /dev/null +++ b/webapp/src/styles/theme.js @@ -0,0 +1,108 @@ +import { extendTheme } from "@chakra-ui/react"; +import '@fontsource-variable/outfit'; // Supports weights 100-900 + +const theme = extendTheme({ + fonts: { + heading: "Outfit Variable, sans-serif", + body: "Tahoma" + }, + fontWeights: { + bold: 900, + }, + colors: { + moonstone: { // blue + DEFAULT: '#64b1b9', + 100: '#122527', + 200: '#234a4f', + 300: '#357076', + 400: '#47959e', + 500: '#64b1b9', + 600: '#83c1c7', + 700: '#a2d0d5', + 800: '#c1e0e3', + 900: '#e0eff1' + }, + raw_umber: { // brown + DEFAULT: '#955e42', + 100: '#1e130d', + 200: '#3c251a', + 300: '#593827', + 400: '#774b34', + 500: '#955e42', + 600: '#b7795b', + 700: '#c99b84', + 800: '#dbbcad', + 900: '#edded6' + }, + forest_green: { // dark green + DEFAULT: '#248232', + 100: '#071a0a', + 200: '#0e3514', + 300: '#164f1e', + 400: '#1d6a28', + 500: '#248232', + 600: '#33ba47', + 700: '#5ed370', + 800: '#94e29f', + 900: '#c9f0cf' + }, + pigment_green: { // green + DEFAULT: '#2ba84a', + 100: '#09210f', + 200: '#11421d', + 300: '#1a642c', + 400: '#22853b', + 500: '#2ba84a', + 600: '#40ce63', + 700: '#6fda8a', + 800: '#9fe6b1', + 900: '#cff3d8' + }, + beige: { + DEFAULT: '#eef5db', + 100: '#3b4914', + 200: '#769228', + 300: '#aacd49', + 400: '#cce192', + 500: '#eef5db', + 600: '#f2f7e2', + 700: '#f5f9e9', + 800: '#f8fbf1', + 900: '#fcfdf8' + }, + }, + components: { + Heading: { + baseStyle: { + bgGradient: 'linear(to-l, forest_green.400, pigment_green.600)', + bgClip: 'text', + }, + sizes: { + xl: { + fontSize: "5xl", + }, + }, + }, + Link: { + baseStyle: { + color: "forest_green.400", + }, + }, + }, + styles: { + global: { + ".effect1": { + transition: "transform 0.3s, background-color 0.3s, color 0.3s", + }, + ".effect1:hover": { + transform: "scale(1.1)", + backgroundColor: "#0f47ee", + }, + ".statistics-table td, .statistics-table th": { + margin: "0vh 1vw", + padding: "0vh 1vw", + }, + }, + }, +}); +export default theme; \ No newline at end of file diff --git a/webapp/src/tests/AuthManager.test.js b/webapp/src/tests/AuthManager.test.js new file mode 100644 index 00000000..94382421 --- /dev/null +++ b/webapp/src/tests/AuthManager.test.js @@ -0,0 +1,42 @@ +import MockAdapter from "axios-mock-adapter"; +import AuthManager from "../components/auth/AuthManager"; +import { HttpStatusCode } from "axios"; + +const authManager = new AuthManager(); +let mockAxios; + +describe("AuthManager", () => { + + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + }); + + it("can log in successfully", async () => { + mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { + "token": "token", + "refresh_Token": "refreshToken" + }); + const mockOnSucess = jest.fn(); + const mockOnError = jest.fn(); + + const loginData = { + "email": "test@email.com", + "password": "test" + }; + + await authManager.login(loginData, mockOnSucess, mockOnError); + + expect(mockOnSucess).toHaveBeenCalled(); + expect(mockOnError).not.toHaveBeenCalled(); + expect(authManager.isLoggedIn()).toBe(true); + + }); + + it("can log out correctly", async () => { + mockAxios.onGet().replyOnce(HttpStatusCode.Ok); + authManager.setLoggedIn(true); + await authManager.logout(); + expect(authManager.isLoggedIn()).toBe(false); + }); +}); diff --git a/webapp/src/tests/AuthUtils.test.js b/webapp/src/tests/AuthUtils.test.js deleted file mode 100644 index bc315bf2..00000000 --- a/webapp/src/tests/AuthUtils.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import MockAdapter from "axios-mock-adapter"; -import axios, { HttpStatusCode } from "axios"; -import {isUserLogged, login, saveToken} from "components/auth/AuthUtils"; - -const mockAxios = new MockAdapter(axios); - -describe("Auth Utils tests", () => { - describe("when the user is not authenticated", () => { - - beforeEach(() => { - sessionStorage.clear(); - mockAxios.reset(); - }); - - it("does not have a stored token", () => { - expect(isUserLogged()).toBe(false); - }); - - it("can log in successfully", async () => { - mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { - "token": "token", - "refresh_Token": "refreshToken" - }); - const mockOnSucess = jest.fn(); - const mockOnError = jest.fn(); - - const loginData = { - "email": "test@email.com", - "password": "test" - }; - - await login(loginData, mockOnSucess, mockOnError); - - expect(mockOnSucess).toHaveBeenCalled(); - expect(mockOnError).not.toHaveBeenCalled(); - expect(isUserLogged()).toBe(true); - }); - - it("handles login error", async () => { - mockAxios.onPost().replyOnce(HttpStatusCode.BadRequest); - - const mockOnSucess = jest.fn(); - const mockOnError = jest.fn(); - - const loginData = { - "email": "test@email.com", - "password": "test" - }; - - await login(loginData, mockOnSucess, mockOnError); - - expect(mockOnSucess).not.toHaveBeenCalled(); - expect(mockOnError).toHaveBeenCalled(); - expect(isUserLogged()).toBe(false); - }); - }); - - describe("when the user is authenticated", () => { - - beforeAll(() => { - sessionStorage.setItem("jwtToken", "token"); - }); - - afterEach(() => { - sessionStorage.clear(); - }); - - it("has a stored token", () => { - expect(isUserLogged()).toBe(true); - }); - - it("can log out successfully", () => { - sessionStorage.clear(); - expect(isUserLogged()).toBe(false); - }); - }); - - describe("saving the token", () => { - beforeEach(() => { - sessionStorage.clear(); - }); - - it("saves the token and refresh token", () => { - let mockResponse = { - "data": { - "token": "token", - "refresh_token": "refreshToken" - } - }; - saveToken(mockResponse); - expect(sessionStorage.getItem("jwtToken")).toBe(mockResponse.data.token); - expect(sessionStorage.getItem("jwtRefreshToken")).toBe(mockResponse.data.refresh_token); - }); - }); -}); - - - diff --git a/webapp/src/tests/Dashboard.test.js b/webapp/src/tests/Dashboard.test.js index 0e278d3a..bf56f45e 100644 --- a/webapp/src/tests/Dashboard.test.js +++ b/webapp/src/tests/Dashboard.test.js @@ -2,12 +2,35 @@ import React from 'react'; import { render, fireEvent, screen, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Dashboard from '../pages/Dashboard'; -import ButtonEf from '../components/ButtonEf'; -import * as LogoutModule from '../components/game/Logout'; +import AuthManager from 'components/auth/AuthManager'; +import MockAdapter from 'axios-mock-adapter'; +import { HttpStatusCode } from 'axios'; +import { ChakraProvider } from '@chakra-ui/react'; +import theme from '../styles/theme'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); + +const authManager = new AuthManager(); +let mockAxios; describe('Dashboard component', () => { + + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + }) + it('renders dashboard elements correctly', async () => { - const { getByText } = render(); + const { getByText } = render(); expect(getByText("common.dashboard")).toBeInTheDocument(); @@ -19,7 +42,7 @@ describe('Dashboard component', () => { }); it('navigates to the rules route on button click', () => { - render(); + render(); const rulesButton = screen.getByTestId('Rules'); fireEvent.click(rulesButton); @@ -28,7 +51,7 @@ describe('Dashboard component', () => { }); it('do not navigates to the statistics route on button click', () => { - render(); + render(); const statisticsButton = screen.getByTestId('Statistics'); fireEvent.click(statisticsButton); @@ -37,7 +60,7 @@ describe('Dashboard component', () => { }); it('navigates to the game route on "Play" button click', () => { - render(); + render(); const playButton = screen.getByTestId('Play'); fireEvent.click(playButton); @@ -46,7 +69,7 @@ describe('Dashboard component', () => { }); it('does not navigate to the statistics route on button click', () => { - render(); + render(); const statisticsButton = screen.getByTestId('Statistics'); fireEvent.click(statisticsButton); @@ -54,29 +77,21 @@ describe('Dashboard component', () => { expect(screen.getByText("common.dashboard")).toBeInTheDocument(); }); - it('renders ButtonEf correctly', () => { - const { getByTestId } = render( {}} />); - - expect(getByTestId('TestId')).toBeInTheDocument(); - }); - it('handles logout successfully', async () => { - render(); - + render(); + mockAxios.onGet().replyOnce(HttpStatusCode.Ok); const logoutButton = screen.getByText(/logout/i); - const logoutUserMock = jest.spyOn(LogoutModule, 'logoutUser').mockResolvedValueOnce(); - await act(async () => { fireEvent.click(logoutButton); }); - expect(logoutUserMock).toHaveBeenCalledTimes(1); + expect(mockAxios.history.get.length).toBe(1); expect(screen.getByText("common.dashboard")).toBeInTheDocument(); }); it('does not navigate to the statistics route on disabled button click', () => { - render(); + render(); const statisticsButton = screen.getByTestId('Statistics'); fireEvent.click(statisticsButton); diff --git a/webapp/src/tests/Game.test.js b/webapp/src/tests/Game.test.js index 8b0b1f9a..98726349 100644 --- a/webapp/src/tests/Game.test.js +++ b/webapp/src/tests/Game.test.js @@ -1,54 +1,79 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Game from '../pages/Game'; +import { ChakraProvider } from '@chakra-ui/react'; +import theme from '../styles/theme'; +import { getQuestion } from '../components/game/Questions'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); + +jest.mock('../components/game/Questions', () => ({ + getQuestion: jest.fn(), +})); describe('Game component', () => { + beforeEach(() => { + getQuestion.mockResolvedValue({ + content: 'Test question', + answers: [ + { id: 1, text: 'Test answer 1', category: 'Test category 1' }, + { id: 2, text: 'Test answer 2', category: 'Test category 2' }, + ], + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + test('renders without crashing', () => { - render(); + render(); }); - test('selects an option when clicked', () => { - const { getByTestId } = render(); - const option1Button = getByTestId('Option1'); - + test('selects an option when clicked', async () => { + render(); + const option1Button = await screen.findByTestId('Option1'); + fireEvent.click(option1Button); - - expect(option1Button).toHaveClass('chakra-button custom-button effect1 css-1vdwnhw'); + + expect(option1Button).toHaveClass('chakra-button custom-button effect1 css-m4hh83'); }); - test('disables next button when no option is selected', () => { - const { getByText } = render(); - const nextButton = getByText('Next'); - + test('disables next button when no option is selected', async () => { + render(); + const nextButton = await screen.findByText('Next'); + expect(nextButton).toBeDisabled(); }); - test('enables next button when an option is selected', () => { - const { getByTestId, getByText } = render(); - const option1Button = getByTestId('Option1'); - const nextButton = getByText('Next'); - + test('enables next button when an option is selected', async () => { + render(); + const option1Button = await screen.findByTestId('Option1'); + const nextButton = await screen.findByText('Next'); + fireEvent.click(option1Button); - + expect(nextButton).toBeEnabled(); }); - test('renders ButtonEf component correctly', () => { - const { getByTestId } = render( - - - - ); - const option2Button = getByTestId('Option2'); - - // Assuming 'outline' variant is the default state - expect(option2Button).toHaveClass('chakra-button css-1vdwnhw'); - - // Simulate selecting the option + test('renders ButtonEf component correctly', async () => { + render(); + const option2Button = await screen.findByTestId('Option2'); + + expect(option2Button).toHaveClass('chakra-button custom-button effect1 css-147pzm2'); + fireEvent.click(option2Button); - - // Ensure the 'solid' variant is applied when the option is selected - expect(option2Button).toHaveClass('chakra-button custom-button effect1 css-1vdwnhw'); + + expect(option2Button).toHaveClass('chakra-button custom-button effect1 css-m4hh83'); }); -}); \ No newline at end of file +}); diff --git a/webapp/src/tests/Login.test.js b/webapp/src/tests/Login.test.js index 6d0471da..37634ea2 100644 --- a/webapp/src/tests/Login.test.js +++ b/webapp/src/tests/Login.test.js @@ -3,40 +3,40 @@ import { render, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router'; import Login from '../pages/Login'; -import { login as mockLogin } from '../components/auth/AuthUtils'; -import * as AuthUtils from '../components/auth/AuthUtils'; -import {logoutUser} from "components/game/Logout"; - -jest.mock('../components/auth/AuthUtils', () => ({ - isUserLogged: jest.fn(), - login: jest.fn(), -})); +import AuthManager from 'components/auth/AuthManager'; +import MockAdapter from 'axios-mock-adapter'; +import { HttpStatusCode } from 'axios'; +import { ChakraProvider } from '@chakra-ui/react'; +import theme from '../styles/theme'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: jest.fn(), })); -jest.mock('../components/game/Logout', () => ({ - logoutUser: jest.fn(), +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, })); +const authManager = new AuthManager(); +let mockAxios = new MockAdapter(authManager.getAxiosInstance()); describe('Login Component', () => { beforeEach(() => { + authManager.reset(); jest.clearAllMocks(); - }); - - it('calls logoutUser when user is already logged in', async () => { - jest.spyOn(AuthUtils, 'isUserLogged').mockReturnValue(true); - - render(); - - expect(logoutUser).toHaveBeenCalled(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); }); it('calls login function with correct credentials on submit', async () => { - const { getByPlaceholderText, getByTestId } = render(, { wrapper: MemoryRouter }); + const { getByPlaceholderText, getByTestId } = render(); const emailInput = getByPlaceholderText('session.email'); const passwordInput = getByPlaceholderText('session.password'); const loginButton = getByTestId('Login'); @@ -46,24 +46,12 @@ describe('Login Component', () => { fireEvent.click(loginButton); await waitFor(() => { - expect(mockLogin).toHaveBeenCalledWith( - { email: 'test@example.com', password: 'password123' }, - expect.any(Function), - expect.any(Function) - ); + expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ email: 'test@example.com', password: 'password123' })); }); }); - it('calls logoutUser during useEffect when user is already logged in', async () => { - jest.spyOn(AuthUtils, 'isUserLogged').mockReturnValue(true); - - render(); - - expect(logoutUser).toHaveBeenCalled(); - }); - it('renders form elements correctly', () => { - const { getByPlaceholderText, getByTestId } = render(); + const { getByPlaceholderText, getByTestId } = render(); expect(getByPlaceholderText('session.email')).toBeInTheDocument(); expect(getByPlaceholderText('session.password')).toBeInTheDocument(); @@ -72,7 +60,7 @@ describe('Login Component', () => { it('toggles password visibility', () => { - const { getByLabelText, getByPlaceholderText } = render(); + const { getByLabelText, getByPlaceholderText } = render(); const passwordInput = getByPlaceholderText('session.password'); expect(passwordInput).toHaveAttribute('type', 'password'); @@ -83,22 +71,19 @@ describe('Login Component', () => { expect(passwordInput).toHaveAttribute('type', 'text'); }); - it('calls login function with correct credentials on submit', async () => { - const { getByPlaceholderText, getByTestId } = render(, { wrapper: MemoryRouter }); + it('displays error message on failed login attempt', async () => { + mockAxios.onPost().replyOnce(HttpStatusCode.BadRequest); + const { getByPlaceholderText, getByTestId } = render(); const emailInput = getByPlaceholderText('session.email'); const passwordInput = getByPlaceholderText('session.password'); const loginButton = getByTestId('Login'); - + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); fireEvent.change(passwordInput, { target: { value: 'password123' } }); fireEvent.click(loginButton); - + await waitFor(() => { - expect(mockLogin).toHaveBeenCalledWith( - { email: 'test@example.com', password: 'password123' }, - expect.any(Function), - expect.any(Function) - ); + expect(getByTestId('error-message')).toBeInTheDocument(); }); }); }); \ No newline at end of file diff --git a/webapp/src/tests/Logout.test.js b/webapp/src/tests/Logout.test.js index 56e8dfa4..f6281d43 100644 --- a/webapp/src/tests/Logout.test.js +++ b/webapp/src/tests/Logout.test.js @@ -1,24 +1,39 @@ -import axios from "axios"; import MockAdapter from "axios-mock-adapter"; -import {logoutUser} from "components/game/Logout"; +import AuthManager from "components/auth/AuthManager"; +import Logout from "../pages/Logout"; +import { MemoryRouter } from "react-router"; +import { render, waitFor } from "@testing-library/react"; +import React from "react"; -const mockAxios = new MockAdapter(axios); +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, + })); +const authManager = new AuthManager(); +let mockAxios; describe("Logout User tests", () => { beforeEach(() => { - sessionStorage.clear(); - mockAxios.reset(); + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); }); it("successfully logs out the user", async () => { mockAxios.onGet(process.env.REACT_APP_API_ENDPOINT + "/auth/logout").replyOnce(200); - sessionStorage.setItem("jwtToken", "token"); - sessionStorage.setItem("jwtRefreshToken", "refreshToken"); + authManager.setLoggedIn(true); - await logoutUser(); + render(, {wrapper: MemoryRouter}); - expect(sessionStorage.getItem("jwtToken")).toBeNull(); - expect(sessionStorage.getItem("jwtRefreshToken")).toBeNull(); + waitFor(() => { + expect(mockAxios.history.get.length).toBe(1); + expect(authManager.isLoggedIn()).toBe(false); + }) }); }); \ No newline at end of file diff --git a/webapp/src/tests/Questions.test.js b/webapp/src/tests/Questions.test.js index 7fe6e37a..cf96ca75 100644 --- a/webapp/src/tests/Questions.test.js +++ b/webapp/src/tests/Questions.test.js @@ -4,6 +4,17 @@ import axios, { HttpStatusCode } from "axios"; const mockAxios = new MockAdapter(axios); +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, + })); + describe("Question Service tests", () => { describe("getQuestion function", () => { beforeEach(() => { diff --git a/webapp/src/tests/Results.test.js b/webapp/src/tests/Results.test.js index 47de497f..0e0ec50a 100644 --- a/webapp/src/tests/Results.test.js +++ b/webapp/src/tests/Results.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { BrowserRouter, MemoryRouter } from 'react-router-dom'; +import { BrowserRouter } from 'react-router-dom'; import Results from '../pages/Results'; jest.mock('react-router-dom', () => ({ @@ -11,6 +11,17 @@ jest.mock('react-router-dom', () => ({ }), })); +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, + })); + describe('Results Component', () => { test('renders results with correct answers', () => { const { getByText, getByTestId } = render( diff --git a/webapp/src/tests/Root.test.js b/webapp/src/tests/Root.test.js index d2687f01..1f811b4d 100644 --- a/webapp/src/tests/Root.test.js +++ b/webapp/src/tests/Root.test.js @@ -1,36 +1,53 @@ import React from 'react'; import { render, screen, fireEvent, getByTestId } from '@testing-library/react'; -import { MemoryRouter, createMemoryRouter } from 'react-router'; +import { MemoryRouter } from 'react-router'; import Root from '../pages/Root'; +import { ChakraProvider } from '@chakra-ui/react'; +import theme from '../styles/theme'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); describe('Root component', () => { - it('renders WIQ-EN2B heading', () => { - render(); - const headingElement = screen.getByText('WIQ-EN2B'); + it('renders KIWIQ heading', () => { + render(); + const headingElement = screen.getByText('KIWIQ'); expect(headingElement).toBeInTheDocument(); }); it('renders welcome message', () => { - render(); + render(); const welcomeMessage = screen.getByText('session.welcome'); expect(welcomeMessage).toBeInTheDocument(); }); it('renders Log In button', () => { - render(); + render(); expect(getByTestId(document.body, 'Login')).toBeInTheDocument(); }); it('navigates to /login when Log In button is clicked', () => { - const { container } = render(); - fireEvent.click(getByTestId(document.body, 'Login')); - expect(container.innerHTML).toMatch('

WIQ-EN2B

session.welcome

session.account session.clickHere

'); + render(); + fireEvent.click(screen.getByTestId('Login')); + expect(screen.getByText('KIWIQ')).toBeInTheDocument(); + expect(screen.getByText('session.welcome')).toBeInTheDocument(); + expect(screen.getByTestId('Login')).toBeInTheDocument(); }); it('navigates to /signup when "You don\'t have an account?" message is clicked', () => { - const { container } = render(); + render(); fireEvent.click(screen.getByText('session.account')); - expect(container.innerHTML).toMatch('

WIQ-EN2B

session.welcome

session.account session.clickHere

'); + expect(screen.getByText('KIWIQ')).toBeInTheDocument(); + expect(screen.getByText('session.welcome')).toBeInTheDocument(); + expect(screen.getByTestId('Login')).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/webapp/src/tests/Rules.test.js b/webapp/src/tests/Rules.test.js index 8218b94f..15299730 100644 --- a/webapp/src/tests/Rules.test.js +++ b/webapp/src/tests/Rules.test.js @@ -3,6 +3,17 @@ import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Rules from '../pages/Rules'; +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); + describe('Rules component', () => { it('renders rules elements correctly', async () => { const { getByText, getByTestId } = render(); diff --git a/webapp/src/tests/Signup.test.js b/webapp/src/tests/Signup.test.js index 378150ec..bd122cee 100644 --- a/webapp/src/tests/Signup.test.js +++ b/webapp/src/tests/Signup.test.js @@ -1,18 +1,27 @@ import React from 'react'; -import { render, fireEvent, getByTestId, getAllByTestId, waitFor } from '@testing-library/react'; +import { render, fireEvent, getByTestId, getAllByTestId, waitFor, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Signup from '../pages/Signup'; -import * as AuthUtils from '../components/auth/AuthUtils'; - -jest.mock('../components/auth/AuthUtils', () => ({ - isUserLogged: jest.fn(), - register: jest.fn(), +import AuthManager from '../components/auth/AuthManager'; +import { when } from 'jest-when'; +import { ChakraProvider } from '@chakra-ui/react'; +import theme from '../styles/theme'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, })); describe('Signup Component', () => { it('renders form elements correctly', () => { - const { getByPlaceholderText } = render(); + const { getByPlaceholderText } = render(); expect(getByPlaceholderText('session.email')).toBeInTheDocument(); expect(getByPlaceholderText('session.username')).toBeInTheDocument(); @@ -21,7 +30,7 @@ describe('Signup Component', () => { }); it('toggles password visibility', () => { - const { getByPlaceholderText } = render(); + const { getByPlaceholderText } = render(); const passwordInput = getByPlaceholderText('session.password'); const showPasswordButtons = getAllByTestId(document.body, 'show-confirm-password-button'); @@ -32,7 +41,7 @@ describe('Signup Component', () => { }); it('submits form data correctly', async () => { - const { getByPlaceholderText, getByTestId } = render(); + const { getByPlaceholderText, getByTestId } = render(); const emailInput = getByPlaceholderText('session.email'); const usernameInput = getByPlaceholderText('session.username'); @@ -44,8 +53,9 @@ describe('Signup Component', () => { fireEvent.change(passwordInput, { target: { value: 'password' } }); fireEvent.click(signUpButton); }); + it('toggles confirm password visibility', () => { - const { getAllByTestId, getByPlaceholderText } = render(); + const { getAllByTestId, getByPlaceholderText } = render(); getByPlaceholderText('session.confirm_password'); const toggleButton = getAllByTestId('show-confirm-password-button')[1]; @@ -54,74 +64,13 @@ describe('Signup Component', () => { const confirmPasswordInput = getByPlaceholderText('session.confirm_password'); expect(confirmPasswordInput.getAttribute('type')).toBe('text'); }); + it('handles confirm password change', () => { - const { getByPlaceholderText } = render(); + const { getByPlaceholderText } = render(); const confirmPasswordInput = getByPlaceholderText('session.confirm_password'); fireEvent.change(confirmPasswordInput, { target: { value: 'newPassword' } }); expect(confirmPasswordInput.value).toBe('newPassword'); }); - - it('navigates to login page on successful registration', async () => { - const { getByPlaceholderText, getByTestId } = render(); - - // EspĆ­a sobre la funciĆ³n de registro - const registerSpy = jest.spyOn(AuthUtils, 'register').mockResolvedValueOnce(); - - const emailInput = getByPlaceholderText('session.email'); - const usernameInput = getByPlaceholderText('session.username'); - const passwordInput = getByPlaceholderText('session.password'); - const signUpButton = getByTestId('Sign up'); - - // Modifica los valores segĆŗn lo que necesites - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - fireEvent.change(usernameInput, { target: { value: 'testuser' } }); - fireEvent.change(passwordInput, { target: { value: 'password' } }); - fireEvent.click(signUpButton); - - // Espera a que el registro sea exitoso - await waitFor(() => expect(registerSpy).toHaveBeenCalled()); - - // AsegĆŗrate de que la funciĆ³n de navegaciĆ³n se haya llamado - expect(registerSpy.mock.calls[0][1]).toBeInstanceOf(Function); // Esto verifica que se pase una funciĆ³n como segundo argumento - registerSpy.mock.calls[0][1](); // Llama a la funciĆ³n de navegaciĆ³n - - // Verifica que la navegaciĆ³n se haya realizado correctamente - // Puedes agregar mĆ”s expectativas aquĆ­ segĆŗn tus necesidades - - // Restaura la implementaciĆ³n original de la funciĆ³n de registro para otras pruebas - registerSpy.mockRestore(); - }); - - it('handles registration error', async () => { - const { getByPlaceholderText, getByTestId } = render(); - - // EspĆ­a sobre la funciĆ³n de registro - const registerSpy = jest.spyOn(AuthUtils, 'register').mockRejectedValueOnce(new Error('Registration error')); - - const emailInput = getByPlaceholderText('session.email'); - const usernameInput = getByPlaceholderText('session.username'); - const passwordInput = getByPlaceholderText('session.password'); - const signUpButton = getByTestId('Sign up'); - // Modifica los valores segĆŗn lo que necesites - fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - fireEvent.change(usernameInput, { target: { value: 'testuser' } }); - fireEvent.change(passwordInput, { target: { value: 'password' } }); - fireEvent.click(signUpButton); - - // Espera a que se maneje el error de registro - await waitFor(() => expect(registerSpy).toHaveBeenCalled()); - - // Verifica que la funciĆ³n de manejo de error se haya llamado - expect(registerSpy.mock.calls[0][2]).toBeInstanceOf(Function); // Verifica que se pase una funciĆ³n como tercer argumento - registerSpy.mock.calls[0][2](); // Llama a la funciĆ³n de manejo de error - - // Verifica que la variable de estado `hasError` se haya establecido correctamente - // Puedes agregar mĆ”s expectativas aquĆ­ segĆŗn tus necesidades - // ... - - // Restaura la implementaciĆ³n original de la funciĆ³n de registro para otras pruebas - registerSpy.mockRestore(); - }); }); \ No newline at end of file diff --git a/webapp/src/tests/Statistics.test.js b/webapp/src/tests/Statistics.test.js new file mode 100644 index 00000000..16fcb94d --- /dev/null +++ b/webapp/src/tests/Statistics.test.js @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import Statistics from "pages/Statistics"; +import React from "react"; + +describe("Statistics", () => { + + it("renders the spinning wheel while no data is loaded", async () => { + // TODO: mock Axios here once connectivity is implemented + + // render(); + // expect(screen.getByTestId("spinning-wheel")).toBeVisible(); + }); + + it("renders the spinning wheel while no data is loaded", async () => { + // TODO: mock Axios here once connectivity is implemented + + // render(); + // expect(screen.getByTestId("spinning-wheel")).toBeVisible(); + }); + +});