diff --git a/api/src/main/java/vook/server/api/app/user/UserService.java b/api/src/main/java/vook/server/api/app/user/UserService.java index aa76879..366513c 100644 --- a/api/src/main/java/vook/server/api/app/user/UserService.java +++ b/api/src/main/java/vook/server/api/app/user/UserService.java @@ -5,9 +5,7 @@ import vook.server.api.app.user.data.OnboardingCommand; import vook.server.api.app.user.data.RegisterCommand; import vook.server.api.app.user.data.SignUpFromSocialCommand; -import vook.server.api.app.user.exception.AlreadyOnboardingException; -import vook.server.api.app.user.exception.AlreadyRegisteredException; -import vook.server.api.app.user.exception.NotReadyToOnboardingException; +import vook.server.api.app.user.exception.*; import vook.server.api.app.user.repo.SocialUserRepository; import vook.server.api.app.user.repo.UserInfoRepository; import vook.server.api.app.user.repo.UserRepository; @@ -49,6 +47,9 @@ public void register(RegisterCommand command) { if (user.isRegistered()) { throw new AlreadyRegisteredException(); } + if (user.isWithdrawn()) { + throw new WithdrawnUserException(); + } UserInfo userInfo = userInfoRepository.save(UserInfo.forRegisterOf( command.getNickname(), @@ -69,4 +70,20 @@ public void onboarding(OnboardingCommand command) { user.onboarding(command.getFunnel(), command.getJob()); } + + public void updateInfo(String uid, String nickname) { + User user = repository.findByUid(uid).orElseThrow(); + if (!user.isRegistered()) { + throw new NotRegisteredException(); + } + user.update(nickname); + } + + public void withdraw(String uid) { + User user = repository.findByUid(uid).orElseThrow(); + if (user.isWithdrawn()) { + return; + } + user.withdraw(); + } } diff --git a/api/src/main/java/vook/server/api/app/user/exception/NotRegisteredException.java b/api/src/main/java/vook/server/api/app/user/exception/NotRegisteredException.java new file mode 100644 index 0000000..0ff2eef --- /dev/null +++ b/api/src/main/java/vook/server/api/app/user/exception/NotRegisteredException.java @@ -0,0 +1,10 @@ +package vook.server.api.app.user.exception; + +import vook.server.api.app.common.AppException; + +public class NotRegisteredException extends AppException { + @Override + public String contents() { + return "NotRegistered"; + } +} diff --git a/api/src/main/java/vook/server/api/app/user/exception/WithdrawnUserException.java b/api/src/main/java/vook/server/api/app/user/exception/WithdrawnUserException.java new file mode 100644 index 0000000..f91b80e --- /dev/null +++ b/api/src/main/java/vook/server/api/app/user/exception/WithdrawnUserException.java @@ -0,0 +1,10 @@ +package vook.server.api.app.user.exception; + +import vook.server.api.app.common.AppException; + +public class WithdrawnUserException extends AppException { + @Override + public String contents() { + return "WithdrawnUser"; + } +} diff --git a/api/src/main/java/vook/server/api/model/user/User.java b/api/src/main/java/vook/server/api/model/user/User.java index 28ded02..c70a692 100644 --- a/api/src/main/java/vook/server/api/model/user/User.java +++ b/api/src/main/java/vook/server/api/model/user/User.java @@ -34,7 +34,7 @@ public class User { private LocalDateTime lastUpdatedAt; - private LocalDateTime deletedAt; + private LocalDateTime withdrawnAt; @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List socialUsers = new ArrayList<>(); @@ -76,4 +76,18 @@ public boolean isReadyToOnboarding() { public boolean isRegistered() { return status == UserStatus.REGISTERED; } + + public void update(String nickname) { + userInfo.update(nickname); + lastUpdatedAt = LocalDateTime.now(); + } + + public void withdraw() { + this.status = UserStatus.WITHDRAWN; + this.withdrawnAt = LocalDateTime.now(); + } + + public boolean isWithdrawn() { + return status == UserStatus.WITHDRAWN; + } } diff --git a/api/src/main/java/vook/server/api/model/user/UserInfo.java b/api/src/main/java/vook/server/api/model/user/UserInfo.java index 1e549c5..932ce39 100644 --- a/api/src/main/java/vook/server/api/model/user/UserInfo.java +++ b/api/src/main/java/vook/server/api/model/user/UserInfo.java @@ -42,4 +42,8 @@ public void addOnboardingInfo(Funnel funnel, Job job) { this.funnel = funnel; this.job = job; } + + public void update(String nickname) { + this.nickname = nickname; + } } diff --git a/api/src/main/java/vook/server/api/helper/jwt/JWTHelperProvider.java b/api/src/main/java/vook/server/api/web/auth/app/JWTHelperProvider.java similarity index 80% rename from api/src/main/java/vook/server/api/helper/jwt/JWTHelperProvider.java rename to api/src/main/java/vook/server/api/web/auth/app/JWTHelperProvider.java index 60e88e4..11430a4 100644 --- a/api/src/main/java/vook/server/api/helper/jwt/JWTHelperProvider.java +++ b/api/src/main/java/vook/server/api/web/auth/app/JWTHelperProvider.java @@ -1,8 +1,10 @@ -package vook.server.api.helper.jwt; +package vook.server.api.web.auth.app; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import vook.server.api.helper.jwt.JWTReader; +import vook.server.api.helper.jwt.JWTWriter; @Component public class JWTHelperProvider { @@ -19,7 +21,7 @@ public void init() { jwtReaderBuilder = new JWTReader.Builder(jwtSecret); } - public JWTWriter builder() { + public JWTWriter writer() { return jwtWriterBuilder.build(); } diff --git a/api/src/main/java/vook/server/api/web/auth/app/TokenService.java b/api/src/main/java/vook/server/api/web/auth/app/TokenService.java index 6005146..16038ea 100644 --- a/api/src/main/java/vook/server/api/web/auth/app/TokenService.java +++ b/api/src/main/java/vook/server/api/web/auth/app/TokenService.java @@ -4,7 +4,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import vook.server.api.helper.jwt.JWTHelperProvider; import vook.server.api.helper.jwt.JWTReader; import vook.server.api.web.auth.data.GeneratedToken; @@ -50,7 +49,7 @@ public GeneratedToken refreshToken(String refreshToken) { } private String buildAccessToken(String uid) { - return jwtHelperProvider.builder() + return jwtHelperProvider.writer() .withExpiredMs(1000L * 60 * accessTokenExpiredMinute) .withClaim("category", "access") .withClaim("uid", uid) @@ -58,7 +57,7 @@ private String buildAccessToken(String uid) { } private String buildRefreshToken(String uid) { - return jwtHelperProvider.builder() + return jwtHelperProvider.writer() .withExpiredMs(1000L * 60 * refreshTokenExpiredMinute) .withClaim("category", "refresh") .withClaim("uid", uid) diff --git a/api/src/main/java/vook/server/api/web/routes/user/UserApi.java b/api/src/main/java/vook/server/api/web/routes/user/UserApi.java index a108425..85edcf5 100644 --- a/api/src/main/java/vook/server/api/web/routes/user/UserApi.java +++ b/api/src/main/java/vook/server/api/web/routes/user/UserApi.java @@ -46,7 +46,7 @@ class UserApiUerInfoResponse extends CommonApiResponse { description = """ 비즈니스 규칙 위반 내용 - AlreadyRegistered: 이미 회원가입이 완료된 유저가 해당 API를 호출 할 경우 - """ + - WithdrawnUser: 탈퇴한 유저가 해당 API를 호출 할 경우""" ) @ApiResponses(value = { @ApiResponse( @@ -87,4 +87,37 @@ class UserApiUerInfoResponse extends CommonApiResponse { ), }) CommonApiResponse onboarding(VookLoginUser user, UserOnboardingRequest request); + + @Operation( + summary = "사용자 정보 수정", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = """ + 비즈니스 규칙 위반 내용 + - NotRegistered: 가입하지 않은 유저가 해당 API를 호출 할 경우""" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "400", + content = @Content( + mediaType = "application/json", + schema = @Schema(ref = ComponentRefConsts.Schema.COMMON_API_RESPONSE), + examples = { + @ExampleObject(name = "유효하지 않은 파라미터", ref = ComponentRefConsts.Example.INVALID_PARAMETER), + @ExampleObject(name = "비즈니스 규칙 위반", ref = ComponentRefConsts.Example.VIOLATION_BUSINESS_RULE) + } + ) + ), + }) + CommonApiResponse updateInfo(VookLoginUser user, UserUpdateInfoRequest request); + + @Operation( + summary = "회원 탈퇴", + security = { + @SecurityRequirement(name = "AccessToken") + }, + description = "탈퇴된 회원에 대한 요청은 무시됩니다." + ) + CommonApiResponse withdraw(VookLoginUser user); } diff --git a/api/src/main/java/vook/server/api/web/routes/user/UserRestController.java b/api/src/main/java/vook/server/api/web/routes/user/UserRestController.java index f31fcdd..dc5f967 100644 --- a/api/src/main/java/vook/server/api/web/routes/user/UserRestController.java +++ b/api/src/main/java/vook/server/api/web/routes/user/UserRestController.java @@ -47,4 +47,23 @@ public CommonApiResponse onboarding( service.onboarding(user, request); return CommonApiResponse.ok(); } + + @Override + @PutMapping("/info") + public CommonApiResponse updateInfo( + @AuthenticationPrincipal VookLoginUser user, + @Validated @RequestBody UserUpdateInfoRequest request + ) { + service.updateInfo(user, request); + return CommonApiResponse.ok(); + } + + @Override + @PostMapping("/withdraw") + public CommonApiResponse withdraw( + @AuthenticationPrincipal VookLoginUser user + ) { + service.withdraw(user); + return CommonApiResponse.ok(); + } } diff --git a/api/src/main/java/vook/server/api/web/routes/user/UserUpdateInfoRequest.java b/api/src/main/java/vook/server/api/web/routes/user/UserUpdateInfoRequest.java new file mode 100644 index 0000000..83a21ae --- /dev/null +++ b/api/src/main/java/vook/server/api/web/routes/user/UserUpdateInfoRequest.java @@ -0,0 +1,13 @@ +package vook.server.api.web.routes.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UserUpdateInfoRequest { + + @NotBlank + @Size(min = 1, max = 10) + private String nickname; +} diff --git a/api/src/main/java/vook/server/api/web/routes/user/UserWebService.java b/api/src/main/java/vook/server/api/web/routes/user/UserWebService.java index 1c57d4f..2bbf234 100644 --- a/api/src/main/java/vook/server/api/web/routes/user/UserWebService.java +++ b/api/src/main/java/vook/server/api/web/routes/user/UserWebService.java @@ -29,4 +29,12 @@ public void register(VookLoginUser loginUser, UserRegisterRequest request) { public void onboarding(VookLoginUser loginUser, UserOnboardingRequest request) { userService.onboarding(request.toCommand(loginUser.getUid())); } + + public void updateInfo(VookLoginUser loginUser, UserUpdateInfoRequest request) { + userService.updateInfo(loginUser.getUid(), request.getNickname()); + } + + public void withdraw(VookLoginUser loginUser) { + userService.withdraw(loginUser.getUid()); + } } diff --git a/api/src/test/java/vook/server/api/testhelper/TestDataCreator.java b/api/src/test/java/vook/server/api/testhelper/TestDataCreator.java index 3032b7f..3fe44db 100644 --- a/api/src/test/java/vook/server/api/testhelper/TestDataCreator.java +++ b/api/src/test/java/vook/server/api/testhelper/TestDataCreator.java @@ -41,6 +41,12 @@ public User createCompletedOnboardingUser() { return userService.findByUid(user.getUid()).orElseThrow(); } + public User createWithdrawnUser() { + User user = createCompletedOnboardingUser(); + userService.withdraw(user.getUid()); + return userService.findByUid(user.getUid()).orElseThrow(); + } + public GeneratedToken createToken(User user) { return tokenService.generateToken(user.getUid()); } diff --git a/api/src/test/java/vook/server/api/web/routes/user/UserRestControllerTest.java b/api/src/test/java/vook/server/api/web/routes/user/UserRestControllerTest.java index 404a253..cb5d7d1 100644 --- a/api/src/test/java/vook/server/api/web/routes/user/UserRestControllerTest.java +++ b/api/src/test/java/vook/server/api/web/routes/user/UserRestControllerTest.java @@ -9,6 +9,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import vook.server.api.model.user.User; import vook.server.api.testhelper.HttpEntityBuilder; import vook.server.api.testhelper.IntegrationTestBase; @@ -22,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@Transactional class UserRestControllerTest extends IntegrationTestBase { @MockBean @@ -133,4 +135,59 @@ Collection registerError() { }) ); } + + @Test + @DisplayName("회원 정보 수정 - 정상") + void updateInfo() { + // given + User registeredUser = testDataCreator.createRegisteredUser(); + GeneratedToken token = testDataCreator.createToken(registeredUser); + + // when + var res = rest.exchange( + "/user/info", + HttpMethod.PUT, + new HttpEntityBuilder() + .header("Authorization", "Bearer " + token.getAccessToken()) + .body(Map.of( + "nickname", "newName" + )) + .build(), + String.class + ); + + // then + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @TestFactory + @DisplayName("회원 정보 수정 - 실패") + Collection updateInfoError() { + // given + User registeredUser = testDataCreator.createRegisteredUser(); + GeneratedToken token = testDataCreator.createToken(registeredUser); + + Function, ResponseEntity> restExchange = body -> rest.exchange( + "/user/info", + HttpMethod.PUT, + new HttpEntityBuilder() + .header("Authorization", "Bearer " + token.getAccessToken()) + .body(body) + .build(), + String.class + ); + + return List.of( + DynamicTest.dynamicTest("닉네임 누락", () -> { + var res = restExchange.apply(Map.of()); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + }), + DynamicTest.dynamicTest("닉네임 길이 제한 초과", () -> { + var res = restExchange.apply(Map.of( + "nickname", "12345678901" + )); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + }) + ); + } } diff --git a/api/src/test/java/vook/server/api/web/routes/user/UserWebServiceTest.java b/api/src/test/java/vook/server/api/web/routes/user/UserWebServiceTest.java index eb8927f..ca3bdcb 100644 --- a/api/src/test/java/vook/server/api/web/routes/user/UserWebServiceTest.java +++ b/api/src/test/java/vook/server/api/web/routes/user/UserWebServiceTest.java @@ -5,9 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import vook.server.api.app.user.UserService; -import vook.server.api.app.user.exception.AlreadyOnboardingException; -import vook.server.api.app.user.exception.AlreadyRegisteredException; -import vook.server.api.app.user.exception.NotReadyToOnboardingException; +import vook.server.api.app.user.exception.*; import vook.server.api.model.user.Funnel; import vook.server.api.model.user.Job; import vook.server.api.model.user.User; @@ -132,6 +130,23 @@ void registerError1() { .isInstanceOf(AlreadyRegisteredException.class); } + @Test + @DisplayName("회원 가입 - 에러; 탈퇴한 유저") + void registerError2() { + // given + User withdrawnUser = testDataCreator.createWithdrawnUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(withdrawnUser.getUid()); + + UserRegisterRequest request = new UserRegisterRequest(); + request.setNickname("nickname"); + request.setRequiredTermsAgree(true); + request.setMarketingEmailOptIn(true); + + // when + assertThatThrownBy(() -> userWebService.register(vookLoginUser, request)) + .isInstanceOf(WithdrawnUserException.class); + } + @Test @DisplayName("온보딩 완료 - 정상") void onboarding1() { @@ -187,4 +202,52 @@ void onboardingError2() { assertThatThrownBy(() -> userWebService.onboarding(vookLoginUser, request)) .isInstanceOf(AlreadyOnboardingException.class); } + + @Test + @DisplayName("사용자 정보 수정 - 정상") + void updateInfo1() { + // given + User registeredUser = testDataCreator.createRegisteredUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(registeredUser.getUid()); + UserUpdateInfoRequest request = new UserUpdateInfoRequest(); + request.setNickname("newNickname"); + + // when + userWebService.updateInfo(vookLoginUser, request); + + // then + User user = userService.findByUid(registeredUser.getUid()).orElseThrow(); + assertThat(user.getUserInfo().getNickname()).isEqualTo("newNickname"); + assertThat(user.getLastUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("사용자 정보 수정 - 에러; 미 가입 유저") + void updateInfoError1() { + // given + User unregisteredUser = testDataCreator.createUnregisteredUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(unregisteredUser.getUid()); + UserUpdateInfoRequest request = new UserUpdateInfoRequest(); + request.setNickname("newNickname"); + + // when + assertThatThrownBy(() -> userWebService.updateInfo(vookLoginUser, request)) + .isInstanceOf(NotRegisteredException.class); + } + + @Test + @DisplayName("탈퇴 - 정상") + void withdraw1() { + // given + User registeredUser = testDataCreator.createRegisteredUser(); + VookLoginUser vookLoginUser = VookLoginUser.of(registeredUser.getUid()); + + // when + userWebService.withdraw(vookLoginUser); + + // then + User user = userService.findByUid(registeredUser.getUid()).orElseThrow(); + assertThat(user.getStatus()).isEqualTo(UserStatus.WITHDRAWN); + assertThat(user.getWithdrawnAt()).isNotNull(); + } }