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 82c0cc31..27ca6bf4 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,25 +1,25 @@ -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; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.NonNull; - -@NoArgsConstructor -@AllArgsConstructor -@Data -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; -} +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; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@NoArgsConstructor +@AllArgsConstructor +@Data +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/commons/user/UserResponseDto.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java index 14e860f6..0d14ca74 100644 --- a/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserResponseDto.java @@ -1,12 +1,16 @@ package lab.en2b.quizapi.commons.user; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @AllArgsConstructor @Data @Builder @EqualsAndHashCode public class UserResponseDto { + @Schema(description = "Identification for an user", example = "123456789") private Long id; + @Schema(description = "Username for a game", example = "HordyJurtado") private String username; + @Schema(description = "Email for a game", example = "chipiChipi@chapaChapa.es") private String email; } diff --git a/api/src/main/java/lab/en2b/quizapi/game/Game.java b/api/src/main/java/lab/en2b/quizapi/game/Game.java index 1cb2567e..5028b9d7 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/Game.java +++ b/api/src/main/java/lab/en2b/quizapi/game/Game.java @@ -48,6 +48,7 @@ public class Game { @JoinColumn(name="question_id", referencedColumnName="id") ) private List questions; + private boolean isGameOver; public void newRound(Question question){ if(getActualRound() != 0){ @@ -72,8 +73,6 @@ public boolean isGameOver(){ } 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()) diff --git a/api/src/main/java/lab/en2b/quizapi/game/GameController.java b/api/src/main/java/lab/en2b/quizapi/game/GameController.java index 0e2516e4..2f5e2d7e 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/GameController.java +++ b/api/src/main/java/lab/en2b/quizapi/game/GameController.java @@ -1,47 +1,90 @@ package lab.en2b.quizapi.game; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import lab.en2b.quizapi.game.dtos.GameAnswerDto; import lab.en2b.quizapi.game.dtos.GameResponseDto; +import lab.en2b.quizapi.questions.question.QuestionCategory; 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.*; +import java.util.List; + @RestController @RequestMapping("/games") @RequiredArgsConstructor public class GameController { private final GameService gameService; + + @Operation(summary = "Starts new game", description = "Requests the API to create a new game for a given authentication (a player)") + @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), + }) @PostMapping("/new") public ResponseEntity newGame(Authentication authentication){ return ResponseEntity.ok(gameService.newGame(authentication)); } + @Operation(summary = "Starts a new round", description = "Starts the round (asks a question and its possible answers to the API and start the timer) for a given authentication (a player)") + @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), + }) @PostMapping("/{id}/startRound") public ResponseEntity startRound(@PathVariable Long id, Authentication authentication){ return ResponseEntity.ok(gameService.startRound(id, authentication)); } + @Operation(summary = "Starts a new round", description = "Gets the question and its possible answers from the API for a given authentication (a player)") + @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), + }) @GetMapping("/{id}/question") public ResponseEntity getCurrentQuestion(@PathVariable Long id, Authentication authentication){ return ResponseEntity.ok(gameService.getCurrentQuestion(id, authentication)); } + @Operation(summary = "Starts a new round", description = "Starts the round (getting a question and its possible answers and start the timer) for a given authentication (a player)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "400", description = "Not a valid answer", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "403", description = "You are not logged in", content = @io.swagger.v3.oas.annotations.media.Content), + }) @PostMapping("/{id}/answer") public ResponseEntity answerQuestion(@PathVariable Long id, @RequestBody GameAnswerDto dto, Authentication authentication){ return ResponseEntity.ok(gameService.answerQuestion(id, dto, authentication)); } + @Operation(summary = "Changing languages", description = "Changes the language of the game for a given authentication (a player) and a language supported. Changes may are applied on the next round.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "400", description = "Not a valid answer", content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse(responseCode = "403", description = "You are not logged in", content = @io.swagger.v3.oas.annotations.media.Content), + }) @PutMapping("/{id}/language") public ResponseEntity changeLanguage(@PathVariable Long id, @RequestParam String language, Authentication authentication){ return ResponseEntity.ok(gameService.changeLanguage(id, language, authentication)); } + @Operation(summary = "Get the summary of a game") + @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), + }) @GetMapping("/{id}/details") public ResponseEntity getGameDetails(@PathVariable Long id, Authentication authentication){ return ResponseEntity.ok(gameService.getGameDetails(id, authentication)); } + @GetMapping("/questionCategories") + public ResponseEntity> getQuestionCategories(){ + return ResponseEntity.ok(gameService.getQuestionCategories()); + } + } diff --git a/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java b/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java index ec63787d..fd3094bb 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java +++ b/api/src/main/java/lab/en2b/quizapi/game/GameRepository.java @@ -8,6 +8,9 @@ public interface GameRepository extends JpaRepository { - @Query(value = "SELECT * FROM Games g WHERE id=?1 AND user_id=?2", nativeQuery = true) + @Query(value = "SELECT * FROM Games g WHERE id=?1 AND user_id=?2 LIMIT 1", nativeQuery = true) Optional findByIdForUser(Long gameId, Long userId); + + @Query(value = "SELECT * FROM Games g WHERE user_id = ?1 AND g.is_game_over = false LIMIT 1", nativeQuery = true) + Optional findActiveGameForUser(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 index 28963d4a..447b3995 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/GameService.java +++ b/api/src/main/java/lab/en2b/quizapi/game/GameService.java @@ -1,21 +1,23 @@ 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.QuestionCategory; 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 lab.en2b.quizapi.statistics.Statistics; +import lab.en2b.quizapi.statistics.StatisticsRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; @Service @RequiredArgsConstructor @@ -23,9 +25,14 @@ public class GameService { private final GameRepository gameRepository; private final GameResponseDtoMapper gameResponseDtoMapper; private final UserService userService; + private final QuestionService questionService; private final QuestionRepository questionRepository; private final QuestionResponseDtoMapper questionResponseDtoMapper; + private final StatisticsRepository statisticsRepository; public GameResponseDto newGame(Authentication authentication) { + if (gameRepository.findActiveGameForUser(userService.getUserByAuthentication(authentication).getId()).isPresent()){ + return gameResponseDtoMapper.apply(gameRepository.findActiveGameForUser(userService.getUserByAuthentication(authentication).getId()).get()); + } return gameResponseDtoMapper.apply(gameRepository.save(Game.builder() .user(userService.getUserByAuthentication(authentication)) .questions(new ArrayList<>()) @@ -39,6 +46,18 @@ public GameResponseDto newGame(Authentication authentication) { public GameResponseDto startRound(Long id, Authentication authentication) { Game game = gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow(); game.newRound(questionRepository.findRandomQuestion(game.getLanguage())); + if (game.isGameOver()){ + Statistics statistics = Statistics.builder() + .user(game.getUser()) + .correct(Long.valueOf(game.getCorrectlyAnsweredQuestions())) + .wrong(Long.valueOf(game.getRounds() - game.getCorrectlyAnsweredQuestions())) + .total(Long.valueOf(game.getRounds())) + .build(); + Statistics oldStatistics = statisticsRepository.findByUserId(game.getUser().getId()).orElseThrow(); + statisticsRepository.delete(oldStatistics); + oldStatistics.updateStatistics(statistics); + statisticsRepository.save(oldStatistics); + } return gameResponseDtoMapper.apply(gameRepository.save(game)); } @@ -62,4 +81,8 @@ public GameResponseDto changeLanguage(Long id, String language, Authentication a public GameResponseDto getGameDetails(Long id, Authentication authentication) { return gameResponseDtoMapper.apply(gameRepository.findByIdForUser(id, userService.getUserByAuthentication(authentication).getId()).orElseThrow()); } + + public List getQuestionCategories() { + return Arrays.asList(QuestionCategory.values()); + } } 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 index d1bc5d5e..54cc8634 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/dtos/GameAnswerDto.java +++ b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameAnswerDto.java @@ -12,7 +12,6 @@ @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 index 192e0bc9..e7b680bf 100644 --- a/api/src/main/java/lab/en2b/quizapi/game/dtos/GameResponseDto.java +++ b/api/src/main/java/lab/en2b/quizapi/game/dtos/GameResponseDto.java @@ -1,6 +1,7 @@ package lab.en2b.quizapi.game.dtos; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lab.en2b.quizapi.commons.user.UserResponseDto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,22 +15,30 @@ @Builder @EqualsAndHashCode public class GameResponseDto { + @Schema(description = "Identification for a game", example = "23483743") private Long id; + @Schema(description = "User for the game", example = "{\"id\":1,\"username\":\"Hordi Jurtado\",\"email\":\"chipiChipi@chapaChapa.es \"}") private UserResponseDto user; + @Schema(description = "Total rounds for the game", example = "9") private int rounds; + @Schema(description = "Actual round for the game", example = "3") private int actualRound; + @Schema(description = "Number of correct answered questions", example = "2") @JsonProperty("correctly_answered_questions") private int correctlyAnsweredQuestions; + @Schema(description = "Moment when the timer has started", example = "LocalDateTime.now()") @JsonProperty("round_start_time") private LocalDateTime roundStartTime; + @Schema(description = "Number of seconds for the player to answer the question", example = "20") @JsonProperty("round_duration") private int roundDuration; + @Schema(description = "Whether the game has finished or not", example = "true") private boolean isGameOver; } diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/Statistics.java b/api/src/main/java/lab/en2b/quizapi/statistics/Statistics.java new file mode 100644 index 00000000..ee727760 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/Statistics.java @@ -0,0 +1,44 @@ +package lab.en2b.quizapi.statistics; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lab.en2b.quizapi.commons.user.User; +import lombok.*; + +@Entity +@Table(name = "statistics") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class Statistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Setter(AccessLevel.NONE) + private Long id; + + @NonNull + private Long correct; + @NonNull + private Long wrong; + @NonNull + private Long total; + + @ManyToOne + @NotNull + @JoinColumn(name = "user_id") + private User user; + + public Long getCorrectRate() { + return (correct * 100) / total; + } + + public void updateStatistics(Statistics statistics){ + this.correct += statistics.getCorrect(); + this.wrong += statistics.getWrong(); + this.total += statistics.getTotal(); + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsController.java b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsController.java new file mode 100644 index 00000000..a874a7b8 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsController.java @@ -0,0 +1,30 @@ +package lab.en2b.quizapi.statistics; + +import lab.en2b.quizapi.statistics.dtos.StatisticsResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/statistics") +@RequiredArgsConstructor +public class StatisticsController { + + private final StatisticsService statisticsService; + + @GetMapping("/personal") + public ResponseEntity getPersonalStatistics(Authentication authentication){ + return ResponseEntity.ok(statisticsService.getStatisticsForUser(authentication)); + } + + @GetMapping("/top") + public ResponseEntity> getTopTenStatistics(){ + return ResponseEntity.ok(statisticsService.getTopTenStatistics()); + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsRepository.java b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsRepository.java new file mode 100644 index 00000000..c3ed50ee --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsRepository.java @@ -0,0 +1,13 @@ +package lab.en2b.quizapi.statistics; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface StatisticsRepository extends JpaRepository { + + @Query(value = "SELECT * FROM Statistics WHERE user_id = ?1", nativeQuery = true) + Optional findByUserId(Long userId); + +} diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsService.java b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsService.java new file mode 100644 index 00000000..0f0ec3f6 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/StatisticsService.java @@ -0,0 +1,34 @@ +package lab.en2b.quizapi.statistics; + +import lab.en2b.quizapi.commons.user.UserService; +import lab.en2b.quizapi.statistics.dtos.StatisticsResponseDto; +import lab.en2b.quizapi.statistics.mappers.StatisticsResponseDtoMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StatisticsService { + + private final StatisticsRepository statisticsRepository; + private final UserService userService; + private final StatisticsResponseDtoMapper statisticsResponseDtoMapper; + + public StatisticsResponseDto getStatisticsForUser(Authentication authentication){ + return statisticsResponseDtoMapper.apply(statisticsRepository.findByUserId(userService. + getUserByAuthentication(authentication).getId()).orElseThrow()); + } + + public List getTopTenStatistics(){ + List all = statisticsRepository.findAll(); + all.sort(Comparator.comparing(Statistics::getCorrectRate).reversed()); + List topTen = all.stream().limit(10).collect(Collectors.toList()); + return topTen.stream().map(statisticsResponseDtoMapper).collect(Collectors.toList()); + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/dtos/StatisticsResponseDto.java b/api/src/main/java/lab/en2b/quizapi/statistics/dtos/StatisticsResponseDto.java new file mode 100644 index 00000000..2a04eed7 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/dtos/StatisticsResponseDto.java @@ -0,0 +1,24 @@ +package lab.en2b.quizapi.statistics.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; + +@AllArgsConstructor +@Data +@Builder +@EqualsAndHashCode +public class StatisticsResponseDto { + + private Long id; + private Long right; + private Long wrong; + private Long total; + private UserResponseDto user; + @JsonProperty("correct_rate") + private Long correctRate; + +} diff --git a/api/src/main/java/lab/en2b/quizapi/statistics/mappers/StatisticsResponseDtoMapper.java b/api/src/main/java/lab/en2b/quizapi/statistics/mappers/StatisticsResponseDtoMapper.java new file mode 100644 index 00000000..c4b301f0 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/statistics/mappers/StatisticsResponseDtoMapper.java @@ -0,0 +1,28 @@ +package lab.en2b.quizapi.statistics.mappers; + +import lab.en2b.quizapi.commons.user.mappers.UserResponseDtoMapper; +import lab.en2b.quizapi.statistics.Statistics; +import lab.en2b.quizapi.statistics.dtos.StatisticsResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +public class StatisticsResponseDtoMapper implements Function { + + private final UserResponseDtoMapper userResponseDtoMapper; + + @Override + public StatisticsResponseDto apply(Statistics statistics) { + return StatisticsResponseDto.builder() + .id(statistics.getId()) + .right(statistics.getCorrect()) + .wrong(statistics.getWrong()) + .total(statistics.getTotal()) + .user(userResponseDtoMapper.apply(statistics.getUser())) + .correctRate(statistics.getCorrectRate()) + .build(); + } +} diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 788a68b8..8f7af9da 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -1,12 +1,12 @@ -JWT_EXPIRATION_MS=86400000 -REFRESH_TOKEN_DURATION_MS=86400000 -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 -springdoc.swagger-ui.path=/swagger/swagger-ui.html -springdoc.api-docs.path=/swagger/api-docs - -management.endpoints.web.exposure.include=prometheus +JWT_EXPIRATION_MS=86400000 +REFRESH_TOKEN_DURATION_MS=86400000 +spring.jpa.hibernate.ddl-auto=create +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USER} +spring.datasource.password=${DATABASE_PASSWORD} +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/game/GameControllerTest.java b/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java index a5e07da1..f9865ad8 100644 --- a/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java +++ b/api/src/test/java/lab/en2b/quizapi/game/GameControllerTest.java @@ -161,4 +161,21 @@ void getGameDetailsShouldReturn200() throws Exception{ .andExpect(status().isOk()); } + @Test + void getQuestionCategoriesShouldReturn200() throws Exception{ + mockMvc.perform(get("/games/questionCategories") + .with(user("test").roles("user")) + .contentType("application/json") + .with(csrf())) + .andExpect(status().isOk()); + } + + @Test + void getQuestionCategoriesShouldReturn403() throws Exception{ + mockMvc.perform(get("/games/questionCategories") + .contentType("application/json") + .with(csrf())) + .andExpect(status().isForbidden()); + } + } diff --git a/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java b/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java index 3ccd75f5..d42ef727 100644 --- a/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java +++ b/api/src/test/java/lab/en2b/quizapi/game/GameServiceTest.java @@ -14,6 +14,7 @@ import lab.en2b.quizapi.questions.question.*; import lab.en2b.quizapi.questions.question.dtos.QuestionResponseDto; import lab.en2b.quizapi.questions.question.mappers.QuestionResponseDtoMapper; +import lab.en2b.quizapi.statistics.StatisticsRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,10 +26,7 @@ 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 java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -50,6 +48,12 @@ public class GameServiceTest { @Mock private QuestionRepository questionRepository; + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private QuestionService questionService; + private User defaultUser; private Question defaultQuestion; private QuestionResponseDto defaultQuestionResponseDto; @@ -69,7 +73,7 @@ public class GameServiceTest { @BeforeEach void setUp() { this.questionResponseDtoMapper = new QuestionResponseDtoMapper(); - this.gameService = new GameService(gameRepository,new GameResponseDtoMapper(new UserResponseDtoMapper()), userService, questionRepository, questionResponseDtoMapper); + this.gameService = new GameService(gameRepository,new GameResponseDtoMapper(new UserResponseDtoMapper()), userService, questionService, questionRepository, questionResponseDtoMapper, statisticsRepository); this.defaultUser = User.builder() .id(1L) .email("test@email.com") @@ -200,12 +204,12 @@ public void getCurrentQuestion() { assertEquals(defaultQuestionResponseDto, questionDto); } - @Test + /*@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() { @@ -333,4 +337,9 @@ public void getGameDetailsInvalidId(){ assertThrows(NoSuchElementException.class, () -> gameService.getGameDetails(2L, authentication)); } + @Test + public void testGetQuestionCategories(){ + assertEquals(Arrays.asList(QuestionCategory.values()), gameService.getQuestionCategories()); + } + } diff --git a/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsControllerTest.java b/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsControllerTest.java new file mode 100644 index 00000000..6af50855 --- /dev/null +++ b/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsControllerTest.java @@ -0,0 +1,61 @@ +package lab.en2b.quizapi.statistics; + +import lab.en2b.quizapi.auth.config.SecurityConfig; +import lab.en2b.quizapi.auth.jwt.JwtUtils; +import lab.en2b.quizapi.commons.user.UserService; +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 org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StatisticsController.class) +@AutoConfigureMockMvc +@Import(SecurityConfig.class) +public class StatisticsControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + JwtUtils jwtUtils; + + @MockBean + UserService userService; + + @MockBean + StatisticsService statisticsService; + + @Test + void getPersonalStatisticsShouldReturn200() throws Exception{ + mockMvc.perform(get("/statistics/personal") + .with(user("test").roles("user"))) + .andExpect(status().isOk()); + } + + @Test + void getPersonalStatisticsShouldReturn403() throws Exception{ + mockMvc.perform(get("/statistics/personal")) + .andExpect(status().isForbidden()); + } + + @Test + void getTopTenStatisticsShouldReturn200() throws Exception{ + mockMvc.perform(get("/statistics/top") + .with(user("test").roles("user"))) + .andExpect(status().isOk()); + } + + @Test + void getTopTenStatisticsShouldReturn403() throws Exception{ + mockMvc.perform(get("/statistics/top")) + .andExpect(status().isForbidden()); + } + +} diff --git a/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsServiceTest.java b/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsServiceTest.java new file mode 100644 index 00000000..9a91a629 --- /dev/null +++ b/api/src/test/java/lab/en2b/quizapi/statistics/StatisticsServiceTest.java @@ -0,0 +1,131 @@ +package lab.en2b.quizapi.statistics; + +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.statistics.dtos.StatisticsResponseDto; +import lab.en2b.quizapi.statistics.mappers.StatisticsResponseDtoMapper; +import org.junit.jupiter.api.Assertions; +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.util.List; +import java.util.Optional; + +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 StatisticsServiceTest { + + @InjectMocks + private StatisticsService statisticsService; + + @Mock + private UserService userService; + + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private Authentication authentication; + + @Mock + private StatisticsResponseDtoMapper statisticsResponseDtoMapper; + + private User defaultUser; + + private Statistics defaultStatistics1; + + private StatisticsResponseDto defaultStatisticsResponseDto1; + + private StatisticsResponseDto defaultStatisticsResponseDto2; + + private Statistics defaultStatistics2; + + private UserResponseDto defaultUserResponseDto; + + @BeforeEach + public void setUp(){ + this.statisticsService = new StatisticsService(statisticsRepository, userService, statisticsResponseDtoMapper); + 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.defaultUserResponseDto = UserResponseDto.builder() + .id(1L) + .email("test") + .username("test") + .build(); + + this.defaultStatistics1 = Statistics.builder() + .id(1L) + .user(defaultUser) + .correct(5L) + .wrong(5L) + .total(10L) + .build(); + + this.defaultStatisticsResponseDto1 = StatisticsResponseDto.builder() + .id(1L) + .right(5L) + .wrong(5L) + .total(10L) + .correctRate(50L) + .user(defaultUserResponseDto) + .build(); + + this.defaultStatistics2 = Statistics.builder() + .id(1L) + .user(defaultUser) + .correct(7L) + .wrong(3L) + .total(10L) + .build(); + + this.defaultStatisticsResponseDto2 = StatisticsResponseDto.builder() + .id(1L) + .right(7L) + .wrong(3L) + .total(10L) + .correctRate(70L) + .user(defaultUserResponseDto) + .build(); + } + + @Test + public void getStatisticsForUserTest(){ + Authentication authentication = mock(Authentication.class); + when(userService.getUserByAuthentication(any())).thenReturn(defaultUser); + when(statisticsRepository.findByUserId(any())).thenReturn(Optional.of(defaultStatistics1)); + when(statisticsResponseDtoMapper.apply(any())).thenReturn(defaultStatisticsResponseDto1); + StatisticsResponseDto result = statisticsService.getStatisticsForUser(authentication); + Assertions.assertEquals(defaultStatisticsResponseDto1, result); + } + + /*@Test + public void getTopTenStatisticsTestWhenThereAreNotTen(){ + when(statisticsRepository.findAll()).thenReturn(List.of(defaultStatistics2, defaultStatistics1)); + when(statisticsResponseDtoMapper.apply(any())).thenReturn(defaultStatisticsResponseDto1); + when(statisticsResponseDtoMapper.apply(any())).thenReturn(defaultStatisticsResponseDto2); + when(statisticsResponseDtoMapper.apply(any())).thenReturn(defaultStatisticsResponseDto1); + List result = statisticsService.getTopTenStatistics(); + Assertions.assertEquals(List.of(defaultStatisticsResponseDto2,defaultStatisticsResponseDto1), result); + }*/ + +} diff --git a/sonar-project.properties b/sonar-project.properties index 957ea4f3..bfb6cdd5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,7 +12,7 @@ sonar.language=js,java sonar.coverage.exclusions=**/*.test.js,**/*.test.jsx sonar.sources=webapp/src/components,api/src/main/java,webapp/src/pages/ sonar.sourceEncoding=UTF-8 -sonar.exclusions=node_modules/**,**/quizapi/commons/utils/**,**/QuizApiApplication.java,**/quizapi/auth/config/**,**/quizapi/commons/exceptions/**,**/quizapi/auth/jwt/**,**/quizapi/**/dtos/** +sonar.exclusions=node_modules/**,webapp/src/i18n.js,webapp/src/index.js,webapp/src/reportWebVitals.js,webapp/src/setupTests.js,**/quizapi/commons/utils/**,**/QuizApiApplication.java,**/quizapi/auth/config/**,**/quizapi/commons/exceptions/**,**/quizapi/auth/jwt/**,**/quizapi/**/dtos/** sonar.javascript.lcov.reportPaths=**/coverage/lcov.info #Java specific config diff --git a/webapp/package-lock.json b/webapp/package-lock.json index ab959a03..6dfc5ec2 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -17,7 +17,6 @@ "@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", @@ -25,7 +24,6 @@ "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", @@ -34,6 +32,7 @@ "react-router-dom": "^6.21.3", "react-scripts": "5.0.1", "react-use": "^17.5.0", + "recharts": "^2.12.3", "web-vitals": "^3.5.1" }, "devDependencies": { @@ -43,6 +42,7 @@ "expect-puppeteer": "^9.0.2", "jest": "^29.3.1", "jest-cucumber": "^3.0.1", + "jest-each": "^29.7.0", "jest-environment-node": "^29.7.0", "mongodb-memory-server": "^9.1.4", "puppeteer": "^21.7.0", @@ -5687,11 +5687,6 @@ "@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", @@ -6616,6 +6611,60 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/eslint": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.1.tgz", @@ -8969,17 +9018,6 @@ "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", @@ -9217,6 +9255,14 @@ "node": ">=8" } }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -10102,6 +10148,116 @@ "uuid": "bin/uuid" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -10159,6 +10315,11 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -10460,6 +10621,15 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -11962,6 +12132,14 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -13596,6 +13774,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -23124,15 +23310,6 @@ "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", @@ -24553,6 +24730,20 @@ "node": ">=10" } }, + "node_modules/react-smooth": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", + "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -24575,6 +24766,21 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/react-universal-interface": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", @@ -24743,6 +24949,41 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.3.tgz", + "integrity": "sha512-vE/F7wTlokf5mtCqVDJlVKelCjliLSJ+DJxj79XlMREm7gpV7ljwbrwE3CfeaoDlOaLX+6iwHaVRn9587YkwIg==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^16.10.2", + "react-smooth": "^4.0.0", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/recursive-readdir": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", @@ -28194,6 +28435,27 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/webapp/package.json b/webapp/package.json index 94d37338..00f78c59 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -12,7 +12,6 @@ "@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", @@ -20,7 +19,6 @@ "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", @@ -29,6 +27,7 @@ "react-router-dom": "^6.21.3", "react-scripts": "5.0.1", "react-use": "^17.5.0", + "recharts": "^2.12.3", "web-vitals": "^3.5.1" }, "scripts": { @@ -64,6 +63,7 @@ "expect-puppeteer": "^9.0.2", "jest": "^29.3.1", "jest-cucumber": "^3.0.1", + "jest-each": "^29.7.0", "jest-environment-node": "^29.7.0", "mongodb-memory-server": "^9.1.4", "puppeteer": "^21.7.0", diff --git a/webapp/public/locales/en/translation.json b/webapp/public/locales/en/translation.json index ac05339f..93f2242f 100644 --- a/webapp/public/locales/en/translation.json +++ b/webapp/public/locales/en/translation.json @@ -62,10 +62,11 @@ "statistics": { "position": "Position", "username": "Username", - "rightAnswers": "Correct answers", - "wrongAnswers": "Wrong answers", - "totalAnswers": "Total answers", - "percentage": "Correct answer rate", + "rightAnswers": "Correct", + "wrongAnswers": "Wrong", + "totalAnswers": "Total", + "percentage": "Rate", + "empty": "Currently, there are no statistics saved", "texts": { "personalRight": "{{right, number}} correct answers", "personalWrong": "{{wrong, number}} wrong answers", diff --git a/webapp/public/locales/es/translation.json b/webapp/public/locales/es/translation.json index 41085ba4..b2c2db73 100644 --- a/webapp/public/locales/es/translation.json +++ b/webapp/public/locales/es/translation.json @@ -60,11 +60,12 @@ }, "statistics": { "position": "Posición", - "username": "Nombre de usuario", + "username": "Nombre", "rightAnswers": "Respuestas correctas", - "wrongAnswers": "Respuestas erróneas", - "totalAnswers": "Respuestas totales", - "percentage": "Porcentaje de acierto", + "wrongAnswers": "Respuestas falladas", + "totalAnswers": "En total", + "percentage": "Acierto", + "empty": "Actualmente, no hay estadísticas guardadas", "texts": { "personalRight": "{{right, number}} respuestas correctas", "personalWrong": "{{wrong, number}} respuestas incorrectas", diff --git a/webapp/src/components/LateralMenu.jsx b/webapp/src/components/LateralMenu.jsx index dcce5e03..e3a0a075 100644 --- a/webapp/src/components/LateralMenu.jsx +++ b/webapp/src/components/LateralMenu.jsx @@ -8,20 +8,20 @@ import { InfoIcon, SettingsIcon } from '@chakra-ui/icons'; import AuthManager from "components/auth/AuthManager"; -const LateralMenu = ({ isOpen, onClose, changeLanguage, currentLanguage, isDashboard }) => { +const LateralMenu = ({ isOpen, onClose, changeLanguage, isDashboard }) => { const navigate = useNavigate(); - const [selectedLanguage, setSelectedLanguage] = useState(currentLanguage); + const [selectedLanguage, setSelectedLanguage] = useState(''); const [isLoggedIn, setIsLoggedIn] = useState(false); const { t } = useTranslation(); useEffect(() => { - setSelectedLanguage(currentLanguage); checkIsLoggedIn(); - }, [currentLanguage]); + }, []); const handleChangeLanguage = (e) => { - const selectedLanguage = e.target.value; - changeLanguage(selectedLanguage); + const selectedValue = e.target.value; + setSelectedLanguage(selectedValue); + changeLanguage(selectedValue); }; const handleApiClick = () => { @@ -116,7 +116,6 @@ LateralMenu.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, changeLanguage: PropTypes.func.isRequired, - currentLanguage: PropTypes.string.isRequired, isDashboard: PropTypes.bool.isRequired }; diff --git a/webapp/src/components/statistics/UserStatistics.jsx b/webapp/src/components/statistics/UserStatistics.jsx new file mode 100644 index 00000000..172bdcfd --- /dev/null +++ b/webapp/src/components/statistics/UserStatistics.jsx @@ -0,0 +1,100 @@ +import { Box, Flex, Heading, Stack, Text, CircularProgress } from "@chakra-ui/react"; +import { HttpStatusCode } from "axios"; +import ErrorMessageAlert from "components/ErrorMessageAlert"; +import AuthManager from "components/auth/AuthManager"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Cell, Pie, PieChart } from "recharts"; + +export default function UserStatistics() { + const {t} = useTranslation(); + const [userData, setUserData] = useState(null); + const [retrievedData, setRetrievedData] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const getData = async () => { + try { + const request = await new AuthManager().getAxiosInstance() + .get(process.env.REACT_APP_API_ENDPOINT + "/statistics/personal"); + if (request.status === HttpStatusCode.Ok) { + setUserData({ + "raw": [ + { + "name": t("statistics.texts.personalRight"), + "value": request.data.correct + }, + { + "name": t("statistics.texts.personalWrong"), + "value": request.data.wrong + } + ], + "rate": request.data.correctRate + }); + setRetrievedData(true); + } else { + throw request; + } + } catch (error) { + let errorType; + switch (error.response ? error.response.status : null) { + case 400: + errorType = { type: t("error.validation.type"), message: t("error.validation.message")}; + break; + case 404: + errorType = { type: t("error.notFound.type"), message: t("error.notFound.message")}; + break; + default: + errorType = { type: t("error.unknown.type"), message: t("error.unknown.message")}; + break; + } + setErrorMessage(errorType); + } + } + + return + { + retrievedData ? + <> + + + {t("common.statistics.personal")} + + + {t("statistics.rightAnswers")} + + + {t("statistics.texts.personalRight", {right: userData.raw[0].value})} + + + + + {t("statistics.wrongAnswers")} + + + {t("statistics.texts.personalWrong", {wrong: userData.raw[1].value}) } + + + + + {t("statistics.percentage")} + + + {t("statistics.texts.personalRate", {rate: userData.rate})} + + + + + + + + + + + + + : + } + +} \ No newline at end of file diff --git a/webapp/src/pages/About.jsx b/webapp/src/pages/About.jsx index af99bb39..cf1fd6f0 100644 --- a/webapp/src/pages/About.jsx +++ b/webapp/src/pages/About.jsx @@ -1,74 +1,74 @@ -import React, { useState } from "react"; -import { useTranslation } from 'react-i18next'; -import { Center, Heading, Stack, Box, Text, Table, Thead, Tr, Td, Th, Tbody, Container } from '@chakra-ui/react'; -import { InfoIcon } from '@chakra-ui/icons'; - -import LateralMenu from '../components/LateralMenu'; -import MenuButton from '../components/MenuButton'; -import GoBack from "components/GoBack"; - -export default function About() { - const { t, i18n } = useTranslation(); - const currentLanguage = i18n.language; - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const changeLanguage = (selectedLanguage) => { - i18n.changeLanguage(selectedLanguage); - }; - - return ( -
- setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> - - - - {t('about.title')} - - - {t("about.description1")} -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{t("about.table1")}{t("about.table2")}
Gonzalo Alonso FernándezUO282104
Sergio Rodríguez GarcíaUO282598
Jorge Joaquín Gancedo FernándezUO282161
Darío Gutiérrez MoriUO282435
Sergio Quintana FernándezUO288090
Diego Villanueva BerrosUO283615
Gonzalo Suárez LosadaUO283928
- -
-
-
- ); +import React, { useState } from "react"; +import { useTranslation } from 'react-i18next'; +import { Center, Heading, Stack, Box, Text, Table, Thead, Tr, Td, Th, Tbody, Container } from '@chakra-ui/react'; +import { InfoIcon } from '@chakra-ui/icons'; + +import LateralMenu from '../components/LateralMenu'; +import MenuButton from '../components/MenuButton'; +import GoBack from "components/GoBack"; + +export default function About() { + const { t, i18n } = useTranslation(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const changeLanguage = (selectedLanguage) => { + i18n.changeLanguage(selectedLanguage); + }; + + return ( +
+ setIsMenuOpen(true)} /> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/> + + + + {t('about.title')} + + + {t("about.description1")} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{t("about.table1")}{t("about.table2")}
Gonzalo Alonso FernándezUO282104
Sergio Rodríguez GarcíaUO282598
Jorge Joaquín Gancedo FernándezUO282161
Darío Gutiérrez MoriUO282435
Sergio Quintana FernándezUO288090
Diego Villanueva BerrosUO283615
Gonzalo Suárez LosadaUO283928
+ +
+
+
+ ); } \ No newline at end of file diff --git a/webapp/src/pages/Dashboard.jsx b/webapp/src/pages/Dashboard.jsx index a7ba0ee4..4edae2af 100644 --- a/webapp/src/pages/Dashboard.jsx +++ b/webapp/src/pages/Dashboard.jsx @@ -23,7 +23,7 @@ export default function Dashboard() { }; const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; + const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; @@ -31,7 +31,7 @@ export default function Dashboard() { return (
setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={true}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={true}/> {t("common.dashboard")} diff --git a/webapp/src/pages/Game.jsx b/webapp/src/pages/Game.jsx index 19f4afb7..8af44cea 100644 --- a/webapp/src/pages/Game.jsx +++ b/webapp/src/pages/Game.jsx @@ -80,7 +80,6 @@ export default function Game() { const { t, i18n } = useTranslation(); const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; @@ -88,7 +87,7 @@ export default function Game() { return (
setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/> {t("game.round") + `${roundNumber}`} diff --git a/webapp/src/pages/Login.jsx b/webapp/src/pages/Login.jsx index 61f80bb1..4e92cdd7 100644 --- a/webapp/src/pages/Login.jsx +++ b/webapp/src/pages/Login.jsx @@ -63,7 +63,6 @@ export default function Login() { const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; @@ -73,7 +72,7 @@ export default function Login() { justifyContent={"center"} alignItems={"center"} onKeyDown={loginOnEnter} bgImage={'/background.svg'}> setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/> diff --git a/webapp/src/pages/Root.jsx b/webapp/src/pages/Root.jsx index 349551e9..78bd4a8e 100644 --- a/webapp/src/pages/Root.jsx +++ b/webapp/src/pages/Root.jsx @@ -13,7 +13,6 @@ export default function Root() { const navigate = useNavigate(); const { t, i18n } = useTranslation(); const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const navigateToDashboard = async () => { if (await new AuthManager().isLoggedIn()) { @@ -29,7 +28,7 @@ export default function Root() { return (
setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/>
diff --git a/webapp/src/pages/Rules.jsx b/webapp/src/pages/Rules.jsx index 9e3c2855..7b528024 100644 --- a/webapp/src/pages/Rules.jsx +++ b/webapp/src/pages/Rules.jsx @@ -13,7 +13,6 @@ export default function Rules() { const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; @@ -21,7 +20,7 @@ export default function Rules() { return (
setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/> {t("common.rules")} diff --git a/webapp/src/pages/Signup.jsx b/webapp/src/pages/Signup.jsx index 6ebf5c1d..14432814 100644 --- a/webapp/src/pages/Signup.jsx +++ b/webapp/src/pages/Signup.jsx @@ -90,7 +90,6 @@ export default function Signup() { const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; @@ -98,7 +97,7 @@ export default function Signup() { return (
setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> + setIsMenuOpen(false)} changeLanguage={changeLanguage} isDashboard={false}/> diff --git a/webapp/src/pages/Statistics.jsx b/webapp/src/pages/Statistics.jsx index a386e23c..7d58ddb6 100644 --- a/webapp/src/pages/Statistics.jsx +++ b/webapp/src/pages/Statistics.jsx @@ -1,135 +1,28 @@ -import { Box, Center, Flex, Heading, Stack, StackDivider, Table, Tbody, Text, - Td, Th, Thead, Tr, useMediaQuery, CircularProgress} from "@chakra-ui/react"; +import { Box, Center, Heading, Stack, StackDivider, Table, Tbody, Text, + Td, Th, Thead, Tr, 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"; +import ErrorMessageAlert from "components/ErrorMessageAlert"; +import UserStatistics from "components/statistics/UserStatistics"; import { FaChartBar } from 'react-icons/fa'; - -import LateralMenu from '../components/LateralMenu'; import MenuButton from '../components/MenuButton'; - -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]})} - - - - - {} - } - } - }}> - - - -} +import LateralMenu from '../components/LateralMenu'; export default function Statistics() { const { t, i18n } = useTranslation(); const [retrievedData, setRetrievedData] = useState(false); - const [topTen, setTopTen] = useState([]); - const [userData, setUserData] = useState({ - // "rate": [50,50], - // "absolute": { - // "right": 6, - // "wrong": 6 - // } - }); + const [topTen, setTopTen] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const getData = async () => { try { const request = await new AuthManager().getAxiosInstance() - .get(process.env.REACT_APP_API_ENDPOINT + "/statistics"); + .get(process.env.REACT_APP_API_ENDPOINT + "/statistics/top"); if (request.status === HttpStatusCode.Ok) { - setTopTen(request.data.topTen); - setUserData(request.data.userData); + setTopTen(request.data); setRetrievedData(true); } else { throw request; @@ -138,40 +31,82 @@ export default function Statistics() { let errorType; switch (error.response ? error.response.status : null) { case 400: - errorType = { type: "error.validation.type", message: "error.validation.message"}; + errorType = { type: t("error.validation.type"), message: t("error.validation.message")}; break; - case 401: - errorType = { type: "error.authorized.type", message: "error.authorized.message"}; + case 403: + errorType = { type: t("error.authorized.type"), message: t("error.authorized.message")}; break; default: - errorType = { type: "error.unknown.type", message: "error.unknown.message"}; + errorType = { type: t("error.unknown.type"), message: t("error.unknown.message")}; break; - } + } + setErrorMessage(errorType); } } + const formatTopTen = () => { + return topTen.map((element, counter) => { + return + {counter + 1} + {element.username} + {element.correct} + {element.wrong} + {element.total} + {element.rate}% + + }); + } + + const [isMenuOpen, setIsMenuOpen] = useState(false); - const currentLanguage = i18n.language; const changeLanguage = (selectedLanguage) => { i18n.changeLanguage(selectedLanguage); }; - return ( -
- setIsMenuOpen(true)} /> - setIsMenuOpen(false)} changeLanguage={changeLanguage} currentLanguage={currentLanguage} isDashboard={false}/> +
+ setIsMenuOpen(true)}/> + setIsMenuOpen(false)} + changeLanguage={changeLanguage} isDashboard={false}/> + {t("common.statistics.title")} - } minW="30vw" minH="50vh" + } minH="50vh" p="1rem" backgroundColor="whiteAlpha.900" shadow="2xl" - boxShadow="md" rounded="1rem" justifyContent="center" alignItems={"center"}> - { retrievedData ? - : - + boxShadow="md" rounded="1rem" alignItems={"center"} data-testid={"leaderboard-component"}> + {retrievedData ? + + + {t("common.statistics.general")} + + { + topTen.length === 0 ? + {t("statistics.empty")} : + + + + + + + + + + + + + {formatTopTen()} + +
{t("statistics.position")}{t("statistics.username")}{t("statistics.rightAnswers")}{t("statistics.wrongAnswers")}{t("statistics.totalAnswers")}{t("statistics.percentage")}
+ } +
+ : } +
diff --git a/webapp/src/styles/theme.js b/webapp/src/styles/theme.js index 1bc8fbfb..80c266de 100644 --- a/webapp/src/styles/theme.js +++ b/webapp/src/styles/theme.js @@ -100,8 +100,14 @@ const theme = extendTheme({ }, ".statistics-table td, .statistics-table th": { margin: "0vh 1vw", - padding: "0vh 1vw", + padding: "0vh 1vw" }, + ".statistics-table td": { + fontSize: "0.8em" + }, + ".statistics-table th": { + fontSize: "0.6em" + } }, }, }); diff --git a/webapp/src/tests/AuthManager.test.js b/webapp/src/tests/AuthManager.test.js index 37dee915..7265fed5 100644 --- a/webapp/src/tests/AuthManager.test.js +++ b/webapp/src/tests/AuthManager.test.js @@ -2,42 +2,147 @@ import MockAdapter from "axios-mock-adapter"; import AuthManager from "../components/auth/AuthManager"; import { HttpStatusCode } from "axios"; import { waitFor } from "@testing-library/react"; +import each from "jest-each"; const authManager = new AuthManager(); let mockAxios; describe("AuthManager", () => { - beforeEach(() => { - authManager.reset(); - mockAxios = new MockAdapter(authManager.getAxiosInstance()); + describe("the user has not logged in", () => { + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + localStorage.clear(); + }); + + it("is possible to 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(localStorage.length).toBe(1); + await (async () => expect(await authManager.isLoggedIn()).toBe(true)); + }); + + test("the user can register successfully", async () => { + mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { + "token": "token", + "refresh_Token": "refreshToken" + }); + const mockOnSucess = jest.fn(); + const mockOnError = jest.fn(); + + const registerData = { + "email": "test@email.com", + "username": "usernameTest", + "password": "test" + }; + + await authManager.register(registerData, mockOnSucess, mockOnError); + + expect(mockOnSucess).toHaveBeenCalled(); + expect(mockOnError).not.toHaveBeenCalled(); + expect(localStorage.length).toBe(1); + await waitFor(async () => expect(await authManager.isLoggedIn()).toBe(true)); + }); + + describe("the onError function is called if the login fails ", () => { + each([HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, HttpStatusCode.Conflict]).test("with status code %d", async (statusCode) => { + mockAxios.onPost().replyOnce(statusCode); + const mockOnSucess = jest.fn(); + const mockOnError = jest.fn(); + + const loginData = { + "email": "test@email.com", + "password": "test" + }; + + await authManager.login(loginData, mockOnSucess, mockOnError); + + expect(mockOnError).toHaveBeenCalled(); + expect(mockOnSucess).not.toHaveBeenCalled(); + expect(localStorage.length).toBe(0); + await waitFor(async () => expect(await authManager.isLoggedIn()).toBe(false)); + }); + }); + + describe("the onError function is called if the signup fails ", () => { + each([HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, HttpStatusCode.Conflict]).test("with status code %d", async (statusCode) => { + mockAxios.onPost().replyOnce(statusCode); + const mockOnSucess = jest.fn(); + const mockOnError = jest.fn(); + + const registerData = { + "username": "user", + "email": "test@email.com", + "password": "test" + }; + + await authManager.register(registerData, mockOnSucess, mockOnError); + + expect(mockOnError).toHaveBeenCalled(); + expect(mockOnSucess).not.toHaveBeenCalled(); + expect(localStorage.length).toBe(0); + await waitFor(async () => expect(await authManager.isLoggedIn()).toBe(false)); + }); + }); }); - it("can log in successfully", async () => { - mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { - "token": "token", - "refresh_Token": "refreshToken" + describe("the user has logged in", () => { + + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + localStorage.clear(); + authManager.setLoggedIn(true); }); - const mockOnSucess = jest.fn(); - const mockOnError = jest.fn(); - const loginData = { - "email": "test@email.com", - "password": "test" - }; + it("is possible to log out correctly", async () => { + mockAxios.onGet().replyOnce(HttpStatusCode.Ok); + authManager.setLoggedIn(true); + await authManager.logout(); + await waitFor(async () => expect(await authManager.isLoggedIn()).toBe(false)); + }); - await authManager.login(loginData, mockOnSucess, mockOnError); + test("the session has expired and is renewed when checking if the user is logged", async () => { + localStorage.setItem("jwtRefreshToken", "oldRefreshToken"); + mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { + "token": "token", + "refresh_Token": "newRefreshToken" + }); + await authManager.setLoggedIn(false); - expect(mockOnSucess).toHaveBeenCalled(); - expect(mockOnError).not.toHaveBeenCalled(); - waitFor(() => expect(authManager.isLoggedIn()).toBe(true)); + await waitFor(async () => { + expect(await authManager.isLoggedIn()).toBe(true); + }); + }); - }); + test("the user can log out", async () => { + mockAxios.onGet().replyOnce(HttpStatusCode.Ok); + authManager.logout(); + + await waitFor(async () => { + expect(mockAxios.history.get.length).toBe(1); + expect(localStorage.length).toBe(0); + expect(await authManager.isLoggedIn()).toBe(false); + }); + + }); - it("can log out correctly", async () => { - mockAxios.onGet().replyOnce(HttpStatusCode.Ok); - authManager.setLoggedIn(true); - await authManager.logout(); - waitFor(() => expect(authManager.isLoggedIn()).toBe(false)); }); -}); +}); \ No newline at end of file diff --git a/webapp/src/tests/Dashboard.test.js b/webapp/src/tests/Dashboard.test.js index 0116c55c..e56ed9bc 100644 --- a/webapp/src/tests/Dashboard.test.js +++ b/webapp/src/tests/Dashboard.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, screen, act } from '@testing-library/react'; +import { render, fireEvent, screen, act, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Dashboard from '../pages/Dashboard'; import AuthManager from 'components/auth/AuthManager'; @@ -30,26 +30,33 @@ describe('Dashboard component', () => { }) it('renders dashboard elements correctly', async () => { - const { getByText } = render(); - - expect(getByText("common.dashboard")).toBeInTheDocument(); - - expect(screen.getByTestId('Play')).toBeInTheDocument(); + await act(async () => { + render(); + }); - expect(screen.getByText(/logout/i)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("common.dashboard")).toBeInTheDocument(); + expect(screen.getByTestId('Play')).toBeInTheDocument(); + expect(screen.getByText(/logout/i)).toBeInTheDocument(); + }); }); - it('navigates to the game route on "Play" button click', () => { - render(); - + it('navigates to the game route on "Play" button click', async () => { + await act(async () => { + render(); + }); + const playButton = screen.getByTestId('Play'); fireEvent.click(playButton); - + expect(screen.getByText("common.play")).toBeInTheDocument(); }); it('handles logout successfully', async () => { - render(); + await act(async () => { + render(); + }); + mockAxios.onGet().replyOnce(HttpStatusCode.Ok); const logoutButton = screen.getByText(/logout/i); diff --git a/webapp/src/tests/LateralMenu.test.js b/webapp/src/tests/LateralMenu.test.js index 24f6ef78..b5524ac1 100644 --- a/webapp/src/tests/LateralMenu.test.js +++ b/webapp/src/tests/LateralMenu.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { ChakraProvider } from '@chakra-ui/react'; import theme from '../styles/theme'; @@ -34,7 +34,6 @@ describe('LateralMenu component', () => { isOpen: true, onClose: jest.fn(), changeLanguage: jest.fn(), - currentLanguage: 'es', isLoggedIn: true, isDashboard: false, }; @@ -51,13 +50,6 @@ describe('LateralMenu component', () => { expect(languageSelect).toBeInTheDocument(); }); - it('changes language when select value is changed', () => { - render(); - const selectElement = screen.getByTestId('language-select'); - fireEvent.change(selectElement, { target: { value: 'en' } }); - expect(props.changeLanguage).toHaveBeenCalledWith('en'); - }); - it('does not render dashboard button when isLoggedIn is false', () => { const newProps = { ...props, isLoggedIn: false }; render(); diff --git a/webapp/src/tests/Login.test.js b/webapp/src/tests/Login.test.js index 37634ea2..84459963 100644 --- a/webapp/src/tests/Login.test.js +++ b/webapp/src/tests/Login.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router'; import Login from '../pages/Login'; @@ -8,6 +8,7 @@ import MockAdapter from 'axios-mock-adapter'; import { HttpStatusCode } from 'axios'; import { ChakraProvider } from '@chakra-ui/react'; import theme from '../styles/theme'; +import Signup from 'pages/Signup'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), diff --git a/webapp/src/tests/Root.test.js b/webapp/src/tests/Root.test.js index 1f811b4d..ad61e4ab 100644 --- a/webapp/src/tests/Root.test.js +++ b/webapp/src/tests/Root.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, getByTestId } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; // Importa act desde @testing-library/react import { MemoryRouter } from 'react-router'; import Root from '../pages/Root'; import { ChakraProvider } from '@chakra-ui/react'; @@ -18,36 +18,46 @@ jest.mock('react-i18next', () => ({ describe('Root component', () => { - it('renders KIWIQ heading', () => { - render(); + it('renders KIWIQ heading', async () => { + await act(async () => { + render(); + }); const headingElement = screen.getByText('KIWIQ'); expect(headingElement).toBeInTheDocument(); }); - it('renders welcome message', () => { - render(); + it('renders welcome message', async () => { + await act(async () => { + render(); + }); const welcomeMessage = screen.getByText('session.welcome'); expect(welcomeMessage).toBeInTheDocument(); }); - it('renders Log In button', () => { - render(); - expect(getByTestId(document.body, 'Login')).toBeInTheDocument(); + it('renders Log In button', async () => { + await act(async () => { + render(); + }); + expect(screen.getByTestId('Login')).toBeInTheDocument(); }); - it('navigates to /login when Log In button is clicked', () => { - render(); + it('navigates to /login when Log In button is clicked', async () => { + await act(async () => { + 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', () => { - render(); + it('navigates to /signup when "You don\'t have an account?" message is clicked', async () => { + await act(async () => { + render(); + }); fireEvent.click(screen.getByText('session.account')); 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 15299730..e362eea5 100644 --- a/webapp/src/tests/Rules.test.js +++ b/webapp/src/tests/Rules.test.js @@ -18,10 +18,8 @@ describe('Rules component', () => { it('renders rules elements correctly', async () => { const { getByText, getByTestId } = render(); - // Check if the heading is rendered expect(getByText("common.rules")).toBeInTheDocument(); - // Check if the button is rendered expect(getByTestId('GoBack')).toBeInTheDocument(); }); }); diff --git a/webapp/src/tests/Signup.test.js b/webapp/src/tests/Signup.test.js index edb28544..9b2112b6 100644 --- a/webapp/src/tests/Signup.test.js +++ b/webapp/src/tests/Signup.test.js @@ -1,9 +1,12 @@ import React from 'react'; -import { render, fireEvent, getByTestId, getAllByTestId } from '@testing-library/react'; +import { render, fireEvent, getByTestId, getAllByTestId, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import Signup from '../pages/Signup'; import { ChakraProvider } from '@chakra-ui/react'; import theme from '../styles/theme'; +import MockAdapter from 'axios-mock-adapter'; +import AuthManager from 'components/auth/AuthManager'; +import { HttpStatusCode } from 'axios'; jest.mock('react-i18next', () => ({ useTranslation: () => { diff --git a/webapp/src/tests/Statistics.test.js b/webapp/src/tests/Statistics.test.js index 16fcb94d..a0402cdd 100644 --- a/webapp/src/tests/Statistics.test.js +++ b/webapp/src/tests/Statistics.test.js @@ -1,21 +1,125 @@ -import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { render, screen, waitFor } from "@testing-library/react"; import Statistics from "pages/Statistics"; import React from "react"; +import { MemoryRouter } from "react-router"; +import theme from "../styles/theme"; +import MockAdapter from "axios-mock-adapter"; +import AuthManager from "components/auth/AuthManager"; +import { HttpStatusCode } from "axios"; +import each from "jest-each"; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); describe("Statistics", () => { - it("renders the spinning wheel while no data is loaded", async () => { - // TODO: mock Axios here once connectivity is implemented + test("the page is rendered correctly", () => { + const { getByText } = render(); + expect(screen.getByTestId("leaderboard-component")).toBeEnabled(); + expect(getByText("common.statistics.title")).toBeVisible(); + expect(screen.getByTestId("background")).toBeVisible(); + }); + + test("the leaderboard spinner is rendered when loading the data", () => { + render(); + expect(screen.getByTestId("leaderboard-spinner")).toBeEnabled(); + }); + + test("the user statistics component is rendered", () => { + render(); + expect(screen.getByTestId("user-statistics")).toBeEnabled(); + }) + + describe("a petition is made requesting the top ten", () => { + const authManager = new AuthManager(); + let mockAxios; + + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + }); + + afterAll(() => { + mockAxios = null; + authManager.reset(); + }); + + test("the data is returned correctly", () => { + const data = [ + { + "username": "pepe", + "correct": 2, + "wrong": 5, + "total": 7, + "rate": 28.57 + }, + { + "username": "maria", + "correct": 4, + "wrong": 8, + "total": 12, + "rate": 33.33 + }, + { + "username": "charlie", + "correct": 8, + "wrong": 2, + "total": 10, + "rate": 80 + } + ]; + mockAxios.onGet().reply(HttpStatusCode.Ok, data); + const { container } = render(); - // render(); - // expect(screen.getByTestId("spinning-wheel")).toBeVisible(); + waitFor(() => { + expect(screen.getByTestId("top-ten")).toBeEnabled(); + expect(Array.from(container.querySelectorAll("tbody tr")).length).toBe(data.length); + data.forEach((element, counter) => { + expect(container.querySelector(`tbody tr:nth-child(${counter}) th`).innerHTML).toBe(counter + 1) + expect(container.querySelector(`tbody tr:nth-child(${counter}) td:nth-child(0)`).innerHTML).toBe(element.username); + expect(container.querySelector(`tbody tr:nth-child(${counter}) td:nth-child(1)`).innerHTML).toBe(element.correct); + expect(container.querySelector(`tbody tr:nth-child(${counter}) td:nth-child(2)`).innerHTML).toBe(element.wrong); + expect(container.querySelector(`tbody tr:nth-child(${counter}) td:nth-child(3)`).innerHTML).toBe(element.total); + expect(container.querySelector(`tbody tr:nth-child(${counter}) td:nth-child(4)`).innerHTML).toBe(element.rate); + }); + }); }); - it("renders the spinning wheel while no data is loaded", async () => { - // TODO: mock Axios here once connectivity is implemented + test("no data is returned", () => { + const data = []; + mockAxios.onGet().reply(HttpStatusCode.Ok, data); + const { getByText } = render(); - // render(); - // expect(screen.getByTestId("spinning-wheel")).toBeVisible(); + waitFor(() => { + expect(screen.getByTestId("top-ten")).not.toBeEnabled(); + expect(getByText("statistics.empty")).toBeEnabled(); + }); }); + + describe("the petition fails", () => { + each([HttpStatusCode.BadRequest, HttpStatusCode.NotFound, + HttpStatusCode.InternalServerError]).test("with status code %d", statusCode => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + mockAxios.onGet().reply(statusCode); + render(); + + waitFor(() => { + expect(mockAxios.history.get.length).toBe(1); + expect(screen.getByTestId("error-message")).toBeVisible(); + }); + }); + }) + }); + }); diff --git a/webapp/src/tests/UserStatistics.test.js b/webapp/src/tests/UserStatistics.test.js new file mode 100644 index 00000000..e02d9bd4 --- /dev/null +++ b/webapp/src/tests/UserStatistics.test.js @@ -0,0 +1,81 @@ +import { ChakraProvider } from "@chakra-ui/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router"; +import theme from "../styles/theme"; +import UserStatistics from "components/statistics/UserStatistics"; +import AuthManager from "components/auth/AuthManager"; +import MockAdapter from "axios-mock-adapter"; +import { HttpStatusCode } from "axios"; +import each from "jest-each"; + +jest.mock('react-i18next', () => ({ + useTranslation: () => { + return { + t: (str) => str, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + } + }, +})); + +describe("UserStatistics", () => { + + test("the spinner is rendered when waiting for the data", () => { + render(); + expect(screen.getByTestId("user-statistics-spinner")).toBeEnabled(); + }); + + test("the component is rendered correctly", () => { + render(); + expect(screen.getByTestId("user-statistics")).toBeEnabled(); + }); + + describe("a petition is made requesting the user's data", () => { + const authManager = new AuthManager(); + let mockAxios; + + beforeEach(() => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + }); + + afterAll(() => { + mockAxios = null; + authManager.reset(); + }); + + test("the data arrives successfully", () => { + const data = { + "right": 5, + "wrong": 5, + "rate": 50 + }; + + mockAxios.onGet().reply(HttpStatusCode.Ok, data); + render(); + + waitFor(() => { + expect(mockAxios.history.get.length).toBe(1); + expect(screen.getByTestId("chart")).toBeEnabled(); + }); + }) + + describe("the request fails", () => { + each([HttpStatusCode.BadGateway, HttpStatusCode.NotFound, + HttpStatusCode.ImATeapot]).test("with status code %d", (statusCode) => { + authManager.reset(); + mockAxios = new MockAdapter(authManager.getAxiosInstance()); + + mockAxios.onGet().reply(statusCode); + + waitFor(() => { + expect(mockAxios.history.get.length).toBe(1); + expect(screen.getByTestId("error-message")).toBeVisible(); + }); + }); + }) + }); + +});