From a1098dad036127676c1f3946af13f305ca88feaa Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 27 Dec 2024 16:19:31 -0300 Subject: [PATCH] test: add more unit and integration tests for the backend (#202) * test: add server unit tests for controllers - partial issue #71 * test: add task controller unit tests issue #71 * docs: add cloud scripts to start services * docs: add sonar to backend issue #198 * docs: add sonar params * fix: russian text * test: add note controller test issue #71 * test: add auth service unit tests - wip issue #71 * test: add auth and home service unit tests issue #71 --- .github/workflows/main.yml | 20 +- .github/workflows/pr.yml | 20 +- .../src/__test__/utils/RussianUtils.test.ts | 2 +- client/src/constants/languageConstants.ts | 2 +- cloud/start-db.sh | 13 + cloud/start-server.sh | 21 + server/pom.xml | 5 +- .../server/controller/NoteController.java | 4 +- .../server/controller/TaskController.java | 6 +- .../controller/UserSessionController.java | 8 - .../server/request/TaskRequest.java | 3 +- .../server/response/UserResponse.java | 21 +- .../server/service/AuthService.java | 41 +- .../server/service/TaskService.java | 3 +- .../server/controller/NoteControllerTest.java | 315 +++++++++++++++ .../server/controller/TaskControllerTest.java | 337 ++++++++++++++++ .../server/controller/UserControllerTest.java | 62 +++ .../controller/UserSessionControllerTest.java | 45 +++ .../server/service/AuthServiceTest.java | 359 ++++++++++++++++++ .../server/service/HomeServiceTest.java | 65 ++++ 20 files changed, 1307 insertions(+), 45 deletions(-) create mode 100644 cloud/start-db.sh create mode 100644 cloud/start-server.sh create mode 100644 server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index db0c8df..61fd136 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,13 @@ jobs: distribution: 'temurin' cache: 'maven' + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages uses: actions/cache@v4 with: @@ -76,5 +83,14 @@ jobs: - name: Google Checkstyle run: cd server && ./mvnw --no-transfer-progress checkstyle:checkstyle -Dskip.checkstyle=false - #- name: All Tests - # run: cd server && ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml + - name: Sonar Analysis + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SERVER }} + run: cd server && ./mvnw -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_server + -Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_server + -Dsonar.coverage.jacoco.xmlReportPaths=target/coverage-reports/merged-test-report/jacoco.xml + -Dsonar.exclusions=**/config/**,**/entity/**,**/exception/**,**/filter/**,**/**Builder*,**/RestExceptionEndpoint.*,**/JavaApiApiApplication.* + + - name: All Tests + run: cd server && ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 10b2d95..e85b008 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -114,6 +114,13 @@ jobs: distribution: 'temurin' cache: 'maven' + - name: Cache SonarQube Cloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages uses: actions/cache@v4 with: @@ -127,8 +134,17 @@ jobs: - name: Google Check-Style run: cd server && ./mvnw --no-transfer-progress checkstyle:checkstyle -Dskip.checkstyle=false - #- name: All Tests - # run: cd server && ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml + - name: Sonar Analysis + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SERVER }} + run: cd server && ./mvnw -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + -Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_server + -Dsonar.coverage.jacoco.xmlReportPaths=target/coverage-reports/merged-test-report/jacoco.xml + -Dsonar.exclusions=**/config/**,**/entity/**,**/exception/**,**/filter/**,**/**Builder*,**/RestExceptionEndpoint.*,**/JavaApiApiApplication.* + + - name: All Tests + run: cd server && ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml java-docker-build: name: Build Java Docker image diff --git a/client/src/__test__/utils/RussianUtils.test.ts b/client/src/__test__/utils/RussianUtils.test.ts index de257bd..2b032e0 100644 --- a/client/src/__test__/utils/RussianUtils.test.ts +++ b/client/src/__test__/utils/RussianUtils.test.ts @@ -63,7 +63,7 @@ describe('Portuguese Utils unit tests', () => { expect(translateServerResponse(keys[2], 'ru')) .toBe('Неправильный пароль: Пароль должен содержать хотя бы 1 специальный символ.'); expect(translateServerResponse(keys[3], 'ru')) - .toBe('Электронная почти уже используется!'); + .toBe('Электронная почта уже используется!'); expect(translateServerResponse(keys[4], 'ru')) .toBe('Доступ запрещен!'); expect(translateServerResponse(keys[5], 'ru')) diff --git a/client/src/constants/languageConstants.ts b/client/src/constants/languageConstants.ts index 6747ae3..7a03250 100644 --- a/client/src/constants/languageConstants.ts +++ b/client/src/constants/languageConstants.ts @@ -97,7 +97,7 @@ export const serverResponsesTranslations: Record = { BAD_PASSWORD_3_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 специальный символ.', BAD_PASSWORD_2_ru: 'Неправильный пароль: Пароль должен содержать как минимум 1 заглавную букву и 1 специальный символ.', BAD_PASSWORD_1_ru: 'Неправильный пароль: Пароль должен содержать хотя бы 1 специальный символ.', - EMAIL_EXISTS_ru: 'Электронная почти уже используется!', + EMAIL_EXISTS_ru: 'Электронная почта уже используется!', FORBIDDEN_ru: 'Доступ запрещен!', INTERNAL_ERROR_ru: 'Внутренняя ошибка сервера!', MAX_LOGIN_ATTEMPT_ru: 'Достигнут максимальный лимит попыток входа. Пожалуйста, подождите 30 минут', diff --git a/cloud/start-db.sh b/cloud/start-db.sh new file mode 100644 index 0000000..e4d0a7e --- /dev/null +++ b/cloud/start-db.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +export $(cat .env | xargs) + +docker run -d --rm \ + --name db \ + --network=host \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e PGDATA=$PGDATA \ + -v ./data:/tmp \ + postgres:15.8-bookworm diff --git a/cloud/start-server.sh b/cloud/start-server.sh new file mode 100644 index 0000000..dc2a203 --- /dev/null +++ b/cloud/start-server.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# + +PR="$1" +echo "PR: $PR" + +echo "Getting env vars..." +export $(cat .env | xargs) + +docker run -d --rm \ + --name server \ + --network=host \ + -e POSTGRES_DB=$POSTGRES_DB \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_PORT=$POSTGRES_PORT \ + -e POSTGRES_HOST=$POSTGRES_HOST \ + -e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \ + -e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \ + -e BUILD=ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR \ + ghcr.io/ricardo-campos-org/react-typescript-todolist/server:$PR diff --git a/server/pom.xml b/server/pom.xml index c3bd6d1..bd99375 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -43,6 +43,8 @@ ${maven.build.timestamp} yyyy-MM-dd HH:mm:ss 6.6.2.Final + ricardo-campos-org + https://sonarcloud.io @@ -259,6 +261,7 @@ ${jacoco.skip} **/config/** + **/entity/** **/exception/** **/filter/** **/*$*Builder* @@ -350,7 +353,7 @@ LINE COVEREDRATIO - 10% + 90% diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java index 9161e3f..49988de 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/NoteController.java @@ -90,7 +90,7 @@ public class NoteController { description = "Note not found", content = @Content(schema = @Schema(implementation = Void.class))) }) - public ResponseEntity putNote( + public ResponseEntity patchNote( @Parameter( name = "id", in = ParameterIn.PATH, @@ -171,7 +171,7 @@ public class NoteController { description = "Note not found", content = @Content(schema = @Schema(implementation = Void.class))) }) - public ResponseEntity deleteNotes( + public ResponseEntity deleteNote( @Parameter( name = "id", in = ParameterIn.PATH, diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java index edac400..459a882 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java @@ -48,7 +48,7 @@ public class TaskController { responses = { @ApiResponse( responseCode = "200", - description = "Tasks successfully retrieved", + description = "Return an array containing found Tasks, or empty array otherwise.", content = @Content( mediaType = "application/json", @@ -91,7 +91,7 @@ public class TaskController { description = "Task not found", content = @Content(schema = @Schema(implementation = Void.class))) }) - public ResponseEntity putTask( + public ResponseEntity patchTask( @Parameter( name = "id", in = ParameterIn.PATH, @@ -172,7 +172,7 @@ public class TaskController { description = "Task not found", content = @Content(schema = @Schema(implementation = Void.class))) }) - public ResponseEntity deleteTasks( + public ResponseEntity deleteTask( @Parameter( name = "id", in = ParameterIn.PATH, diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java index bc2254f..d30c60e 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/UserSessionController.java @@ -41,10 +41,6 @@ public class UserSessionController { @ApiResponse( responseCode = "403", description = "Forbidden. Access Denied", - content = @Content(schema = @Schema(implementation = Void.class))), - @ApiResponse( - responseCode = "404", - description = "User not found", content = @Content(schema = @Schema(implementation = Void.class))) }) public JwtAuthenticationResponse refresh() { @@ -65,10 +61,6 @@ public class UserSessionController { @ApiResponse( responseCode = "403", description = "Forbidden. Access Denied", - content = @Content(schema = @Schema(implementation = Void.class))), - @ApiResponse( - responseCode = "404", - description = "User not found", content = @Content(schema = @Schema(implementation = Void.class))) }) public UserResponse deteleAccount() { diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java index 6c3b6d0..afeb722 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java +++ b/server/src/main/java/br/com/tasknoteapp/server/request/TaskRequest.java @@ -1,13 +1,14 @@ package br.com.tasknoteapp.server.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.List; /** This record represents a task request to be created. */ @Schema(description = "Task request to be created.") public record TaskRequest( - @Schema(description = "Task description.") @NotNull String description, + @Schema(description = "Task description.") @NotNull @NotEmpty String description, @Schema(description = "Task urls. Optional.") List urls, @Schema(description = "Due date. Optional.") String dueDate, @Schema(description = "Define high priority. Optional.") Boolean highPriority) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java index 464cdf4..48dd0d8 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java @@ -3,6 +3,8 @@ package br.com.tasknoteapp.server.response; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +import br.com.tasknoteapp.server.entity.UserEntity; + /** This record represents a User Response object. */ @Schema(description = "This record represents a User Response object.") public record UserResponse( @@ -14,4 +16,21 @@ public record UserResponse( @Schema( description = "The inactivated date and time of the user", example = "2023-01-01T00:00:00") - LocalDateTime inactivatedAt) {} + LocalDateTime inactivatedAt) { + + + /** + * Create a {@link UserResponse} instance from a {@link UserEntity}. + * + * @param user The user entity instance with user info to be used as source. + * @return UserResponse instance. + */ + public static UserResponse fromEntity(UserEntity user) { + return new UserResponse( + user.getId(), + user.getEmail(), + user.getAdmin(), + user.getCreatedAt(), + user.getInactivatedAt()); + } +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index b2ce56c..1db055a 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -145,37 +145,36 @@ public class AuthService { } /** - * Get all registered users. + * Get all registered users. Only allowed for admin users. * * @return List of UserEntity. - * @throws UserForbiddenException when the user has no permission. + * @throws UserForbiddenException when the user has no permissions. */ public List getAllUsers() { Optional currentUserEmail = authUtil.getCurrentUserEmail(); - String email = currentUserEmail.orElseThrow(); - UserEntity currentUser = findByEmail(email).orElseThrow(); - - log.info("Getting all users to user {}", currentUser.getId()); - - if (!currentUser.getAdmin() || !currentUser.getEmail().equals("ricardompcampos@gmail.com")) { - log.info("User not allowed!"); + if (currentUserEmail.isEmpty()) { + log.error("Unable to get current user from the request"); throw new UserForbiddenException(); } - List users = userRepository.findAll(); - List usersResponse = new ArrayList<>(); - for (UserEntity user : users) { - UserResponse userResponse = - new UserResponse( - user.getId(), - user.getEmail(), - user.getAdmin(), - user.getCreatedAt(), - user.getInactivatedAt()); - usersResponse.add(userResponse); + Optional currentUserOpt = findByEmail(currentUserEmail.get()); + if (currentUserOpt.isEmpty()) { + log.error("Unable to find user by email with value: {}", currentUserEmail.get()); + throw new UserForbiddenException(); } - log.info("{} Users found!", usersResponse.size()); + UserEntity currentUser = currentUserOpt.get(); + if (!currentUser.getAdmin()) { + log.warn("User {} not allowed to list users.", currentUser.getId()); + throw new UserForbiddenException(); + } + + log.info("Getting all users to user {}", currentUser.getId()); + + List users = userRepository.findAll(); + List usersResponse = new ArrayList<>(users.size()); + users.forEach((u) -> usersResponse.add(UserResponse.fromEntity(u))); + log.info("{} user(s) found!", usersResponse.size()); return usersResponse; } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index af7f638..8121102 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -44,7 +44,6 @@ public class TaskService { */ public List getAllTasks() { UserEntity user = getCurrentUser(); - log.info("Get all tasks to user {}", user.getId()); List tasks = taskRepository.findAllByUser_id(user.getId()); @@ -177,7 +176,7 @@ public class TaskService { * Search for tasks in the database given a search term. * * @param searchTerm The term to be used for the search. - * @return {@link List} of {@link TaskResponse} with ound records or an empty list. + * @return {@link List} of {@link TaskResponse} with found records or an empty list. */ public List searchTasks(String searchTerm) { UserEntity user = getCurrentUser(); diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java new file mode 100644 index 0000000..cc049d5 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/NoteControllerTest.java @@ -0,0 +1,315 @@ +package br.com.tasknoteapp.server.controller; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import br.com.tasknoteapp.server.entity.NoteEntity; +import br.com.tasknoteapp.server.exception.NoteNotFoundException; +import br.com.tasknoteapp.server.request.NotePatchRequest; +import br.com.tasknoteapp.server.request.NoteRequest; +import br.com.tasknoteapp.server.response.NoteResponse; +import br.com.tasknoteapp.server.response.NoteUrlResponse; +import br.com.tasknoteapp.server.service.NoteService; +import java.util.List; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +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.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class NoteControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private NoteService noteService; + + @Test + @DisplayName("Get all notes with some notes found should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getAllNotes_notesFound_shouldSucceed() throws Exception { + NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com"); + NoteResponse note = new NoteResponse(111L, "title", "description", List.of(noteUrl)); + + when(noteService.getAllNotes()).thenReturn(List.of(note)); + + mockMvc + .perform( + get("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(note.id())) + .andExpect(jsonPath("$[0].title").value(note.title())) + .andExpect(jsonPath("$[0].description").value(note.description())) + .andExpect(jsonPath("$[0].urls[0].id").value(noteUrl.id())) + .andExpect(jsonPath("$[0].urls[0].url").value(noteUrl.url())) + .andReturn(); + } + + @Test + @DisplayName("Get all notes without notes found should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getAllNotes_noNotesFound_shouldSucceed() throws Exception { + when(noteService.getAllNotes()).thenReturn(List.of()); + + mockMvc + .perform( + get("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Get all notes with 403 forbidden request should fail") + void getAllNotes_forbidden_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Patch a note via patch request happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void patchNote_happyPath_shouldSucceed() throws Exception { + Long noteId = 123L; + NotePatchRequest patchRequest = new NotePatchRequest("New title", "New description", List.of()); + + NoteResponse response = + new NoteResponse(noteId, patchRequest.title(), patchRequest.description(), List.of()); + + when(noteService.patchNote(noteId, patchRequest)).thenReturn(response); + + final String payloadJson = + """ + { + "title": "New title", + "description": "New description", + "urls": [] + } + """; + + mockMvc + .perform( + patch("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(response.id())) + .andExpect(jsonPath("$.title").value(response.title())) + .andExpect(jsonPath("$.description").value(response.description())) + .andExpect(jsonPath("$.urls", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Patch a note via patch request with not found id should fail") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void patchNote_notFound_shouldFail() throws Exception { + Long noteId = 123L; + NotePatchRequest patchRequest = new NotePatchRequest("New title", "New description", List.of()); + + when(noteService.patchNote(noteId, patchRequest)).thenThrow(new NoteNotFoundException()); + + final String payloadJson = + """ + { + "title": "New title", + "description": "New description", + "urls": [] + } + """; + + mockMvc + .perform( + patch("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isNotFound()) + .andReturn(); + } + + @Test + @DisplayName("Patch a note via patch request with 403 forbidden exception should fail") + void patchNote_forbidden_shouldFail() throws Exception { + Long noteId = 123L; + + final String payloadJson = + """ + { + "title": "New title", + "description": "New description", + "urls": [] + } + """; + + mockMvc + .perform( + patch("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Post create note happy path should succeed and return 201") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void postNotes_happyPath_shoundSucceed() throws Exception { + NoteRequest request = new NoteRequest("Title", "Description", List.of()); + + NoteEntity entity = new NoteEntity(); + entity.setId(1L); + entity.setTitle(request.title()); + entity.setDescription(request.description()); + + when(noteService.createNote(request)).thenReturn(entity); + + final String payloadJson = + """ + { + "title": "Title", + "description": "Description", + "urls": [] + } + """; + + mockMvc + .perform( + post("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(entity.getId())) + .andExpect(jsonPath("$.title").value(entity.getTitle())) + .andExpect(jsonPath("$.description").value(entity.getDescription())) + .andExpect(jsonPath("$.urls", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Post create note with missing information should fail with 400 bad request") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void postNotes_missingInformation_shouldFail() throws Exception { + final String payloadJson = + """ + { + "description": "Description" + } + """; + + mockMvc + .perform( + post("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + @Test + @DisplayName("Post create note with 403 forbidden request should fail") + void postNotes_forbidden_shouldFail() throws Exception { + final String payloadJson = + """ + { + "description": "Forbidden" + } + """; + + mockMvc + .perform( + post("/rest/notes") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Delete note request happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void deleteNote_happyPath_shouldSucceed() throws Exception { + final Long noteId = 453L; + + doNothing().when(noteService).deleteNote(noteId); + + mockMvc + .perform( + delete("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andReturn(); + } + + @Test + @DisplayName("Delete note with 403 request forbidden should fail") + void deleteNote_forbidden_shouldFail() throws Exception { + final Long noteId = 453L; + + mockMvc + .perform( + delete("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Delete note with 404 request not found should fail") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void deleteNote_notFound_shouldFail() throws Exception { + final Long noteId = 453L; + + doThrow(new NoteNotFoundException()).when(noteService).deleteNote(noteId); + + mockMvc + .perform( + delete("/rest/notes/{id}", noteId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java new file mode 100644 index 0000000..94295b4 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/TaskControllerTest.java @@ -0,0 +1,337 @@ +package br.com.tasknoteapp.server.controller; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import br.com.tasknoteapp.server.entity.TaskEntity; +import br.com.tasknoteapp.server.exception.TaskNotFoundException; +import br.com.tasknoteapp.server.request.TaskPatchRequest; +import br.com.tasknoteapp.server.request.TaskRequest; +import br.com.tasknoteapp.server.response.TaskResponse; +import br.com.tasknoteapp.server.response.TaskUrlResponse; +import br.com.tasknoteapp.server.service.TaskService; +import java.time.LocalDateTime; +import java.util.List; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +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.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class TaskControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private TaskService taskService; + + @Test + @DisplayName("Get all tasks with some tasks found should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getAllTasks_tasksFound_shouldSucceed() throws Exception { + TaskUrlResponse taskUrl = new TaskUrlResponse(1L, "http://test.com"); + TaskResponse taskResponse = + new TaskResponse(1L, "Desc", false, true, null, null, "Moments ago", List.of(taskUrl)); + when(taskService.getAllTasks()).thenReturn(List.of(taskResponse)); + + mockMvc + .perform( + get("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(taskResponse.id())) + .andExpect(jsonPath("$[0].description").value(taskResponse.description())) + .andExpect(jsonPath("$[0].done", Matchers.is(false))) + .andExpect(jsonPath("$[0].highPriority", Matchers.is(true))) + .andExpect(jsonPath("$[0].dueDate", Matchers.nullValue())) + .andExpect(jsonPath("$[0].dueDateFmt", Matchers.nullValue())) + .andExpect(jsonPath("$[0].lastUpdate").value("Moments ago")) + .andExpect(jsonPath("$[0].urls[0].id").value(taskUrl.id())) + .andExpect(jsonPath("$[0].urls[0].url").value(taskUrl.url())) + .andReturn(); + } + + @Test + @DisplayName("Get all tasks with no tasks found should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getAllTasks_noTasksFound_shouldSucceed() throws Exception { + when(taskService.getAllTasks()).thenReturn(List.of()); + + mockMvc + .perform( + get("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Get all tasks with 403 forbidden request should fail") + void getAllTasks_forbidden_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Patch a task via patch request happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void patchTask_happyPath_shouldSucceed() throws Exception { + Long taskId = 111L; + TaskPatchRequest patchRequest = + new TaskPatchRequest("Description patched", false, List.of(), null, true); + + TaskResponse taskResponse = + new TaskResponse( + taskId, "Description patched", false, true, null, null, "Moments ago", List.of()); + when(taskService.patchTask(taskId, patchRequest)).thenReturn(taskResponse); + + final String payloadJson = + """ + { + "description": "Description patched", + "done": false, + "urls": [], + "highPriority": true + } + """; + + mockMvc + .perform( + patch("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(taskId)) + .andExpect(jsonPath("$.description").value(taskResponse.description())) + .andExpect(jsonPath("$.done").value(taskResponse.done())) + .andExpect(jsonPath("$.highPriority").value(taskResponse.highPriority())) + .andExpect(jsonPath("$.dueDate", Matchers.nullValue())) + .andExpect(jsonPath("$.dueDateFmt", Matchers.nullValue())) + .andExpect(jsonPath("$.lastUpdate").value("Moments ago")) + .andExpect(jsonPath("$.urls", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Patch a task via patch request with not found id should fail") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void patchTask_notFound_shouldFail() throws Exception { + Long taskId = 111L; + TaskPatchRequest patchRequest = + new TaskPatchRequest("Description patched", false, List.of(), null, true); + + when(taskService.patchTask(taskId, patchRequest)).thenThrow(new TaskNotFoundException()); + + final String payloadJson = + """ + { + "description": "Description patched", + "done": false, + "urls": [], + "highPriority": true + } + """; + + mockMvc + .perform( + patch("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isNotFound()) + .andReturn(); + } + + @Test + @DisplayName("Patch a task via patch request with 403 forbidden exception") + void patchTask_forbidden_shouldFail() throws Exception { + Long taskId = 111L; + + final String payloadJson = + """ + { + "description": "Description patched", + "done": false, + "urls": [], + "highPriority": true + } + """; + + mockMvc + .perform( + patch("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Post create task happy path should succeed and return 201") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void postTasks_happyPath_shouldSucceed() throws Exception { + TaskRequest request = new TaskRequest("Test task", List.of(), null, true); + + TaskEntity entity = new TaskEntity(); + entity.setId(222L); + entity.setDescription(request.description()); + entity.setDone(false); + entity.setUrls(List.of()); + entity.setLastUpdate(LocalDateTime.now()); + entity.setDueDate(null); + entity.setHighPriority(request.highPriority()); + when(taskService.createTask(request)).thenReturn(entity); + + final String payloadJson = + """ + { + "description": "Test task", + "done": false, + "urls": [], + "highPriority": true + } + """; + + mockMvc + .perform( + post("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(entity.getId())) + .andExpect(jsonPath("$.description").value(request.description())) + .andExpect(jsonPath("$.done", Matchers.is(false))) + .andExpect(jsonPath("$.highPriority", Matchers.is(request.highPriority()))) + .andExpect(jsonPath("$.dueDate", Matchers.nullValue())) + .andExpect(jsonPath("$.dueDateFmt", Matchers.nullValue())) + .andExpect(jsonPath("$.lastUpdate").value("Moments ago")) + .andExpect(jsonPath("$.urls", Matchers.empty())) + .andReturn(); + } + + @Test + @DisplayName("Post create task with missing information should fail with 400 bad request") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void postTasks_missingInformation_shouldFail() throws Exception { + final String payloadJson = + """ + { + "description": "" + } + """; + + mockMvc + .perform( + post("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isBadRequest()) + .andReturn(); + } + + @Test + @DisplayName("Post create task with 403 forbidden request should fail") + void postTasks_forbidden_shouldFail() throws Exception { + final String payloadJson = + """ + { + "description": "Forbidden" + } + """; + + mockMvc + .perform( + post("/rest/tasks") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(payloadJson)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Delete task request happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void deleteTask_happyPath_shouldSucceed() throws Exception { + final Long taskId = 333L; + + doNothing().when(taskService).deleteTask(taskId); + + mockMvc + .perform( + delete("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andReturn(); + } + + @Test + @DisplayName("Delete task with 403 request forbidden should fail") + void deleteTask_forbidden_shouldFail() throws Exception { + final Long taskId = 533L; + + mockMvc + .perform( + delete("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Delete task with 404 request not found should fail") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void deleteTask_notFound_shouldFail() throws Exception { + final Long taskId = 433L; + + doThrow(new TaskNotFoundException()).when(taskService).deleteTask(taskId); + + mockMvc + .perform( + delete("/rest/tasks/{id}", taskId) + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java new file mode 100644 index 0000000..469a166 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java @@ -0,0 +1,62 @@ +package br.com.tasknoteapp.server.controller; + +import static org.mockito.Mockito.when; +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import br.com.tasknoteapp.server.response.UserResponse; +import br.com.tasknoteapp.server.service.AuthService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +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.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockBean private AuthService authService; + + @Test + @DisplayName("Get all users happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void getAllUsers_happyPath_shouldSucceed() throws Exception { + UserResponse userResponse = new UserResponse(1L, "email@test.com", false, null, null); + when(authService.getAllUsers()).thenReturn(List.of(userResponse)); + + mockMvc + .perform( + get("/rest/users") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].userId").value(userResponse.userId())) + .andExpect(jsonPath("$[0].email").value(userResponse.email())) + .andExpect(jsonPath("$[0].admin").value(userResponse.admin())) + .andReturn(); + } + + @Test + @DisplayName("Get all users with 403 forbidden request should fail") + void getAllUsers_forbidden_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/users") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java index 1585332..a8f73e8 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java @@ -3,10 +3,12 @@ package br.com.tasknoteapp.server.controller; import static org.mockito.Mockito.when; 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.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import br.com.tasknoteapp.server.response.JwtAuthenticationResponse; +import br.com.tasknoteapp.server.response.UserResponse; import br.com.tasknoteapp.server.service.UserSessionService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -43,4 +45,47 @@ class UserSessionControllerTest { .andExpect(jsonPath("$.token").value(authResponse.token())) .andReturn(); } + + @Test + @DisplayName("Refresh with 403 forbidden request should fail") + void refresh_forbidden_shouldFail() throws Exception { + mockMvc + .perform( + get("/rest/user-sessions/refresh") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } + + @Test + @DisplayName("Delete account happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void deteleAccount_happyPath_shouldSucceed() throws Exception { + UserResponse response = new UserResponse(1L, "email@test.com", false, null, null); + when(userSessionService.deleteCurrentUserAccount()).thenReturn(response); + + mockMvc + .perform( + post("/rest/user-sessions/delete-account") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + } + + @Test + @DisplayName("Delete account with 403 forbidden request should fail") + void deteleAccount_forbidden_shouldFail() throws Exception { + mockMvc + .perform( + post("/rest/user-sessions/delete-account") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()) + .andReturn(); + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java new file mode 100644 index 0000000..aec41ce --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -0,0 +1,359 @@ +package br.com.tasknoteapp.server.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import br.com.tasknoteapp.server.entity.UserEntity; +import br.com.tasknoteapp.server.entity.UserPwdLimitEntity; +import br.com.tasknoteapp.server.exception.BadPasswordException; +import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException; +import br.com.tasknoteapp.server.exception.UserAlreadyExistsException; +import br.com.tasknoteapp.server.exception.UserForbiddenException; +import br.com.tasknoteapp.server.exception.UserNotFoundException; +import br.com.tasknoteapp.server.exception.WrongUserOrPasswordException; +import br.com.tasknoteapp.server.repository.UserPwdLimitRepository; +import br.com.tasknoteapp.server.repository.UserRepository; +import br.com.tasknoteapp.server.request.LoginRequest; +import br.com.tasknoteapp.server.response.UserResponse; +import br.com.tasknoteapp.server.util.AuthUtil; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +class AuthServiceTest { + + @Mock private UserRepository userRepository; + + @Mock private PasswordEncoder passwordEncoder; + + @Mock private JwtService jwtService; + + @Mock private AuthenticationManager authenticationManager; + + @Mock private AuthUtil authUtil; + + @Mock private UserPwdLimitRepository userPwdLimitRepository; + + private AuthService authService; + + @BeforeEach + void setup() { + authService = + new AuthService( + userRepository, + passwordEncoder, + jwtService, + authenticationManager, + authUtil, + userPwdLimitRepository); + } + + @Test + @DisplayName("SignUp new user happy path should succeed") + void signUpNewUser_happyPath_shouldSucceed() { + LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!"); + + when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty()); + when(authUtil.validatePassword(request.password())).thenReturn(Optional.empty()); + + UserEntity entity = new UserEntity(); + entity.setId(3L); + entity.setEmail(request.email()); + + when(userRepository.save(any())).thenReturn(entity); + when(jwtService.generateToken(request.email())).thenReturn("a1b2c3"); + + String token = authService.signUpNewUser(request); + + Assertions.assertNotNull(token); + Assertions.assertFalse(token.isBlank()); + Assertions.assertEquals("a1b2c3", token); + } + + @Test + @DisplayName("SignUp new user with existing email should fail") + void signUpNewUser_emailExists_shouldFail() { + LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!"); + + UserEntity existing = new UserEntity(); + when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing)); + + Assertions.assertThrows( + UserAlreadyExistsException.class, + () -> { + authService.signUpNewUser(request); + }); + } + + @Test + @DisplayName("SignUp new user with bad password should fail") + void signUpNewUser_badPassword_shouldFail() { + LoginRequest request = new LoginRequest("email@domain.com", "123456"); + + when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty()); + when(authUtil.validatePassword(request.password())).thenReturn(Optional.of("Bad password")); + + Assertions.assertThrows( + BadPasswordException.class, + () -> { + authService.signUpNewUser(request); + }); + } + + @Test + @DisplayName("Find user by email happy path should succeed") + void findByEmail_happyPath_shouldSucceed() { + String email = "user@email.com"; + + UserEntity existing = new UserEntity(); + existing.setEmail(email); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + Optional userOp = authService.findByEmail(email); + + Assertions.assertTrue(userOp.isPresent()); + Assertions.assertEquals(email, userOp.get().getEmail()); + } + + @Test + @DisplayName("Find user by email not found should succeed") + void findByEmail_notFound_shouldSucceed() { + String email = "user@email.com"; + + when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); + + Optional userOp = authService.findByEmail(email); + + Assertions.assertTrue(userOp.isEmpty()); + } + + @Test + @DisplayName("Load user by username happy path should succeed") + void loadUserByUsername_happyPath_shouldSucceed() { + String email = "user@domain.com"; + + UserEntity existing = new UserEntity(); + existing.setEmail(email); + existing.setPassword(email + "123"); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + User user = authService.loadUserByUsername(email); + + Assertions.assertNotNull(user); + Assertions.assertEquals(email, user.getUsername()); + } + + @Test + @DisplayName("Load user by username not found should fail") + void loadUserByUsername_notFound_shouldFail() { + String email = "user@domain.com"; + + when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); + + Assertions.assertThrows( + UserNotFoundException.class, + () -> { + authService.loadUserByUsername(email); + }); + } + + @Test + @DisplayName("SignIn user happy path should succeed") + void signInUser_happyPath_shouldSucceed() { + LoginRequest request = new LoginRequest("email@domain.com", "123456"); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(request.email()); + when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing)); + + Sort sort = Sort.by(Direction.DESC, "whenHappened"); + when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of()); + when(authenticationManager.authenticate(any())).thenReturn(null); + when(jwtService.generateToken(request.email())).thenReturn("a1b2c3"); + + doNothing().when(userPwdLimitRepository).deleteAllForUser(existing.getId()); + + String token = authService.signInUser(request); + + Assertions.assertNotNull(token); + Assertions.assertEquals("a1b2c3", token); + } + + @Test + @DisplayName("SignIn wrong user or password should fail") + void signInUser_wrongUserOrPassword_shouldFail() { + LoginRequest request = new LoginRequest("email@domain.com", "123456"); + + when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty()); + + Assertions.assertThrows( + WrongUserOrPasswordException.class, + () -> { + authService.signInUser(request); + }); + } + + @Test + @DisplayName("SignIn max login attempt should fail") + void signInUser_maxLoginAttempt_shouldFail() { + LoginRequest request = new LoginRequest("email@domain.com", "123456"); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing)); + + UserPwdLimitEntity limit1 = new UserPwdLimitEntity(); + limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1)); + UserPwdLimitEntity limit2 = new UserPwdLimitEntity(); + UserPwdLimitEntity limit3 = new UserPwdLimitEntity(); + Sort sort = Sort.by(Direction.DESC, "whenHappened"); + when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)) + .thenReturn(List.of(limit1, limit2, limit3)); + + Assertions.assertThrows( + MaxLoginLimitAttemptException.class, + () -> { + authService.signInUser(request); + }); + } + + @Test + @DisplayName("SignIn bad credentials should fail") + void signInUser_badCredentials_shouldFail() { + LoginRequest request = new LoginRequest("email@domain.com", "123456"); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing)); + + Sort sort = Sort.by(Direction.DESC, "whenHappened"); + when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of()); + when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("Wrong")); + + String token = authService.signInUser(request); + + Assertions.assertNull(token); + verify(userPwdLimitRepository, times(1)).save(any()); + } + + @Test + @DisplayName("Get all users happy path should succeed") + void getAllUsers_happyPath_shouldSucceed() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(email); + existing.setAdmin(true); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + UserEntity user1 = new UserEntity(); + user1.setEmail("user1@domain.com"); + UserEntity user2 = new UserEntity(); + user2.setEmail("user2@domain.com"); + when(userRepository.findAll()).thenReturn(List.of(user1, user2)); + + List users = authService.getAllUsers(); + + Assertions.assertNotNull(users); + Assertions.assertFalse(users.isEmpty()); + Assertions.assertEquals(user1.getEmail(), users.get(0).email()); + Assertions.assertEquals(user2.getEmail(), users.get(1).email()); + } + + @Test + @DisplayName("Get all users no current user should fail") + void getAllUsers_noCurrentUser_shouldFail() { + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.empty()); + + Assertions.assertThrows(UserForbiddenException.class, () -> { + authService.getAllUsers(); + }); + } + + @Test + @DisplayName("Get all users user not found should fail") + void getAllUsers_userNotFound_shouldFail() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); + + Assertions.assertThrows(UserForbiddenException.class, () -> { + authService.getAllUsers(); + }); + } + + @Test + @DisplayName("Get all users user not admin should fail") + void getAllUsers_userNotAdmin_shouldFail() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(email); + existing.setAdmin(false); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + Assertions.assertThrows(UserForbiddenException.class, () -> { + authService.getAllUsers(); + }); + } + + @Test + @DisplayName("Refresh current user token happy path should succeed") + void refreshCurrentUserToken_happyPath_shouldSucceed() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(email); + existing.setAdmin(false); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + when(jwtService.generateToken(email)).thenReturn("a1b2c3"); + + String token = authService.refreshCurrentUserToken(); + + Assertions.assertNotNull(token); + Assertions.assertEquals("a1b2c3", token); + } + + @Test + @DisplayName("Delete user account happy path should succeed") + void deleteUserAccount_happyPath_shouldSucceed() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(email); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + doNothing().when(userPwdLimitRepository).deleteAllForUser(existing.getId()); + doNothing().when(userRepository).delete(existing); + + UserResponse response = authService.deleteUserAccount(); + + Assertions.assertNotNull(response); + } +} diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java new file mode 100644 index 0000000..aeab4a4 --- /dev/null +++ b/server/src/test/java/br/com/tasknoteapp/server/service/HomeServiceTest.java @@ -0,0 +1,65 @@ +package br.com.tasknoteapp.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import br.com.tasknoteapp.server.response.NoteResponse; +import br.com.tasknoteapp.server.response.SearchResponse; +import br.com.tasknoteapp.server.response.SummaryResponse; +import br.com.tasknoteapp.server.response.TaskResponse; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HomeServiceTest { + + @Mock private TaskService taskService; + + @Mock private NoteService noteService; + + private HomeService homeService; + + private List tasks; + private List notes; + + @BeforeEach + void setUp() { + homeService = new HomeService(taskService, noteService); + + TaskResponse task1 = new TaskResponse(2L, "Task 1", false, false, null, null, null, List.of()); + TaskResponse task2 = new TaskResponse(3L, "Task 2", false, false, null, null, null, List.of()); + tasks = List.of(task1, task2); + + NoteResponse note1 = new NoteResponse(453L, "Note 1", "desc", List.of()); + NoteResponse note2 = new NoteResponse(455L, "Note 2", "desc", List.of()); + notes = List.of(note1, note2); + } + + @Test + void getSummary_shouldReturnSummaryResponse() { + when(taskService.getAllTasks()).thenReturn(tasks); + when(noteService.getAllNotes()).thenReturn(notes); + + SummaryResponse summary = homeService.getSummary(); + + assertEquals(2, summary.pendingTaskCount()); + assertEquals(0, summary.doneTaskCount()); + assertEquals(2, summary.notesCount()); + } + + @Test + void search_shouldReturnSearchResponse() { + String term = "note"; + when(taskService.searchTasks(term)).thenReturn(List.of()); + when(noteService.searchNotes(term)).thenReturn(notes); + + SearchResponse searchResponse = homeService.search(term); + + assertEquals(0, searchResponse.tasks().size()); + assertEquals(2, searchResponse.notes().size()); + } +}