diff --git a/contents/todoListAPI/seungseop/todolist/build.gradle b/contents/todoListAPI/seungseop/todolist/build.gradle index cd5cc39..9377970 100644 --- a/contents/todoListAPI/seungseop/todolist/build.gradle +++ b/contents/todoListAPI/seungseop/todolist/build.gradle @@ -55,6 +55,10 @@ dependencies { "jakarta.annotation:jakarta.annotation-api", "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta") + // spring security 설정 + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.12.3' // jwt token + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/TodolistApplication.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/TodolistApplication.java index bc6710e..880d59b 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/TodolistApplication.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/TodolistApplication.java @@ -3,6 +3,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.config.annotation.authentication.configuration.EnableGlobalAuthentication; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @EnableScheduling @SpringBootApplication diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/common/ExampleData.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/common/ExampleData.java index 055296c..9a086b3 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/common/ExampleData.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/common/ExampleData.java @@ -69,4 +69,16 @@ public interface ExampleData { } """; + String BAD_CREDENTIALS_DATA = """ + { + "timestamp": "2024-05-27T21:48:53.1796943", + "status": 401, + "error": "UNAUTHORIZED", + "code": "BAD_CREDENTIALS", + "message": [ + "이메일 또는 비밀번호가 맞지 않습니다." + ] + } + """; + } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/config/SwaggerConfig.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/config/SwaggerConfig.java index c229f0d..70bf4ef 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/config/SwaggerConfig.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/config/SwaggerConfig.java @@ -3,6 +3,8 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,8 +13,16 @@ public class SwaggerConfig { @Bean public OpenAPI openAPI() { + SecurityScheme apiKey = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("Auth-Token"); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList("Access Token"); return new OpenAPI() - .components(new Components()) + .components(new Components().addSecuritySchemes("Access Token", apiKey)) + .addSecurityItem(securityRequirement) .info(apiInfo()); } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/FolderController.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/FolderController.java index 5daa447..8014f6c 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/FolderController.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/FolderController.java @@ -5,6 +5,7 @@ import com.serverstudy.todolist.dto.request.FolderReq.FolderPost; import com.serverstudy.todolist.dto.response.FolderRes; import com.serverstudy.todolist.exception.ErrorResponse; +import com.serverstudy.todolist.security.SecurityUser; import com.serverstudy.todolist.service.FolderService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +19,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -26,16 +28,14 @@ @Tag(name = "Folder", description = "Folder API 입니다.") @Validated @RestController -@RequestMapping("/api/folder") +@RequestMapping("/api/folders") @CrossOrigin(origins = "*") @RequiredArgsConstructor public class FolderController implements ExampleData { private final FolderService folderService; - @Operation(summary = "폴더 생성", description = "새로운 폴더를 생성합니다.", parameters = { - @Parameter(name = "userId", description = "유저 id", example = "1") - }, responses = { + @Operation(summary = "폴더 생성", description = "새로운 폴더를 생성합니다.", responses = { @ApiResponse(responseCode = "201", description = "폴더 생성 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), @@ -48,25 +48,23 @@ public class FolderController implements ExampleData { })) }) @PostMapping - public ResponseEntity postFolder(@Valid @RequestBody FolderPost folderPost, @NotNull Long userId) { // @RequestParam만 붙여도 null 값 입력 시 예외 발생 + public ResponseEntity postFolder(@Valid @RequestBody FolderPost folderPost, @AuthenticationPrincipal SecurityUser user) { // @RequestParam만 붙여도 null 값 입력 시 예외 발생 - Long folderId = folderService.create(folderPost, userId); + Long folderId = folderService.create(folderPost, user.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(folderId); } - @Operation(summary = "폴더 목록 조회", description = "폴더 목록을 가져옵니다.", parameters = { - @Parameter(name = "userId", description = "유저 id", example = "1") - }, responses = { + @Operation(summary = "폴더 목록 조회", description = "폴더 목록을 가져옵니다.", responses = { @ApiResponse(responseCode = "200", description = "폴더 목록 조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), })) }) @GetMapping - public ResponseEntity> getFoldersByUser(@NotNull Long userId) { + public ResponseEntity> getFoldersByUser(@AuthenticationPrincipal SecurityUser user) { - List responseList = folderService.getAllWithTodoCount(userId); + List responseList = folderService.getAllWithTodoCount(user.getId()); return ResponseEntity.ok(responseList); } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/TodoController.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/TodoController.java index 590c47c..fbdcbf6 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/TodoController.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/TodoController.java @@ -5,6 +5,7 @@ import com.serverstudy.todolist.dto.request.TodoReq.TodoPost; import com.serverstudy.todolist.dto.response.TodoRes; import com.serverstudy.todolist.exception.ErrorResponse; +import com.serverstudy.todolist.security.SecurityUser; import com.serverstudy.todolist.service.TodoService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +19,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -29,16 +31,14 @@ @Tag(name = "Todo", description = "Todo API 입니다.") @Validated @RestController -@RequestMapping("/api/todo") +@RequestMapping("/api/todos") @CrossOrigin(origins = "*") @RequiredArgsConstructor public class TodoController implements ExampleData { private final TodoService todoService; - @Operation(summary = "투두 생성", description = "새로운 투두를 생성합니다.", parameters = { - @Parameter(name = "userId", description = "유저 id", example = "1") - }, responses = { + @Operation(summary = "투두 생성", description = "새로운 투두를 생성합니다.", responses = { @ApiResponse(responseCode = "201", description = "투두 생성 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), @@ -49,26 +49,24 @@ public class TodoController implements ExampleData { })) }) @PostMapping - public ResponseEntity postTodo(@Valid @RequestBody TodoPost todoPost, @NotNull Long userId) { + public ResponseEntity postTodo(@Valid @RequestBody TodoPost todoPost, @AuthenticationPrincipal SecurityUser user) { - Long todoId = todoService.create(todoPost, userId); + Long todoId = todoService.create(todoPost, user.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(todoId); } - @Operation(summary = "투두 목록 조회", description = "조건에 맞는 투두 목록을 가져옵니다.", parameters = { - @Parameter(name = "userId", description = "유저 id", example = "1") - }, responses = { + @Operation(summary = "투두 목록 조회", description = "조건에 맞는 투두 목록을 가져옵니다.", responses = { @ApiResponse(responseCode = "200", description = "투두 목록 조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), })) }) @GetMapping - public ResponseEntity> getTodosByRequirements(@Valid @ModelAttribute TodoGet todoGet, @NotNull Long userId) { + public ResponseEntity> getTodosByRequirements(@Valid @ModelAttribute TodoGet todoGet, @AuthenticationPrincipal SecurityUser user) { - List responseList = todoService.findAllByConditions(todoGet, userId); + List responseList = todoService.findAllByConditions(todoGet, user.getId()); return ResponseEntity.ok(responseList); } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/UserController.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/UserController.java index 7bc2c31..06f5bad 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/UserController.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/controller/UserController.java @@ -1,10 +1,17 @@ package com.serverstudy.todolist.controller; import com.serverstudy.todolist.common.ExampleData; +import com.serverstudy.todolist.domain.User; +import com.serverstudy.todolist.domain.enums.Role; +import com.serverstudy.todolist.dto.request.UserReq; +import com.serverstudy.todolist.dto.request.UserReq.UserLoginPost; import com.serverstudy.todolist.dto.request.UserReq.UserPatch; import com.serverstudy.todolist.dto.request.UserReq.UserPost; +import com.serverstudy.todolist.dto.response.JwtRes; import com.serverstudy.todolist.dto.response.UserRes; import com.serverstudy.todolist.exception.ErrorResponse; +import com.serverstudy.todolist.repository.UserRepository; +import com.serverstudy.todolist.security.SecurityUser; import com.serverstudy.todolist.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -20,13 +27,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.Optional; + @Tag(name = "User", description = "User API 입니다.") @Validated @RestController -@RequestMapping("/api/user") +@RequestMapping("/api/users") @CrossOrigin(origins = "*") @RequiredArgsConstructor public class UserController implements ExampleData { @@ -43,11 +55,28 @@ public class UserController implements ExampleData { })) }) @PostMapping - public ResponseEntity postUser(@Valid @RequestBody UserPost userPost) { + public ResponseEntity postUser(@Valid @RequestBody UserPost userPost) { + + JwtRes token = userService.join(userPost); + + return ResponseEntity.status(HttpStatus.CREATED).body(token); + } + + @Operation(summary = "유저 로그인", description = "해당 유저로 로그인합니다.", responses = { + @ApiResponse(responseCode = "200", description = "유저 로그인 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), + })), + @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "BAD_CREDENTIALS", value = BAD_CREDENTIALS_DATA), + })) + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody UserLoginPost loginDto) { - Long userId = userService.join(userPost); + JwtRes token = userService.login(loginDto); - return ResponseEntity.status(HttpStatus.CREATED).body(userId); + return ResponseEntity.ok(token); } @Operation(summary = "이메일 중복 검사", description = "이메일의 중복 여부를 검사합니다.", parameters = { @@ -73,9 +102,7 @@ public ResponseEntity checkUserEmail( return ResponseEntity.ok(email); } - @Operation(summary = "유저 조회", description = "해당 유저의 정보를 조회합니다.", parameters = { - @Parameter(name = "userId", description = "유저 Id", example = "1") - }, responses = { + @Operation(summary = "유저 조회", description = "해당 유저의 정보를 조회합니다.", responses = { @ApiResponse(responseCode = "200", description = "유저 조회 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), @@ -85,16 +112,14 @@ public ResponseEntity checkUserEmail( })) }) @GetMapping - public ResponseEntity getUser(@NotNull Long userId) { + public ResponseEntity getUser(@AuthenticationPrincipal SecurityUser user) { - UserRes response = userService.get(userId); + UserRes response = userService.get(user.getId()); return ResponseEntity.ok(response); } - @Operation(summary = "유저 정보 수정", description = "해당 유저의 정보를 수정합니다.", parameters = { - @Parameter(name = "userId", description = "유저 Id", example = "1") - }, responses = { + @Operation(summary = "유저 정보 수정", description = "해당 유저의 정보를 수정합니다.", responses = { @ApiResponse(responseCode = "200", description = "유저 정보 수정 성공", useReturnTypeSchema = true), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), @@ -104,28 +129,49 @@ public ResponseEntity getUser(@NotNull Long userId) { })) }) @PatchMapping - public ResponseEntity patchUser(@Valid @RequestBody UserPatch UserPatch, @NotNull Long userId) { + public ResponseEntity patchUser(@Valid @RequestBody UserPatch UserPatch, @AuthenticationPrincipal SecurityUser user) { - Long modifiedUserId = userService.modify(UserPatch, userId); + UserRes modifiedUserRes = userService.modify(UserPatch, user.getId()); - return ResponseEntity.ok(modifiedUserId); + return ResponseEntity.ok(modifiedUserRes); } - @Operation(summary = "유저 삭제", description = "해당 유저를 삭제합니다.", parameters = { - @Parameter(name = "userId", description = "유저 Id", example = "1") - }, responses = { + @Operation(summary = "유저 삭제", description = "해당 유저를 삭제합니다.", responses = { @ApiResponse(responseCode = "204", description = "유저 삭제 성공"), @ApiResponse(responseCode = "400", description = "잘못된 파라미터 입력", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "INVALID_PARAMETER", value = INVALID_PARAMETER_DATA), })), }) @DeleteMapping - public ResponseEntity deleteUser(@NotNull Long userId) { + public ResponseEntity deleteUser(@AuthenticationPrincipal SecurityUser user) { - userService.delete(userId); + userService.delete(user.getId()); return ResponseEntity.noContent().build(); } + @Operation(summary = "관리자 로그인", description = "관리자 계정으로 로그인합니다.", responses = { + @ApiResponse(responseCode = "200", description = "관리자 로그인 성공", useReturnTypeSchema = true), + }) + @PostMapping("/admin") + public ResponseEntity getAdmin() { + + JwtRes token = userService.getAdmin(); + + return ResponseEntity.ok(token); + } + + @Operation(summary = "모든 유저 정보 조회", description = "모든 유저 정보를 조회합니다. 관리자 계정으로 로그인 되어 있어야 합니다.", responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + }) + @Secured("ROLE_ADMIN") + @GetMapping("/admin") + public ResponseEntity> getAllUsers() { + + List userResList = userService.getAll(); + + return ResponseEntity.ok(userResList); + } + } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/domain/User.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/domain/User.java index 2849c93..5109e6f 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/domain/User.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/domain/User.java @@ -8,14 +8,15 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; import java.util.HashSet; import java.util.Set; +@Getter @Entity @Table(name = "user_tb") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter public class User { @Id @@ -54,5 +55,4 @@ public void modifyUser(UserPatch userPatch) { public void addRole(Role role) { this.roles.add(role); } - } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/request/UserReq.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/request/UserReq.java index 394580d..8d456c0 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/request/UserReq.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/request/UserReq.java @@ -36,15 +36,31 @@ class UserPost { message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.") private String nickname; - public User toEntity() { + public User toEntity(String encodedPassword) { return User.builder() .email(email) - .password(password) + .password(encodedPassword) .nickname(nickname) .build(); } } + @Schema(description = "유저 로그인 요청 DTO") + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + class UserLoginPost { + + @Schema(title = "이메일", description = "이메일 형식에 맞게 입력", + example = "example@gmail.com") + @NotBlank(message = "이메일은 공백으로 입력할 수 없습니다.") + private String email; + + @Schema(title = "비밀번호", description = "영문 대소문자, 숫자가 포함되며 공백이 없는 8~16자로 입력", + example = "examplePWD123") + @NotBlank(message = "비밀번호는 공백으로 입력할 수 없습니다.") + private String password; + } + @Schema(description = "유저 정보 수정 요청 DTO") @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -64,6 +80,4 @@ class UserPatch { message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.") private String nickname; } - - } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/response/JwtRes.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/response/JwtRes.java new file mode 100644 index 0000000..73386e7 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/dto/response/JwtRes.java @@ -0,0 +1,17 @@ +package com.serverstudy.todolist.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Schema(description = "토큰 응답 DTO") +@Getter +public class JwtRes { + @Schema(title = "Access Token", description = "Access Token", example = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJleGFtcGxlQGdtYWlsLmNvbSIsImlhdCI6MTcxNjgyNTQyOSwiZXhwIjoxNzE2ODI3MjI5fQ.qcvwPKY2LhvwR6OmQoTIsLGsjAFwLHdroe6aQ1q313xgL_A5X58bVMGc15_F0WVG") + private String accessToken; + + @Builder + private JwtRes(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/exception/ErrorCode.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/exception/ErrorCode.java index 3f2f2ad..b57d843 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/exception/ErrorCode.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/exception/ErrorCode.java @@ -13,6 +13,7 @@ public enum ErrorCode { INVALID_PARAMETER(BAD_REQUEST, "파라미터 값이 유효하지 않습니다."), /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */ + BAD_CREDENTIALS(UNAUTHORIZED, "이메일 또는 비밀번호가 맞지 않습니다."), /* 404 NOT_FOUND : Resource 를 찾을 수 없음 */ USER_NOT_FOUND(NOT_FOUND, "해당 유저 정보를 찾을 수 없습니다"), diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/repository/UserRepository.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/repository/UserRepository.java index 94b7289..772dbc1 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/repository/UserRepository.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/repository/UserRepository.java @@ -3,6 +3,9 @@ import com.serverstudy.todolist.domain.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); boolean existsByEmail(String email); } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtAuthenticationFilter.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6e928f6 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtAuthenticationFilter.java @@ -0,0 +1,39 @@ +package com.serverstudy.todolist.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { // GenericFilterBean와 달리 어느 서블릿 컨테이너에서나 요청 당 한 번의 실행을 보장 + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 헤더에서 JWT를 받아옴 + String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); + log.info("[로그] 헤더에서 JWT 토큰 추출 - token: {}", token); + + // 유효한 토큰인지 확인 + if (token != null && jwtTokenProvider.validateToken(token)) { + // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + // SecurityContext에 Authentication 객체를 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtTokenProvider.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtTokenProvider.java new file mode 100644 index 0000000..bc708c6 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/JwtTokenProvider.java @@ -0,0 +1,87 @@ +package com.serverstudy.todolist.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String SECRET_KEY; + + // 토큰 유효시간 30분 + private final long TOKEN_VALID_TIME = 30 * 60 * 1000L; + + private final UserDetailsService userDetailsService; + + // 객체 초기화, SECRET_KEY를 Base64로 인코딩 + @PostConstruct + protected void init() { + SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(this.SECRET_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } + + // JWT 토큰 생성 + public String createToken(String username) { + return Jwts.builder() + .subject(username) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + TOKEN_VALID_TIME)) // set Expire Time + .signWith(getSigningKey()) + .compact(); + } + + // JWT 토큰에서 인증 정보 조회 + public Authentication getAuthentication(String token) { + log.info("[로그] JWT 토큰에서 인증 정보 조회"); + UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token)); + log.info("[로그] username: {}, authorities: {}", userDetails.getUsername(), userDetails.getAuthorities()); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + // 토큰에서 회원 정보 추출 + public String getUsername(String token) { + return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload().getSubject(); + } + + // Request의 Header에서 token 값을 가져옵니다. "Auth-Token": "TOKEN 값" + public String resolveToken(HttpServletRequest request) { + return request.getHeader("Auth-Token"); + } + + // 토큰의 유효성 + 만료일자 확인 + public boolean validateToken(String jwtToken) { + try { + Jws claims = Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(jwtToken); + return !claims.getPayload().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } +} + diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityConfig.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityConfig.java new file mode 100644 index 0000000..e9cc08f --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityConfig.java @@ -0,0 +1,75 @@ +package com.serverstudy.todolist.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity // 시큐리티 활성화 -> 기본 스프링 필터체인에 등록 +@RequiredArgsConstructor +@EnableMethodSecurity(securedEnabled = true) // @Secured 어노테이션 활성화 +public class SecurityConfig { + + @Value("${security.permit-url}") + private String[] permitUrlArray; + + private final JwtTokenProvider jwtTokenProvider; + + // 비밀번호 암호화 + @Bean + public PasswordEncoder passwordEncoder() { + // bcrypt가 기본 인코더인 DelegatingPasswordEncoder의 구현을 반환하는 정적 메서드 createDelegatingPasswordEncoder() + // return new BCryptPasswordEncoder();와 동일 + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + // authenticationManager를 Bean 등록 + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + return httpSecurity + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> { + requests.requestMatchers(HttpMethod.POST, "api/users").permitAll(); // 회원가입 + requests.requestMatchers(HttpMethod.POST, "api/users/admin").permitAll(); // 관리자 로그인 + requests.requestMatchers("/api/users/login").permitAll(); // 로그인 + requests.requestMatchers("/api/**").authenticated();}) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음 + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() + { + return web -> web.ignoring() + .requestMatchers(permitUrlArray) + .requestMatchers(PathRequest.toH2Console()); + } + +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUser.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUser.java new file mode 100644 index 0000000..12c7f5e --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUser.java @@ -0,0 +1,26 @@ +package com.serverstudy.todolist.security; + +import com.serverstudy.todolist.domain.User; +import lombok.Getter; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.stream.Collectors; + + +@Getter +public class SecurityUser extends org.springframework.security.core.userdetails.User { + + private final User user; + private final Long id; + + public SecurityUser(User user) { + super(user.getEmail(), user.getPassword(), + user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getRole())) + .collect(Collectors.toSet())); + this.user = user; + this.id = user.getId(); + } + +} diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUserDetailsService.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUserDetailsService.java new file mode 100644 index 0000000..61c8211 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/security/SecurityUserDetailsService.java @@ -0,0 +1,29 @@ +package com.serverstudy.todolist.security; + +import com.serverstudy.todolist.domain.User; +import com.serverstudy.todolist.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SecurityUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + + log.info("[로그] SecurityUserDetailsService.loadUserByUsername() - username: {}", username); + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("[" + username + "] 사용자를 찾을 수 없습니다.")); + + return new SecurityUser(user); + } +} diff --git a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/service/UserService.java b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/service/UserService.java index 26a984d..62dd9b3 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/service/UserService.java +++ b/contents/todoListAPI/seungseop/todolist/src/main/java/com/serverstudy/todolist/service/UserService.java @@ -1,17 +1,25 @@ package com.serverstudy.todolist.service; import com.serverstudy.todolist.domain.User; +import com.serverstudy.todolist.domain.enums.Role; +import com.serverstudy.todolist.dto.request.UserReq.UserLoginPost; import com.serverstudy.todolist.dto.request.UserReq.UserPatch; import com.serverstudy.todolist.dto.request.UserReq.UserPost; +import com.serverstudy.todolist.dto.response.JwtRes; import com.serverstudy.todolist.dto.response.UserRes; import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; import com.serverstudy.todolist.repository.FolderRepository; import com.serverstudy.todolist.repository.TodoRepository; import com.serverstudy.todolist.repository.UserRepository; +import com.serverstudy.todolist.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + import static com.serverstudy.todolist.exception.ErrorCode.DUPLICATE_USER_EMAIL; import static com.serverstudy.todolist.exception.ErrorCode.USER_NOT_FOUND; @@ -23,15 +31,23 @@ public class UserService { private final UserRepository userRepository; private final TodoRepository todoRepository; private final FolderRepository folderRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; @Transactional - public long join(UserPost userPost) { + public JwtRes join(UserPost userPost) { checkEmailDuplicated(userPost.getEmail()); - User user = userPost.toEntity(); + String encodedPassword = passwordEncoder.encode(userPost.getPassword()); + + User user = userRepository.save( + userPost.toEntity(encodedPassword) + ); - return userRepository.save(user).getId(); + return JwtRes.builder() + .accessToken(jwtTokenProvider.createToken(user.getEmail())) + .build(); } public void checkEmailDuplicated(String email) { @@ -41,6 +57,21 @@ public void checkEmailDuplicated(String email) { } } + public JwtRes login (UserLoginPost userLoginPost) { + + // 이메일 불일치 + User user = userRepository.findByEmail(userLoginPost.getEmail()) + .orElseThrow(() -> new CustomException(ErrorCode.BAD_CREDENTIALS)); + // 비밀번호 불일치 + if (!passwordEncoder.matches(userLoginPost.getPassword(), user.getPassword())) { + throw new CustomException(ErrorCode.BAD_CREDENTIALS); + } + + return JwtRes.builder() + .accessToken(jwtTokenProvider.createToken(user.getEmail())) + .build(); + } + public UserRes get(Long userId) { User user = getUser(userId); @@ -53,13 +84,17 @@ public UserRes get(Long userId) { } @Transactional - public long modify(UserPatch userPatch, Long userId) { + public UserRes modify(UserPatch userPatch, Long userId) { User user = getUser(userId); user.modifyUser(userPatch); - return user.getId(); + return UserRes.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .build(); } @Transactional @@ -73,6 +108,32 @@ public void delete(Long userId) { userRepository.deleteById(userId); } + @Transactional + public JwtRes getAdmin() { + + User admin = userRepository.findByEmail("ADMIN").orElseGet(() -> { + User user = User.builder().email("ADMIN").nickname("ADMIN").password("ADMIN").build(); + user.addRole(Role.ADMIN); + return userRepository.save(user); + }); + + return JwtRes.builder() + .accessToken(jwtTokenProvider.createToken(admin.getEmail())) + .build(); + } + + public List getAll() { + + return userRepository.findAll().stream() + .filter(user -> !user.getEmail().equals("ADMIN")) // 관리자 제외 + .map(user -> UserRes.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .build() + ).toList(); + } + private User getUser(Long userId) { return userRepository.findById(userId).orElseThrow(() -> new CustomException(USER_NOT_FOUND)); } diff --git a/contents/todoListAPI/seungseop/todolist/src/main/resources/application.properties b/contents/todoListAPI/seungseop/todolist/src/main/resources/application.properties index 205a84a..66a3642 100644 --- a/contents/todoListAPI/seungseop/todolist/src/main/resources/application.properties +++ b/contents/todoListAPI/seungseop/todolist/src/main/resources/application.properties @@ -30,4 +30,10 @@ spring.jpa.properties.hibernate.show_sql=true # make swagger verifying duplicated method name in inner class #springdoc.use-fqn=true springdoc.default-consumes-media-type=application/json -springdoc.default-produces-media-type=application/json \ No newline at end of file +springdoc.default-produces-media-type=application/json + +# token secret key +jwt.secret=MyAppCenterTodoListSecretKeyMyAppCenterTodoListSecretKey + +# permit url list +security.permit-url=/v3/api-docs/**,/swagger-ui/**,/swagger-resources/** \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/FolderControllerTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/FolderControllerTest.java new file mode 100644 index 0000000..a33326e --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/FolderControllerTest.java @@ -0,0 +1,220 @@ +package com.serverstudy.todolist.controller; + +import com.serverstudy.todolist.dto.request.FolderReq.FolderPatch; +import com.serverstudy.todolist.dto.request.FolderReq.FolderPost; +import com.serverstudy.todolist.dto.response.FolderRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.service.FolderService; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest( + controllers = FolderController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class +) +class FolderControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + FolderService folderService; + + @Nested + class postFolder_메서드는 { + @Test + void 폴더_이름과_유저_기본키가_주어지면_폴더를_생성하고_created_응답과_폴더_기본키를_반환한다() throws Exception { + // given + Long userId = 1L; + String folderPostJson = """ + { + "name": "새로운 폴더" + } + """; + Long folderId = 1L; + given(folderService.create(any(FolderPost.class), eq(userId))).willReturn(folderId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/folder?userId={userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPostJson)) + // then + .andExpect(status().isCreated()) + .andExpect(content().string(folderId.toString())); + } + @Test + void 만약_없는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외가_반환된다() throws Exception { + // given + Long nonExistingUserId = 404L; + String folderPostJson = """ + { + "name": "새로운 폴더" + } + """; + + given(folderService.create(any(FolderPost.class), eq(nonExistingUserId))).willThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/folder?userId={nonExistingUserId}", nonExistingUserId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPostJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.USER_NOT_FOUND.getMessage())); + } + @Test + void 만약_중복된_폴더_이름이_주어지면_DUPLICATE_FOLDER_NAME_예외가_반환된다() throws Exception { + // given + Long userId = 1L; + String folderPostJson = """ + { + "name": "중복된 폴더" + } + """; + + given(folderService.create(any(FolderPost.class), eq(userId))).willThrow(new CustomException(ErrorCode.DUPLICATE_FOLDER_NAME)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/folder?userId={userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPostJson)) + // then + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DUPLICATE_FOLDER_NAME.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.DUPLICATE_FOLDER_NAME.getMessage())); + } + } + + @Nested + class getFoldersByUser_메서드는 { + + // 폴더 목록 조회 성공 + @Test + void 유저_기본키가_주어지면_성공_응답과_폴더_응답_객체_리스트를_반환한다() throws Exception { + // given + Long userId = 1L; + List folderList = List.of( + FolderRes.builder().folderId(1L).name("폴더1").todoCount(3).build(), + FolderRes.builder().folderId(2L).name("폴더2").todoCount(5).build() + ); + + given(folderService.getAllWithTodoCount(userId)).willReturn(folderList); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/folder?userId={userId}", userId)) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].folderId").value(folderList.get(0).getFolderId())) + .andExpect(jsonPath("$[0].name").value(folderList.get(0).getName())) + .andExpect(jsonPath("$[0].todoCount").value(folderList.get(0).getTodoCount())) + .andExpect(jsonPath("$[1].folderId").value(folderList.get(1).getFolderId())) + .andExpect(jsonPath("$[1].name").value(folderList.get(1).getName())) + .andExpect(jsonPath("$[1].todoCount").value(folderList.get(1).getTodoCount())); + } + } + + @Nested + class patchFolder_메서드는 { + @Test + void 수정할_폴더_이름과_유저_기본키가_주어지면_수정_후_성공_응답과_폴더_기본키를_반환한다() throws Exception { + // given + Long folderId = 1L; + String folderPatchJson = """ + { + "name": "수정된 폴더" + } + """; + + Long modifiedFolderId = 1L; + given(folderService.modify(any(FolderPatch.class), eq(folderId))).willReturn(modifiedFolderId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/folder/{folderId}", folderId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPatchJson)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(modifiedFolderId.toString())); + } + @Test + void 만약_없는_폴더의_기본키가_주어지면_FOLDER_NOT_FOUND_예외가_반환된다() throws Exception { + // given + Long nonExistingFolderId = 1L; + String folderPatchJson = """ + { + "name": "수정된 폴더" + } + """; + + given(folderService.modify(any(FolderPatch.class), eq(nonExistingFolderId))).willThrow(new CustomException(ErrorCode.FOLDER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/folder/{nonExistingFolderId}", nonExistingFolderId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPatchJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.FOLDER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.FOLDER_NOT_FOUND.getMessage())); + } + @Test + void 만약_중복된_폴더_이름이_주어지면_DUPLICATE_FOLDER_NAME_예외가_반환된다() throws Exception { + // given + Long folderId = 1L; + String folderPatchJson = """ + { + "name": "중복된 폴더" + } + """; + + given(folderService.modify(any(FolderPatch.class), eq(folderId))).willThrow(new CustomException(ErrorCode.DUPLICATE_FOLDER_NAME)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/folder/{folderId}", folderId) + .contentType(MediaType.APPLICATION_JSON) + .content(folderPatchJson)) + // then + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DUPLICATE_FOLDER_NAME.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.DUPLICATE_FOLDER_NAME.getMessage())); + } + } + + @Nested + class deleteFolder_메서드는 { + @Test + void 폴더_기본키가_주어지면_해당_폴더를_삭제한다() throws Exception { + // given + Long folderId = 1L; + + // when + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/folder/{folderId}", folderId)) + // then + .andExpect(status().isNoContent()); + } + } +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/TodoControllerTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/TodoControllerTest.java new file mode 100644 index 0000000..326655f --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/TodoControllerTest.java @@ -0,0 +1,370 @@ +package com.serverstudy.todolist.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.serverstudy.todolist.domain.enums.Priority; +import com.serverstudy.todolist.domain.enums.Progress; +import com.serverstudy.todolist.dto.request.TodoReq.TodoGet; +import com.serverstudy.todolist.dto.request.TodoReq.TodoPost; +import com.serverstudy.todolist.dto.request.TodoReq.TodoPut; +import com.serverstudy.todolist.dto.response.TodoRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.service.TodoService; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest( + controllers = TodoController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class +) +class TodoControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + TodoService todoService; + + ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + class postTodo_메서드는 { + + @Test + void 투두를_생성하고_created_응답과_투두_기본키를_반환한다() throws Exception { + // given + Long userId = 1L; + String todoPostJson = """ + { + "title": "컴퓨터공학개론 레포트", + "description": "주제: 컴퓨터공학이란 무엇인가?, 분량: 3장 이상", + "deadline": "2024-05-15T23:59:00Z", + "priority": "NONE", + "progress": "TODO", + "folderId": 1 + } + """; + Long todoId = 1L; + given(todoService.create(any(TodoPost.class), eq(userId))).willReturn(todoId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/todo?userId={userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoPostJson)) + // then + .andExpect(status().isCreated()) + .andExpect(content().string(todoId.toString())); + } + @Test + void 만약_존재하지_않는_폴더_기본키가_주어지면_FOLDER_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long userId = 1L; + String todoPostJson = """ + { + "title": "컴퓨터공학개론 레포트", + "description": "주제: 컴퓨터공학이란 무엇인가?, 분량: 3장 이상", + "deadline": "2024-05-15T23:59:00Z", + "priority": "NONE", + "progress": "TODO", + "folderId": 404 + } + """; + given(todoService.create(any(TodoPost.class), eq(userId))).willThrow(new CustomException(ErrorCode.FOLDER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/todo?userId={userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoPostJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.FOLDER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.FOLDER_NOT_FOUND.getMessage())); + } + } + + @Nested + class getTodosByRequirements_메서드는 { + + @Test + void 조건에_맞는_투두를_조회하고_성공_응답과_투두_응답_객체_리스트를_반환한다() throws Exception { + // given + Long userId = 1L; + String todoGetJson = """ + { + "priority": "NONE", + "progress": "TODO", + "isDeleted": false, + "folderId": 1 + } + """; + List todoList = List.of( + TodoRes.builder().id(1L).title("Todo1").priority(Priority.NONE).progress(Progress.TODO).folderId(1L).build(), + TodoRes.builder().id(2L).title("Todo2").priority(Priority.NONE).progress(Progress.TODO).folderId(1L).build() + ); + given(todoService.findAllByConditions(any(TodoGet.class), eq(userId))).willReturn(todoList); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/todo?userId={userId}", userId) + .contentType(MediaType.APPLICATION_JSON) + .queryParam("priority", "NONE") + .queryParam("progress", "TODO") + .queryParam("isDeleted", "false") + .queryParam("folderId", "1")) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(todoList.get(0).getId())) + .andExpect(jsonPath("$[0].title").value(todoList.get(0).getTitle())) + .andExpect(jsonPath("$[0].priority").value(todoList.get(0).getPriority().toString())) + .andExpect(jsonPath("$[0].progress").value(todoList.get(0).getProgress().toString())) + .andExpect(jsonPath("$[0].folderId").value(todoList.get(0).getFolderId())) + .andExpect(jsonPath("$[1].id").value(todoList.get(1).getId())) + .andExpect(jsonPath("$[1].title").value(todoList.get(1).getTitle())) + .andExpect(jsonPath("$[1].priority").value(todoList.get(1).getPriority().toString())) + .andExpect(jsonPath("$[1].progress").value(todoList.get(1).getProgress().toString())) + .andExpect(jsonPath("$[1].folderId").value(todoList.get(1).getFolderId())); + } + } + + @Nested + class putTodo_메서드는 { + @Test + void 투두를_수정하고_성공_응답과_투두_기본키를_반환한다() throws Exception { + // given + Long todoId = 1L; + String todoPutJson = """ + { + "title": "앱센터 발표", + "description": "주제: 스프링이란?, 비고: GitHub에 업로드 ", + "deadline": "2024-05-20T12:00:00Z", + "priority": "PRIMARY", + "progress": "DOING", + "folderId": 1 + } + """; + given(todoService.update(any(TodoPut.class), eq(todoId))).willReturn(todoId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .put("/api/todo/{todoId}", todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoPutJson)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(todoId.toString())); + } + @Test + void 만약_존재하지_않는_투두_기본키가_주어지면_TODO_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long nonExistingTodoId = 404L; + String todoPutJson = """ + { + "title": "앱센터 발표", + "description": "주제: 스프링이란?, 비고: GitHub에 업로드 ", + "deadline": "2024-05-20T12:00:00Z", + "priority": "PRIMARY", + "progress": "DOING", + "folderId": 1 + } + """; + given(todoService.update(any(TodoPut.class), eq(nonExistingTodoId))).willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .put("/api/todo/{nonExistingTodoId}", nonExistingTodoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoPutJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.TODO_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.TODO_NOT_FOUND.getMessage())); + } + @Test + void 만약_존재하지_않는_폴더_기본키가_주어지면_FOLDER_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long todoId = 1L; + String todoPutJson = """ + { + "title": "앱센터 발표", + "description": "주제: 스프링이란?, 비고: GitHub에 업로드 ", + "deadline": "2024-05-20T12:00:00Z", + "priority": "PRIMARY", + "progress": "DOING", + "folderId": 404 + } + """; + given(todoService.update(any(TodoPut.class), eq(todoId))).willThrow(new CustomException(ErrorCode.FOLDER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .put("/api/todo/{todoId}", todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoPutJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.FOLDER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.FOLDER_NOT_FOUND.getMessage())); + } + } + + @Nested + class switchTodoProgress_메서드는 { + @Test + void 투두_진행_상황을_변경하고_성공_응답과_투두_기본키를_반환한다() throws Exception { + // given + Long todoId = 1L; + given(todoService.switchProgress(eq(todoId))).willReturn(todoId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/todo/{todoId}/progress", todoId)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(todoId.toString())); + } + @Test + void 만약_존재하지_않는_투두_기본키가_주어지면_TODO_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long todoId = 1L; + given(todoService.switchProgress(eq(todoId))).willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/todo/{todoId}/progress", todoId)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.TODO_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.TODO_NOT_FOUND.getMessage())); + } + } + + @Nested + class patchTodoFolder_메서드는 { + @Test + void 투두_폴더를_변경하고_성공_응답과_투두_기본키를_반환한다() throws Exception { + // given + Long todoId = 1L; + String todoFolderPatchJson = """ + { + "folderId": 2 + } + """; + given(todoService.moveFolder(anyLong(), eq(todoId))).willReturn(todoId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/todo/{todoId}/folder", todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoFolderPatchJson)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(todoId.toString())); + } + @Test + void 만약_존재하지_않는_투두_기본키가_주어지면_TODO_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long todoId = 1L; + String todoFolderPatchJson = """ + { + "folderId": 2 + } + """; + given(todoService.moveFolder(anyLong(), eq(todoId))).willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/todo/{todoId}/folder", todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoFolderPatchJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.TODO_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.TODO_NOT_FOUND.getMessage())); + } + @Test + void 만약_존재하지_않는_폴더_기본키가_주어지면_FOLDER_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long todoId = 1L; + String todoFolderPatchJson = """ + { + "folderId": 2 + } + """; + given(todoService.moveFolder(anyLong(), eq(todoId))).willThrow(new CustomException(ErrorCode.FOLDER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/todo/{todoId}/folder", todoId) + .contentType(MediaType.APPLICATION_JSON) + .content(todoFolderPatchJson)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.FOLDER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.FOLDER_NOT_FOUND.getMessage())); + } + } + + @Nested + class deleteTodo_메서드는 { + @Test + void 투두_임시_삭제_후_성공_응답과_투두_기본키를_반환한다() throws Exception { + // given + Long todoId = 1L; + Boolean restore = true; + given(todoService.delete(eq(todoId), eq(restore))).willReturn(todoId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/todo/{todoId}?restore={restore}", todoId, restore)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(todoId.toString())); + } + + @Test + void 투두_영구_삭제_성공_후_noContent_응답을_반환한다() throws Exception { + // given + Long todoId = 1L; + Boolean restore = false; + given(todoService.delete(eq(todoId), eq(restore))).willReturn(null); + + // when + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/todo/{todoId}?restore={restore}", todoId, restore)) + // then + .andExpect(status().isNoContent()); + } + + @Test + void 만약_존재하지_않는_투두_기본키가_주어지면_TODO_NOT_FOUND_예외를_반환한다() throws Exception { + // given + Long todoId = 1L; + Boolean restore = true; + given(todoService.delete(eq(todoId), eq(restore))).willThrow(new CustomException(ErrorCode.TODO_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/todo/{todoId}?restore={restore}", todoId, restore)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.TODO_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.TODO_NOT_FOUND.getMessage())); + } + } + +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/UserControllerTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/UserControllerTest.java new file mode 100644 index 0000000..b9ec94c --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/controller/UserControllerTest.java @@ -0,0 +1,356 @@ +package com.serverstudy.todolist.controller; + +import com.serverstudy.todolist.dto.request.UserReq; +import com.serverstudy.todolist.dto.request.UserReq.UserPatch; +import com.serverstudy.todolist.dto.response.UserRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.service.UserService; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest( + controllers = UserController.class, + excludeAutoConfiguration = SecurityAutoConfiguration.class +) +class UserControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + UserService userService; + + @Nested + class postUser_메서드는 { + @Test + void 이메일_비밀번호_닉네임이_주어지면_유저_기본키_HTTP_응답이_반환된다() throws Exception { + // given + String userPostJson = """ + { + "email": "success@email.com", + "password": "successPWD123", + "nickname": "ex성공1" + } + """; + Long userId = 1L; + given(userService.join(any(UserReq.UserPost.class))).willReturn(userId); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPostJson)) + // then + //.andDo(MockMvcResultHandlers.print()) + .andExpect(status().isCreated()) + .andExpect(content().string(userId.toString())); + } + @Test + void 만약_잘못된_형식의_이메일이_주어지면_유효성_검사_예외가_반환된다() throws Exception { + // given + String invalidEmail = "NoAtandNoDotcom"; + String userPostJson = """ + { + "email": "%s", + "password": "successPWD123", + "nickname": "ex성공1" + } + """.formatted(invalidEmail); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPostJson)) + // then + .andExpect(status().isBadRequest()) + .andExpect(result -> { + MethodArgumentNotValidException exception = (MethodArgumentNotValidException) result.getResolvedException(); + assertThat(exception).isNotNull(); + BindingResult bindingResult = exception.getBindingResult(); + assertThat(bindingResult.getFieldErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError("email"); + assertThat(fieldError).isNotNull(); + assertThat(fieldError.getRejectedValue()).isEqualTo(invalidEmail); + }); + //.andExpect(result -> assertInstanceOf(MethodArgumentNotValidException.class, result.getResolvedException())); + } + @Test + void 만약_잘못된_형식의_비밀번호가_주어지면_유효성_검사_예외가_반환된다() throws Exception { + // given + String invalidPassword = "noNumberPassword"; + String userPostJson = """ + { + "email": "success@email.com", + "password": "%s", + "nickname": "ex성공1" + } + """.formatted(invalidPassword); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPostJson)) + // then + .andExpect(status().isBadRequest()) + .andExpect(result -> { + MethodArgumentNotValidException exception = (MethodArgumentNotValidException) result.getResolvedException(); + assertThat(exception).isNotNull(); + BindingResult bindingResult = exception.getBindingResult(); + assertThat(bindingResult.getFieldErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError("password"); + assertThat(fieldError).isNotNull(); + assertThat(fieldError.getRejectedValue()).isEqualTo(invalidPassword); + }); + } + @Test + void 만약_잘못된_형식의_닉네임이_주어지면_유효성_검사_예외가_반환된다() throws Exception { + // given + String invalidNickname = "특수문자닉네임!@#"; + String userPostJson = """ + { + "email": "success@email.com", + "password": "successPWD123", + "nickname": "%s" + } + """.formatted(invalidNickname); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPostJson)) + // then + .andExpect(status().isBadRequest()) + .andExpect(result -> { + MethodArgumentNotValidException exception = (MethodArgumentNotValidException) result.getResolvedException(); + assertThat(exception).isNotNull(); + BindingResult bindingResult = exception.getBindingResult(); + assertThat(bindingResult.getFieldErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError("nickname"); + assertThat(fieldError).isNotNull(); + assertThat(fieldError.getRejectedValue()).isEqualTo(invalidNickname); + }); + } + @Test + void 만약_중복된_이메일이_주어지면_DUPLICATE_USER_EMAIL_예외가_반환된다() throws Exception { + // given + String userPostJson = """ + { + "email": "duplicated@email.com", + "password": "successPWD123", + "nickname": "ex성공1" + } + """; + willThrow(new CustomException(ErrorCode.DUPLICATE_USER_EMAIL)).given(userService).join(any(UserReq.UserPost.class)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPostJson)) + // then + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DUPLICATE_USER_EMAIL.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.DUPLICATE_USER_EMAIL.getMessage())); + } + } + + @Nested + class checkUserEmail_메서드는 { + @Test + void 이메일이_주어지면_이메일_HTTP_응답을_반환한다() throws Exception { + // given + String email = "success@email.com"; + willDoNothing().given(userService).checkEmailDuplicated(email); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/check-email") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("email", email)) + // then + .andExpect(status().isOk()) + .andExpect(content().string(email)); + verify(userService, times(1)).checkEmailDuplicated(email); + } + @Test + void 만약_잘못된_형식의_이메일이_주어지면_유효성_검사_예외가_반환된다() throws Exception { + // given + String invalidEmail = "NoAtandNoDotcom"; + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/check-email") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("email", invalidEmail)) + // then + .andExpect(status().isBadRequest()) + .andExpect(result -> { + ConstraintViolationException exception = (ConstraintViolationException) result.getResolvedException(); + assertThat(exception).isNotNull(); + assertThat(exception.getMessage()).contains("이메일 형식이 올바르지 않습니다."); + }); + //.andExpect(result -> assertInstanceOf(ConstraintViolationException.class, result.getResolvedException())); + } + @Test + void 만약_중복된_이메일이_주어지면_DUPLICATE_USER_EMAIL_예외가_반환된다() throws Exception { + // given + String duplicatedEmail = "duplicated@email.com"; + willThrow(new CustomException(ErrorCode.DUPLICATE_USER_EMAIL)).given(userService).checkEmailDuplicated(duplicatedEmail); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user/check-email") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("email", duplicatedEmail)) + // then + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.DUPLICATE_USER_EMAIL.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.DUPLICATE_USER_EMAIL.getMessage())); + } + } + + @Nested + class getUser_메서드는 { + @Test + void 유저_기본키가_주어지면_유저_응답_객체_HTTP_응답을_반환한다() throws Exception { + // given + Long userId = 1L; + UserRes userRes = UserRes.builder() + .id(userId) + .email("example@gmail.com") + .nickname("ex닉네임1") + .build(); + given(userService.get(userId)).willReturn(userRes); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("userId", userId.toString())) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userRes.getId())) + .andExpect(jsonPath("$.email").value(userRes.getEmail())) + .andExpect(jsonPath("$.nickname").value(userRes.getNickname())); + } + @Test + void 만약_없는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외가_반환된다() throws Exception { + // given + Long nonExistingUserId = 404L; + given(userService.get(nonExistingUserId)).willThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("userId", nonExistingUserId.toString())) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.USER_NOT_FOUND.getMessage())); + } + } + + @Nested + class patchUser_메서드는 { + @Test + void 유저_기본키와_수정할_정보가_주어지면_유저_정보가_성공적으로_수정된다() throws Exception { + // given + Long userId = 1L; + String userPatchJson = """ + { + "password": "newExamplePWD123", + "nickname": "newEx닉네임1" + } + """; + Long modifiedUserId = 1L; + given(userService.modify(any(UserPatch.class), eq(userId))).willReturn(modifiedUserId); + + // when then + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPatchJson) + .queryParam("userId", userId.toString())).andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").value(modifiedUserId)); + } + @Test + void 만약_존재하지_않는_유저_기본키로_수정을_시도하면_USER_NOT_FOUND_예외가_반환된다() throws Exception { + // given + Long nonExistingUserId = 404L; + String userPatchJson = """ + { + "password": "newExamplePWD123", + "nickname": "newEx닉네임1" + } + """; + given(userService.modify(any(UserPatch.class), eq(nonExistingUserId))).willThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + // when then + mockMvc.perform(MockMvcRequestBuilders + .patch("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .content(userPatchJson) + .queryParam("userId", nonExistingUserId.toString())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.USER_NOT_FOUND.getMessage())); + } + } + + @Nested + class deleteUser_메서드는 { + @Test + void 유저_기본키가_주어지면_유저가_성공적으로_삭제된다() throws Exception { + // given + Long userId = 1L; + willDoNothing().given(userService).delete(userId); + + // when then + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("userId", userId.toString())) + .andExpect(status().isNoContent()); + } + @Test + void 만약_존재하지_않는_유저를_삭제하려고_하면_유저_삭제_예외가_발생한다() throws Exception { + // given + Long userId = 404L; + CustomException expectedException = new CustomException(ErrorCode.USER_NOT_FOUND); + willThrow(expectedException).given(userService).delete(userId); + + // when then + mockMvc.perform(MockMvcRequestBuilders + .delete("/api/user") + .contentType(MediaType.APPLICATION_JSON) + .queryParam("userId", userId.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND.toString())) + .andExpect(jsonPath("$.message").value(ErrorCode.USER_NOT_FOUND.getMessage())); + } + } +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/FolderServiceTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/FolderServiceTest.java new file mode 100644 index 0000000..5f5bf61 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/FolderServiceTest.java @@ -0,0 +1,265 @@ +package com.serverstudy.todolist.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.serverstudy.todolist.domain.Folder; +import com.serverstudy.todolist.domain.Todo; +import com.serverstudy.todolist.dto.request.FolderReq.FolderPatch; +import com.serverstudy.todolist.dto.request.FolderReq.FolderPost; +import com.serverstudy.todolist.dto.response.FolderRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.repository.FolderRepository; +import com.serverstudy.todolist.repository.TodoRepository; +import com.serverstudy.todolist.repository.UserRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FolderServiceTest { + + @InjectMocks + FolderService folderService; + @Mock + FolderRepository folderRepository; + @Mock + UserRepository userRepository; + @Mock + TodoRepository todoRepository; + @Captor + private ArgumentCaptor folderCaptor; + ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + class create_메서드는 { + FolderPost givenFolderPost() throws JsonProcessingException { + String folderPostJson = """ + { + "name": "과제 폴더" + } + """; + return objectMapper.readValue(folderPostJson, FolderPost.class); + } + + @Test + void 폴더_이름과_유저_기본키가_주어지면_새로운_폴더를_생성하고_기본키를_반환한다() throws JsonProcessingException { + // given + FolderPost folderPost = givenFolderPost(); + Long userId = 1L; + Folder folder = Folder.builder() + .name(folderPost.getName()) + .userId(userId) + .build(); + Long folderId = 1L; + ReflectionTestUtils.setField(folder, "id", folderId); + given(userRepository.existsById(userId)).willReturn(true); + given(folderRepository.existsByNameAndUserId(folderPost.getName(), userId)).willReturn(false); + given(folderRepository.save(any(Folder.class))).willReturn(folder); + + // when + long result = folderService.create(folderPost, userId); + + // then + assertThat(result).isEqualTo(folderId); + verify(userRepository, times(1)).existsById(userId); + verify(folderRepository, times(1)).existsByNameAndUserId(folderPost.getName(), userId); + verify(folderRepository, times(1)).save(folderCaptor.capture()); + Folder savedFolder = folderCaptor.getValue(); + assertThat(savedFolder.getName()).isEqualTo(folderPost.getName()); + assertThat(savedFolder.getUserId()).isEqualTo(userId); + } + + @Test + void 만약_존재하지_않는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외를_던진다() throws JsonProcessingException { + // given + FolderPost folderPost = givenFolderPost(); + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(false); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> folderService.create(folderPost, userId)); + + // then + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).existsById(userId); + verify(folderRepository, times(0)).existsByNameAndUserId(any(String.class), any(Long.class)); + verify(folderRepository, times(0)).save(any(Folder.class)); + } + + @Test + void 만약_중복된_폴더_이름이_주어지면_DUPLICATE_FOLDER_NAME_예외를_던진다() throws JsonProcessingException { + // given + FolderPost folderPost = givenFolderPost(); + Long userId = 1L; + given(userRepository.existsById(userId)).willReturn(true); + given(folderRepository.existsByNameAndUserId(folderPost.getName(), userId)).willReturn(true); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> folderService.create(folderPost, userId)); + + // then + assertEquals(ErrorCode.DUPLICATE_FOLDER_NAME, exception.getErrorCode()); + verify(userRepository, times(1)).existsById(userId); + verify(folderRepository, times(1)).existsByNameAndUserId(folderPost.getName(), userId); + verify(folderRepository, times(0)).save(any(Folder.class)); + } + } + + @Nested + class getAllWithTodoCount_메서드는 { + @Test + void 유저_기본키가_주어지면_유저의_모든_폴더의_정보를_반환한다() { + // given + Long userId = 1L; + Folder folder1 = Folder.builder().name("Folder 1").userId(userId).build(); + ReflectionTestUtils.setField(folder1, "id", 1L); + Folder folder2 = Folder.builder().name("Folder 2").userId(userId).build(); + ReflectionTestUtils.setField(folder2, "id", 2L); + + List folderList = List.of(folder1, folder2); + + given(folderRepository.findAllByUserIdOrderByNameAsc(userId)).willReturn(folderList); + given(todoRepository.countByFolder(folder1)).willReturn(3); + given(todoRepository.countByFolder(folder2)).willReturn(5); + + // when + List result = folderService.getAllWithTodoCount(userId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getFolderId()).isEqualTo(folder1.getId()); + assertThat(result.get(0).getName()).isEqualTo(folder1.getName()); + assertThat(result.get(0).getTodoCount()).isEqualTo(3); + assertThat(result.get(1).getFolderId()).isEqualTo(folder2.getId()); + assertThat(result.get(1).getName()).isEqualTo(folder2.getName()); + assertThat(result.get(1).getTodoCount()).isEqualTo(5); + } + } + + @Nested + class modify_메서드는 { + @Test + void 수정할_폴더_이름과_폴더_기본키가_주어지면_수정하고_폴더_기본키를_반환한다() throws JsonProcessingException { + // given + Long folderId = 1L; + Long userId = 1L; + String folderPatchJson = """ + { + "name": "New Folder Name" + } + """; + FolderPatch folderPatch = objectMapper.readValue(folderPatchJson, FolderPatch.class); + + Folder folder = Folder.builder().name("Old Folder Name").userId(userId).build(); + ReflectionTestUtils.setField(folder, "id", folderId); + + given(folderRepository.findById(folderId)).willReturn(Optional.of(folder)); + given(folderRepository.existsByNameAndUserId(folderPatch.getName(), userId)).willReturn(false); + + // when + long result = folderService.modify(folderPatch, folderId); + + // then + assertThat(result).isEqualTo(folderId); + assertThat(folder.getName()).isEqualTo(folderPatch.getName()); + verify(folderRepository, times(1)).findById(folderId); + verify(folderRepository, times(1)).existsByNameAndUserId(folderPatch.getName(), userId); + } + + @Test + void 만약_중복된_폴더_이름이_존재하면_DUPLICATE_FOLDER_NAME_예외를_던진다() throws JsonProcessingException { + // given + Long folderId = 1L; + Long userId = 1L; + String folderPatchJson = """ + { + "name": "New Folder Name" + } + """; + FolderPatch folderPatch = objectMapper.readValue(folderPatchJson, FolderPatch.class); + + Folder folder = Folder.builder().name("Old Folder Name").userId(userId).build(); + + given(folderRepository.findById(folderId)).willReturn(Optional.of(folder)); + given(folderRepository.existsByNameAndUserId(folderPatch.getName(), userId)).willReturn(true); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> folderService.modify(folderPatch, folderId)); + + // then + assertEquals(ErrorCode.DUPLICATE_FOLDER_NAME, exception.getErrorCode()); + verify(folderRepository, times(1)).findById(folderId); + verify(folderRepository, times(1)).existsByNameAndUserId(folderPatch.getName(), userId); + } + + @Test + void 만약_존재하지_않는_폴더의_기본키가_주어지면_FOLDER_NOT_FOUND_예외를_던진다() throws JsonProcessingException { + // given + Long folderId = 1L; + String folderPatchJson = """ + { + "name": "New Folder Name" + } + """; + FolderPatch folderPatch = objectMapper.readValue(folderPatchJson, FolderPatch.class); + + given(folderRepository.findById(folderId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> folderService.modify(folderPatch, folderId)); + + // then + assertEquals(ErrorCode.FOLDER_NOT_FOUND, exception.getErrorCode()); + verify(folderRepository, times(1)).findById(folderId); + } + } + @Nested + class delete_메서드는 { + @Test + void 폴더_기본키가_주어지면_해당_폴더를_삭제하고_폴더에_속한_투두를_폴더없음으로_바꾼다() { + // given + Long folderId = 1L; + Folder folder = Folder.builder().name("Folder").userId(1L).build(); + + List todoList = List.of( + Todo.builder().title("Todo 1").folder(folder).build(), + Todo.builder().title("Todo 2").folder(folder).build() + ); + + given(folderRepository.findById(folderId)).willReturn(Optional.of(folder)); + given(todoRepository.findAllByFolder(any(Folder.class))).willReturn(todoList); + + // when + folderService.delete(folderId); + + // then + assertThat(todoList.get(0).getFolder()).isEqualTo(null); + assertThat(todoList.get(1).getFolder()).isEqualTo(null); + verify(folderRepository, times(1)).findById(folderId); + verify(todoRepository, times(1)).findAllByFolder(folder); + verify(folderRepository, times(1)).delete(folder); + } + } +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/TodoServiceTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/TodoServiceTest.java new file mode 100644 index 0000000..50fabb5 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/TodoServiceTest.java @@ -0,0 +1,390 @@ +package com.serverstudy.todolist.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.serverstudy.todolist.domain.Folder; +import com.serverstudy.todolist.domain.Todo; +import com.serverstudy.todolist.domain.enums.Priority; +import com.serverstudy.todolist.domain.enums.Progress; +import com.serverstudy.todolist.dto.response.TodoRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.repository.FolderRepository; +import com.serverstudy.todolist.repository.TodoRepository; +import com.serverstudy.todolist.repository.UserRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static com.serverstudy.todolist.dto.request.TodoReq.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class TodoServiceTest { + @InjectMocks + TodoService todoService; + @Mock + TodoRepository todoRepository; + @Mock + UserRepository userRepository; + @Mock + FolderRepository folderRepository; + @Captor + private ArgumentCaptor todoCaptor; + ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + class create_메서드는 { + TodoPost givenTodoPost() throws JsonProcessingException { + String todoPostJson = """ + { + "title": "컴퓨터공학개론 레포트", + "description": "주제: 컴퓨터공학이란 무엇인가?, 분량: 3장 이상", + "priority": "NONE", + "progress": "TODO", + "folderId": 1 + } + """; + return objectMapper.readValue(todoPostJson, TodoPost.class); + } + + @Test + void 주어진_투두_정보와_유저_기본키에_대해_투두를_생성_및_저장하고_기본키를_반환한다() throws JsonProcessingException { + // given + Long userId = 1L; + Long folderId = 1L; + TodoPost todoPost = givenTodoPost(); + + Folder folder = Folder.builder().name("Folder").userId(userId).build(); + ReflectionTestUtils.setField(folder, "id", 1L); + + Todo todo = todoPost.toEntity(userId, folder); + ReflectionTestUtils.setField(todo, "id", 1L); + + given(userRepository.existsById(userId)).willReturn(true); + given(folderRepository.findById(folderId)).willReturn(Optional.of(folder)); + given(todoRepository.save(any(Todo.class))).willReturn(todo); + + // when + long result = todoService.create(todoPost, userId); + + // then + assertThat(result).isEqualTo(1L); + verify(userRepository, times(1)).existsById(userId); + verify(folderRepository, times(1)).findById(folderId); + verify(todoRepository, times(1)).save(todoCaptor.capture()); + Todo savedTodo = todoCaptor.getValue(); + assertThat(savedTodo.getTitle()).isEqualTo(todoPost.getTitle()); + assertThat(savedTodo.getDescription()).isEqualTo(todoPost.getDescription()); + assertThat(savedTodo.getFolder().getId()).isEqualTo(folderId); + } + @Test + void 만약_존재하지_않는_유저_기본키가_주어지면_USER_NOT_FOUND_예외를_던진다() throws JsonProcessingException { + // given + Long userId = 1L; + TodoPost todoPost = givenTodoPost(); + given(userRepository.existsById(userId)).willReturn(false); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.create(todoPost, userId)); + + // then + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).existsById(userId); + verify(todoRepository, times(0)).save(any(Todo.class)); + } + } + + @Nested + class findAllByConditions_메서드는 { + @Test + void 조건에_맞는_투두들을_검색한_후_투두_응답_객체_리스트로_반환한다() throws JsonProcessingException { + // given + Long userId = 1L; + String todoGetJson = """ + { + "priority": "NONE", + "progress": "TODO", + "isDeleted": false + } + """; + TodoGet todoGet = objectMapper.readValue(todoGetJson, TodoGet.class); + List todoList = List.of( + Todo.builder().title("Todo 1").priority(Priority.NONE).progress(Progress.TODO).userId(userId).build(), + Todo.builder().title("Todo 2").priority(Priority.NONE).progress(Progress.TODO).userId(userId).build() + ); + ReflectionTestUtils.setField(todoList.get(0),"id", 1L); + todoList.get(0).moveToTrash(); + ReflectionTestUtils.setField(todoList.get(1),"id", 2L); + + given(todoRepository.findAllByConditions(any(), eq(userId), any(Priority.class), any(Progress.class), any(Boolean.class))).willReturn(todoList); + + // when + List result = todoService.findAllByConditions(todoGet, userId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getId()).isEqualTo(1L); + assertThat(result.get(0).getTitle()).isEqualTo(todoList.get(0).getTitle()); + assertThat(result.get(0).getDateFromDelete()).isEqualTo(0); + assertThat(result.get(1).getId()).isEqualTo(2L); + assertThat(result.get(1).getTitle()).isEqualTo(todoList.get(1).getTitle()); + } + } + + @Nested + class update_메서드는 { + @Test + void 주어진_투두_정보와_유저_기본키에_대해_투두_정보를_수정하고_기본키를_반환한다() throws JsonProcessingException { + // given + Long todoId = 1L; + String todoPutJson = """ + { + "title": "앱센터 발표", + "description": "주제: 스프링이란?, 비고: GitHub에 업로드 ", + "priority": "PRIMARY", + "progress": "DOING", + "folderId": 1 + } + """; + TodoPut todoPut = objectMapper.readValue(todoPutJson, TodoPut.class); + + Todo todo = Todo.builder().title("Todo").build(); + ReflectionTestUtils.setField(todo, "id", todoId); + Folder folder = Folder.builder().name("Folder").build(); + + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + given(folderRepository.findById(todoPut.getFolderId())).willReturn(Optional.of(folder)); + + // when + long result = todoService.update(todoPut, todoId); + + // then + assertThat(result).isEqualTo(todoId); + verify(todoRepository, times(1)).findById(todoId); + verify(folderRepository, times(1)).findById(todoPut.getFolderId()); + } + @Test + void 존재하지_않는_투두_기본키가_주어지면_TODO_NOT_FOUND_예외를_던진다() throws JsonProcessingException { + // given + Long todoId = 1L; + String todoPutJson = """ + { + "title": "앱센터 발표", + "description": "주제: 스프링이란?, 비고: GitHub에 업로드 ", + "priority": "PRIMARY", + "progress": "DOING" + } + """; + TodoPut todoPut = objectMapper.readValue(todoPutJson, TodoPut.class); + given(todoRepository.findById(todoId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.update(todoPut, todoId)); + + // then + assertEquals(ErrorCode.TODO_NOT_FOUND, exception.getErrorCode()); + verify(todoRepository, times(1)).findById(todoId); + verify(folderRepository, times(0)).findById(todoPut.getFolderId()); + } + } + + @Nested + class switchProgress_메서드는 { + @Test + void 주어진_투두_기본키에_해당하는_투두_상태를_변경하고_기본키를_반환한다() { + // given + Long todoId = 1L; + Todo todo = Todo.builder().title("Todo").progress(Progress.TODO).build(); + ReflectionTestUtils.setField(todo, "id", todoId); + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + Todo switchedTodo =Todo.builder().title("Todo").progress(Progress.TODO).build(); + switchedTodo.switchProgress(); + + // when + long result = todoService.switchProgress(todoId); + + // then + assertThat(result).isEqualTo(todoId); + assertThat(todo.getProgress()).isEqualTo(switchedTodo.getProgress()); + verify(todoRepository, times(1)).findById(todoId); + } + @Test + void 만약_주어진_투두_기본키에_해당하는_투두가_존재하지_않으면_TODO_NOT_FOUND_예외를_던진다() { + // given + Long todoId = 1L; + given(todoRepository.findById(todoId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.switchProgress(todoId)); + + // then + assertEquals(ErrorCode.TODO_NOT_FOUND, exception.getErrorCode()); + verify(todoRepository, times(1)).findById(todoId); + } + } + + @Nested + class moveFolder_메서드는 { + @Test + void 투두를_해당_폴더로_이동하고_기본키를_반환한다() { + // given + Long folderId = 1L; + Long todoId = 1L; + Folder folder = Folder.builder().name("Folder").build(); + ReflectionTestUtils.setField(folder, "id", folderId); + Todo todo = Todo.builder().title("Todo").build(); + ReflectionTestUtils.setField(todo, "id", todoId); + + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + given(folderRepository.findById(folderId)).willReturn(Optional.of(folder)); + + // when + long result = todoService.moveFolder(folderId, todoId); + + // then + assertThat(result).isEqualTo(todoId); + assertThat(todo.getFolder()).isEqualTo(folder); + verify(todoRepository, times(1)).findById(todoId); + verify(folderRepository, times(1)).findById(folderId); + } + @Test + void 만약_투두_기본키에_해당하는_투두가_존재하지_않으면_TODO_NOT_FOUND_예외를_던진다() { + // given + Long folderId = 1L; + Long todoId = 1L; + given(todoRepository.findById(todoId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.moveFolder(folderId, todoId)); + + // then + assertEquals(ErrorCode.TODO_NOT_FOUND, exception.getErrorCode()); + verify(todoRepository, times(1)).findById(todoId); + verify(folderRepository, times(0)).findById(folderId); + } + @Test + void 만약_폴더_기본키에_해당하는_폴더가_존재하지_않으면_FOLDER_NOT_FOUND_예외를_던진다() { + // given + Long folderId = 1L; + Long todoId = 1L; + Todo todo = Todo.builder().title("Todo").build(); + ReflectionTestUtils.setField(todo, "id", todoId); + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + given(folderRepository.findById(folderId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.moveFolder(folderId, todoId)); + + // then + assertEquals(ErrorCode.FOLDER_NOT_FOUND, exception.getErrorCode()); + verify(todoRepository, times(1)).findById(todoId); + verify(folderRepository, times(1)).findById(folderId); + } + } + + @Nested + class delete_메서드는 { + @Test + void restore_값이_false_이면_투두를_삭제하고_null_을_반환한다() { + // given + Long todoId = 1L; + Boolean restore = false; + Todo todo = Todo.builder().title("Todo").build(); + + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + + // when + Long result = todoService.delete(todoId, restore); + + // then + assertThat(result).isNull(); + verify(todoRepository, times(1)).findById(todoId); + verify(todoRepository, times(1)).delete(todo); + } + + @Test + void restore_값이_true_이면_투두를_휴지통으로_옮기고_기본키를_반환한다() { + // given + Long todoId = 1L; + Boolean restore = true; + Todo todo = Todo.builder().title("Todo").build(); + + given(todoRepository.findById(todoId)).willReturn(Optional.of(todo)); + + // when + Long result = todoService.delete(todoId, restore); + + // then + assertThat(result).isEqualTo(todoId); + verify(todoRepository, times(1)).findById(todoId); + verify(todoRepository, times(0)).delete(todo); + assertThat(todo.getIsDeleted()).isTrue(); + } + @Test + void 투두_기본키가_존재하지_않으면_TODO_NOT_FOUND_예외를_던진다() { + // given + Long todoId = 1L; + Boolean restore = false; + given(todoRepository.findById(todoId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> todoService.delete(todoId, restore)); + + // then + assertEquals(ErrorCode.TODO_NOT_FOUND, exception.getErrorCode()); + verify(todoRepository, times(1)).findById(todoId); + } + } + + @Nested + class deleteInTrash_메서드는 { + @Test + void 휴지통에_있는_30일_지난_투두들을_삭제한다() { + // given + Todo todo1 = Todo.builder().title("Todo1").build(); + ReflectionTestUtils.setField(todo1, "id", 1L); + todo1.moveToTrash(); + ReflectionTestUtils.setField(todo1, "deletedTime", LocalDateTime.now().minusDays(31)); + + Todo todo2 = Todo.builder().title("Todo2").build(); + ReflectionTestUtils.setField(todo2, "id", 2L); + todo2.moveToTrash(); + ReflectionTestUtils.setField(todo2, "deletedTime", LocalDateTime.now().minusDays(10)); + + List todoList = List.of(todo1, todo2); + given(todoRepository.findAllByIsDeletedOrderByDeletedTimeAsc(true)).willReturn(todoList); + + // when + todoService.deleteInTrash(); + + // then + verify(todoRepository, times(1)).findAllByIsDeletedOrderByDeletedTimeAsc(true); + verify(todoRepository, times(1)).delete(todo1); + verify(todoRepository, times(0)).delete(todo2); + } + } +} \ No newline at end of file diff --git a/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/UserServiceTest.java b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/UserServiceTest.java new file mode 100644 index 0000000..93f0e44 --- /dev/null +++ b/contents/todoListAPI/seungseop/todolist/src/test/java/com/serverstudy/todolist/service/UserServiceTest.java @@ -0,0 +1,289 @@ +package com.serverstudy.todolist.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.serverstudy.todolist.domain.Folder; +import com.serverstudy.todolist.domain.Todo; +import com.serverstudy.todolist.domain.User; +import com.serverstudy.todolist.dto.request.UserReq.UserPatch; +import com.serverstudy.todolist.dto.response.UserRes; +import com.serverstudy.todolist.exception.CustomException; +import com.serverstudy.todolist.exception.ErrorCode; +import com.serverstudy.todolist.repository.FolderRepository; +import com.serverstudy.todolist.repository.TodoRepository; +import com.serverstudy.todolist.repository.UserRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.serverstudy.todolist.dto.request.UserReq.UserPost; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + UserService userService; + @Mock + UserRepository userRepository; + @Mock + TodoRepository todoRepository; + @Mock + FolderRepository folderRepository; + @Captor + private ArgumentCaptor userCaptor; + ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + class join_메서드는 { + UserPost givenUserPost() throws JsonProcessingException { + String userPostJson = """ + { + "email": "example@gmail.com", + "password": "examplePWD123", + "nickname": "ex닉네임1" + } + """; + return objectMapper.readValue(userPostJson, UserPost.class); + } + + @Test + void 이메일_비밀번호_닉네임이_주어지면_새로운_유저를_생성하고_기본키를_반환한다() throws JsonProcessingException { + // given + /*인자 생성*/ + UserPost userPost = givenUserPost(); + /*호출 반환 값 생성*/ + User user = User.builder() + .email(userPost.getEmail()) + .password(userPost.getPassword()) + .nickname(userPost.getNickname()) + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + /*Mocking*/ + given(userRepository.existsByEmail(userPost.getEmail())).willReturn(false); + given(userRepository.save(any(User.class))).willReturn(user); + + // when + long result = userService.join(userPost); + + // then + /*반환 값 검증*/ + assertThat(result).isEqualTo(1L); + /*호출 횟수 검증*/ + verify(userRepository, times(1)).existsByEmail(userPost.getEmail()); + verify(userRepository, times(1)).save(any(User.class)); + /*호출 인자 검증*/ + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + assertThat(savedUser.getEmail()).isEqualTo(userPost.getEmail()); + assertThat(savedUser.getPassword()).isEqualTo(userPost.getPassword()); + assertThat(savedUser.getNickname()).isEqualTo(userPost.getNickname()); + } + @Test + void 만약_중복된_이메일이_주어지면_DUPLICATE_USER_EMAIL_예외를_던진다() throws JsonProcessingException { + // given + /*인자 생성*/ + UserPost userPost = givenUserPost(); + /*Mocking*/ + given(userRepository.existsByEmail(userPost.getEmail())).willReturn(true); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> userService.join(userPost)); + + // then + assertEquals(ErrorCode.DUPLICATE_USER_EMAIL, exception.getErrorCode()); + /*호출 횟수 검증*/ + verify(userRepository, times(1)).existsByEmail(userPost.getEmail()); + verify(userRepository, times(0)).save(any(User.class)); + } + + } + + @Nested + class checkEmailDuplicated_메서드는 { + @Test + void 이메일이_주어지면_아무것도_반환하지_않는다() { + // given + String email = "notDuplicated@email.com"; + given(userRepository.existsByEmail(email)).willReturn(false); + + // when + userService.checkEmailDuplicated(email); + + // then + verify(userRepository, times(1)).existsByEmail(email); + } + @Test + void 만약_중복된_이메일이_주어지면_DUPLICATE_USER_EMAIL_예외를_던진다() { + // given + String email = "duplicated@email.com"; + given(userRepository.existsByEmail(email)).willReturn(true); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> userService.checkEmailDuplicated(email)); + + // then + assertEquals(ErrorCode.DUPLICATE_USER_EMAIL, exception.getErrorCode()); + verify(userRepository, times(1)).existsByEmail(email); + } + } + + @Nested + class get_메서드는 { + @Test + void 유저_기본키가_주어지면_유저_응답_객체를_반환한다() { + // given + Long userId = 1L; + User user = User.builder() + .email("test@email.com") + .nickname("nickname") + .password("password") + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + UserRes result = userService.get(userId); + + // then + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getEmail()).isEqualTo(user.getEmail()); + assertThat(result.getNickname()).isEqualTo(user.getNickname()); + verify(userRepository, times(1)).findById(userId); + } + @Test + void 만약_없는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외를_던진다() { + // given + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> userService.get(userId)); + + // then + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).findById(userId); + } + } + + @Nested + class modify_메서드는 { + private UserPatch givenUserPatch() throws JsonProcessingException { + String userPatchJson = """ + { + "password": "newExamplePWD123", + "nickname": "newEx닉네임1" + } + """; + return objectMapper.readValue(userPatchJson, UserPatch.class); + } + + @Test + void 비밀번호_닉네임과_유저_기본키가_주어지면_해당_정보를_수정하고_기본키를_반환한다() throws JsonProcessingException { + // given + UserPatch userPatch = givenUserPatch(); + Long userId = 1L; + User user = User.builder() + .email("test@email.com") + .nickname("nickname") + .password("password") + .build(); + ReflectionTestUtils.setField(user, "id", 1L); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + long result = userService.modify(userPatch, userId); + + // then + assertThat(result).isEqualTo(userId); + assertThat(user.getPassword()).isEqualTo(userPatch.getPassword()); + assertThat(user.getNickname()).isEqualTo(userPatch.getNickname()); + verify(userRepository, times(1)).findById(userId); + } + @Test + void 만약_없는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외를_던진다() throws JsonProcessingException { + // given + UserPatch userPatch = givenUserPatch(); + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> userService.modify(userPatch, userId)); + + // then + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).findById(userId); + } + } + + @Nested + class delete_메서드는 { + @Test + void 유저_기본키가_주어지면_해당_유저와_관련된_모든_정보를_삭제한다() { + // given + Long userId = 1L; + User user = User.builder() + .email("test@email.com") + .nickname("nickname") + .password("password") + .build(); + ReflectionTestUtils.setField(user, "id", userId); + List todoList = Collections.emptyList(); + List folderList = Collections.emptyList(); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(todoRepository.findAllByUserId(userId)).willReturn(todoList); + given(folderRepository.findAllByUserId(userId)).willReturn(folderList); + + // when + userService.delete(userId); + + // then + verify(userRepository, times(1)).findById(userId); + verify(todoRepository, times(1)).findAllByUserId(userId); + verify(todoRepository, times(1)).deleteAll(todoList); + verify(folderRepository, times(1)).findAllByUserId(userId); + verify(folderRepository, times(1)).deleteAll(folderList); + verify(userRepository, times(1)).delete(user); + } + @Test + void 만약_없는_유저의_기본키가_주어지면_USER_NOT_FOUND_예외를_던진다() { + // given + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when + CustomException exception = assertThrows(CustomException.class, + () -> userService.delete(userId)); + + // then + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + verify(userRepository, times(1)).findById(userId); + verify(todoRepository, times(0)).findAllByUserId(userId); + verify(todoRepository, times(0)).deleteAll(any()); + verify(folderRepository, times(0)).findAllByUserId(userId); + verify(folderRepository, times(0)).deleteAll(any()); + verify(userRepository, times(0)).delete(any(User.class)); + } + } +} \ No newline at end of file