diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java index ec0e9744..b4fd4076 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicController.java @@ -11,6 +11,7 @@ import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase; import com.dreamteam.alter.domain.user.port.inbound.LoginWithSocialUseCase; import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase; +import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckContactDuplicationUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckNicknameDuplicationUseCase; import com.dreamteam.alter.domain.user.port.inbound.CheckEmailDuplicationUseCase; @@ -41,6 +42,9 @@ public class UserPublicController implements UserPublicControllerSpec { @Resource(name = "createUser") private final CreateUserUseCase createUser; + @Resource(name = "createUserWithSocial") + private final CreateUserWithSocialUseCase createUserWithSocial; + @Resource(name = "checkContactDuplication") private final CheckContactDuplicationUseCase checkContactDuplication; @@ -95,6 +99,14 @@ public ResponseEntity> createUser( return ResponseEntity.ok(CommonApiResponse.of(createUser.execute(request))); } + @Override + @PostMapping("/signup-social") + public ResponseEntity> createUserWithSocial( + @Valid @RequestBody CreateUserWithSocialRequestDto request + ) { + return ResponseEntity.ok(CommonApiResponse.of(createUserWithSocial.execute(request))); + } + @Override @PostMapping("/exists/nickname") public ResponseEntity> checkNicknameDuplication( diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java index dee7471a..599ac9d5 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserPublicControllerSpec.java @@ -93,6 +93,23 @@ public interface UserPublicControllerSpec { }) ResponseEntity> createUser(@Valid CreateUserRequestDto request); + @Operation(summary = "소셜 계정으로 회원가입을 수행한다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "소셜 회원 가입 및 로그인 성공 (JWT 응답)"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "회원 가입 세션이 존재하지 않음", value = "{\"code\" : \"A006\"}"), + @ExampleObject(name = "사용자 닉네임 중복", value = "{\"code\" : \"A008\"}"), + @ExampleObject(name = "사용자 휴대폰 번호 중복", value = "{\"code\" : \"A009\"}"), + @ExampleObject(name = "소셜 플랫폼 ID 중복", value = "{\"code\" : \"A005\"}"), + @ExampleObject(name = "소셜 토큰 만료 (재 로그인 필요)", value = "{\"code\" : \"A007\"}") + })) + }) + ResponseEntity> createUserWithSocial(@Valid CreateUserWithSocialRequestDto request); + @Operation(summary = "사용자 닉네임 중복 체크") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "닉네임 중복 체크 성공") diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java index dee7d4bc..c26e6326 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfController.java @@ -57,6 +57,9 @@ public class UserSelfController implements UserSelfControllerSpec { @Resource(name = "verifyEmailVerificationCode") private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode; + @Resource(name = "updatePassword") + private final UpdatePasswordUseCase updatePassword; + @Override @GetMapping public ResponseEntity> getUserSelfInfo() { @@ -154,4 +157,14 @@ public ResponseEntity> return ResponseEntity.ok(CommonApiResponse.of(verifyEmailVerificationCode.execute(request))); } + @Override + @PutMapping("/password") + public ResponseEntity> updatePassword( + @Valid @RequestBody UpdatePasswordRequestDto request + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + updatePassword.execute(actor, request); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java index bee596cc..13fa8488 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSelfControllerSpec.java @@ -215,4 +215,25 @@ ResponseEntity> updateUserSelfCertificate( }) ResponseEntity> verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request); + @Operation(summary = "비밀번호 변경", description = "비밀번호가 설정된 사용자는 currentPassword 필수. 소셜 전용 사용자는 생략 가능.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "현재 비밀번호 불일치", + value = "{\"code\": \"A017\", \"message\": \"현재 비밀번호가 올바르지 않습니다.\"}" + ), + @ExampleObject( + name = "비밀번호 형식 오류", + value = "{\"code\": \"A014\", \"message\": \"비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.\"}" + ) + } + )) + }) + ResponseEntity> updatePassword(@RequestBody @Valid UpdatePasswordRequestDto request); + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java index bc255916..21663224 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialController.java @@ -2,9 +2,13 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto; -import com.dreamteam.alter.application.user.usecase.LinkSocialAccount; +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; import com.dreamteam.alter.application.aop.AppActionContext; import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.port.inbound.LinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import jakarta.annotation.Resource; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,7 +19,11 @@ @RequiredArgsConstructor public class UserSocialController implements UserSocialControllerSpec { - private final LinkSocialAccount linkSocialAccount; + @Resource(name = "linkSocialAccount") + private final LinkSocialAccountUseCase linkSocialAccount; + + @Resource(name = "unlinkSocialAccount") + private final UnlinkSocialAccountUseCase unlinkSocialAccount; @Override @PostMapping("/link") @@ -23,8 +31,17 @@ public ResponseEntity> linkSocialAccount( @Valid @RequestBody LinkSocialAccountRequestDto request ) { AppActor actor = AppActionContext.getInstance().getActor(); - linkSocialAccount.execute(actor, request); return ResponseEntity.ok(CommonApiResponse.empty()); } + + @Override + @DeleteMapping("/unlink/{provider}") + public ResponseEntity> unlinkSocialAccount( + @PathVariable SocialProvider provider + ) { + AppActor actor = AppActionContext.getInstance().getActor(); + unlinkSocialAccount.execute(actor, new UnlinkSocialAccountRequestDto(provider)); + return ResponseEntity.ok(CommonApiResponse.empty()); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java index 7e085fc1..84203cfa 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/controller/UserSocialControllerSpec.java @@ -2,6 +2,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto; +import com.dreamteam.alter.domain.user.type.SocialProvider; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; @Tag(name = "사용자 - 소셜 계정 연동") public interface UserSocialControllerSpec { @@ -39,4 +41,25 @@ public interface UserSocialControllerSpec { )) }) ResponseEntity> linkSocialAccount(@Valid LinkSocialAccountRequestDto request); + + @Operation(summary = "소셜 계정 연동 해제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "소셜 계정 연동 해제 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse.class), + examples = { + @ExampleObject( + name = "연동되지 않은 소셜 플랫폼", + value = "{\"code\": \"A015\", \"message\": \"연동되지 않은 소셜 플랫폼입니다.\"}" + ), + @ExampleObject( + name = "마지막 소셜 계정 해제 불가", + value = "{\"code\": \"A016\", \"message\": \"비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다.\"}" + ) + } + )) + }) + ResponseEntity> unlinkSocialAccount(@PathVariable SocialProvider provider); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java new file mode 100644 index 00000000..08bcdf20 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/CreateUserWithSocialRequestDto.java @@ -0,0 +1,72 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import com.dreamteam.alter.domain.user.type.PlatformType; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import com.dreamteam.alter.domain.user.type.UserGender; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "소셜 회원가입 요청 DTO") +public class CreateUserWithSocialRequestDto { + + @NotBlank + @Size(max = 64) + @Schema(description = "회원가입 세션 ID", example = "UUID") + private String signupSessionId; + + @NotNull + @Schema(description = "소셜 로그인 플랫폼", example = "KAKAO") + private SocialProvider provider; + + @Valid + @Schema(description = "OAuth 토큰") + private OauthLoginTokenDto oauthToken; + + @Schema(description = "OAuth 인가 코드", example = "authorizationCode") + private String authorizationCode; + + @NotNull + @Schema(description = "플랫폼 타입", example = "WEB / NATIVE") + private PlatformType platformType; + + @NotBlank + @Size(max = 12) + @Schema(description = "성명", example = "김철수") + private String name; + + @NotBlank + @Size(max = 64) + @Schema(description = "닉네임", example = "유땡땡") + private String nickname; + + @NotNull + @Schema(description = "성별", example = "GENDER_MALE") + private UserGender gender; + + @NotBlank + @Size(min = 8, max = 8) + @Schema(description = "생년월일", example = "YYYYMMDD") + private String birthday; + + @AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다") + private boolean isWebPlatformValid() { + if (platformType != PlatformType.WEB) return true; + return authorizationCode != null && !authorizationCode.isBlank(); + } + + @AssertTrue(message = "NATIVE 플랫폼은 oauthToken이 필수입니다") + private boolean isNativePlatformValid() { + if (platformType != PlatformType.NATIVE) return true; + return oauthToken != null; + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java new file mode 100644 index 00000000..9c8b115e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UnlinkSocialAccountRequestDto.java @@ -0,0 +1,19 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import com.dreamteam.alter.domain.user.type.SocialProvider; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "소셜 계정 연동 해제 요청 DTO") +public class UnlinkSocialAccountRequestDto { + + @NotNull + @Schema(description = "해제할 소셜 플랫폼", example = "KAKAO") + private SocialProvider provider; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java new file mode 100644 index 00000000..718f623f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/user/dto/UpdatePasswordRequestDto.java @@ -0,0 +1,30 @@ +package com.dreamteam.alter.adapter.inbound.general.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "비밀번호 변경 요청 DTO") +public class UpdatePasswordRequestDto { + + @Schema(description = "현재 비밀번호 (비밀번호가 설정된 사용자는 필수, 소셜 전용 사용자는 생략 가능)", example = "currentPass1!") + private String currentPassword; + + @NotBlank + @Size(min = 8, max = 16) + @Schema(description = "새 비밀번호 (8~16자, 영문·숫자·특수문자 각 1개 이상)", example = "newPass1!") + private String newPassword; + + @AssertTrue(message = "새 비밀번호는 현재 비밀번호와 달라야 합니다") + private boolean isNewPasswordDifferent() { + if (currentPassword == null || newPassword == null) return true; + return !currentPassword.equals(newPassword); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java new file mode 100644 index 00000000..1ab77f00 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class SignupSessionCacheRepository { + + private final StringRedisTemplate redisTemplate; + + public void save(String key, String value) { + redisTemplate.opsForValue().set(key, value); + } + + public void save(String key, String value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + public String get(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void delete(String key) { + redisTemplate.delete(key); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteAll(List keys) { + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java new file mode 100644 index 00000000..c1da584f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialJpaRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import com.dreamteam.alter.domain.user.entity.UserSocial; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserSocialJpaRepository extends JpaRepository { +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java index 3b29553e..74c0a54e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialQueryRepositoryImpl.java @@ -7,9 +7,11 @@ import com.dreamteam.alter.domain.user.type.SocialProvider; import com.dreamteam.alter.domain.user.type.UserStatus; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -18,6 +20,32 @@ public class UserSocialQueryRepositoryImpl implements UserSocialQueryRepository private final JPAQueryFactory queryFactory; + @Override + public Optional findByUserIdAndSocialProvider(Long userId, SocialProvider socialProvider) { + QUserSocial qUserSocial = QUserSocial.userSocial; + + UserSocial userSocial = queryFactory.selectFrom(qUserSocial) + .where( + qUserSocial.user.id.eq(userId), + qUserSocial.socialProvider.eq(socialProvider) + ) + .fetchFirst(); + + return Optional.ofNullable(userSocial); + } + + @Override + public long countByUserId(Long userId) { + QUserSocial qUserSocial = QUserSocial.userSocial; + + Long count = queryFactory.select(qUserSocial.count()) + .from(qUserSocial) + .where(qUserSocial.user.id.eq(userId)) + .fetchOne(); + + return count != null ? count : 0L; + } + @Override public Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId) { QUserSocial qUserSocial = QUserSocial.userSocial; @@ -54,7 +82,7 @@ public boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, @Override public boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider) { QUserSocial qUserSocial = QUserSocial.userSocial; - + Long count = queryFactory.select(qUserSocial.count()) .from(qUserSocial) .where( @@ -63,7 +91,17 @@ public boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialP qUserSocial.socialProvider.eq(socialProvider) ) .fetchOne(); - + return count != null && count > 0; } + + @Override + public long countByUserIdForUpdate(Long userId) { + QUserSocial q = QUserSocial.userSocial; + List rows = queryFactory.selectFrom(q) + .where(q.user.id.eq(userId)) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetch(); + return rows.size(); + } } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java new file mode 100644 index 00000000..0ade0450 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/UserSocialRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.dreamteam.alter.adapter.outbound.user.persistence; + +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class UserSocialRepositoryImpl implements UserSocialRepository { + + private final UserSocialJpaRepository userSocialJpaRepository; + + @Override + public void delete(UserSocial userSocial) { + userSocialJpaRepository.delete(userSocial); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java new file mode 100644 index 00000000..96fa4c04 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocial.java @@ -0,0 +1,128 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.SocialLoginRequestDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.AuthLog; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.AuthLogType; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; +import java.util.List; + +@Service("createUserWithSocial") +@RequiredArgsConstructor +@Transactional +public class CreateUserWithSocial implements CreateUserWithSocialUseCase { + + private static final String KEY_PREFIX = "SIGNUP:PENDING:"; + private static final String CONTACT_INDEX_KEY_PREFIX = "SIGNUP:CONTACT:"; + + private final UserRepository userRepository; + private final UserQueryRepository userQueryRepository; + private final UserSocialQueryRepository userSocialQueryRepository; + private final SocialAuthenticationManager socialAuthenticationManager; + private final AuthService authService; + private final AuthLogRepository authLogRepository; + private final SignupSessionCacheRepository cacheRepository; + + @Override + public GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request) { + + // Redis 세션에서 휴대폰 인증 정보 확인 + String sessionIdKey = KEY_PREFIX + request.getSignupSessionId(); + String contact = cacheRepository.get(sessionIdKey); + + if (ObjectUtils.isEmpty(contact)) { + throw new CustomException(ErrorCode.SIGNUP_SESSION_NOT_EXIST); + } + + // 중복 확인 + validateDuplication(request, contact, sessionIdKey); + + // 소셜 인증 + SocialLoginRequestDto socialAuthRequest = new SocialLoginRequestDto( + request.getProvider(), + request.getOauthToken(), + request.getAuthorizationCode(), + request.getPlatformType() + ); + SocialAuthInfo socialAuthInfo = socialAuthenticationManager.authenticate(socialAuthRequest); + + // 이미 연동된 소셜 계정인지 확인 + if (userSocialQueryRepository.existsBySocialProviderAndSocialId( + socialAuthInfo.getProvider(), socialAuthInfo.getSocialId() + )) { + throw new CustomException(ErrorCode.SOCIAL_ID_DUPLICATED); + } + + // 소셜 계정 이메일 중복 확인 + String email = socialAuthInfo.getEmail(); + if (ObjectUtils.isNotEmpty(email)) { + userQueryRepository.findByEmail(email) + .ifPresent(existing -> { throw new CustomException(ErrorCode.EMAIL_DUPLICATED); }); + } + + // 사용자 생성 + User user = userRepository.save(User.createWithSocial( + contact, + request.getName(), + request.getNickname(), + request.getGender(), + request.getBirthday(), + email + )); + + // 소셜 계정 연동 + UserSocial userSocial = UserSocial.create( + user, + socialAuthInfo.getProvider(), + socialAuthInfo.getSocialId(), + socialAuthInfo.getRefreshToken() + ); + user.addUserSocial(userSocial); + + // 회원가입 세션 삭제 + String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; + cacheRepository.deleteAll(Arrays.asList(sessionIdKey, contactKey)); + + Authorization authorization = authService.generateAuthorization(user, TokenScope.APP); + authLogRepository.save(AuthLog.create(user, authorization, AuthLogType.LOGIN)); + + return GenerateTokenResponseDto.of(authorization); + } + + private void validateDuplication(CreateUserWithSocialRequestDto request, String contact, String sessionIdKey) { + String contactKey = CONTACT_INDEX_KEY_PREFIX + contact; + List keysToDelete = Arrays.asList(sessionIdKey, contactKey); + + // 닉네임 중복 확인 + if (userQueryRepository.findByNickname(request.getNickname()).isPresent()) { + cacheRepository.deleteAll(keysToDelete); + throw new CustomException(ErrorCode.NICKNAME_DUPLICATED); + } + + // 연락처 중복 확인 + if (userQueryRepository.findByContact(contact).isPresent()) { + cacheRepository.deleteAll(keysToDelete); + throw new CustomException(ErrorCode.USER_CONTACT_DUPLICATED); + } + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java index d8075fb3..3bdcd242 100644 --- a/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/LoginWithPassword.java @@ -1,22 +1,23 @@ package com.dreamteam.alter.application.user.usecase; -import com.dreamteam.alter.adapter.inbound.general.user.dto.LoginWithPasswordRequestDto; import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; -import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; -import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.adapter.inbound.general.user.dto.LoginWithPasswordRequestDto; +import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.common.exception.CustomException; import com.dreamteam.alter.common.exception.ErrorCode; -import com.dreamteam.alter.application.auth.service.AuthService; import com.dreamteam.alter.domain.auth.entity.AuthLog; import com.dreamteam.alter.domain.auth.entity.Authorization; import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; import com.dreamteam.alter.domain.auth.type.AuthLogType; import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; @Service("loginWithPassword") @RequiredArgsConstructor @@ -33,7 +34,7 @@ public GenerateTokenResponseDto execute(LoginWithPasswordRequestDto request) { User user = userQueryRepository.findByContact(request.getContact()) .orElseThrow(() -> new CustomException(ErrorCode.INVALID_LOGIN_INFO)); - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + if (ObjectUtils.isEmpty(user.getPassword()) || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new CustomException(ErrorCode.INVALID_LOGIN_INFO); } diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java new file mode 100644 index 00000000..6bef9fe0 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccount.java @@ -0,0 +1,42 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; + +@Service("unlinkSocialAccount") +@RequiredArgsConstructor +@Transactional +public class UnlinkSocialAccount implements UnlinkSocialAccountUseCase { + + private final UserSocialQueryRepository userSocialQueryRepository; + private final UserSocialRepository userSocialRepository; + + @Override + public void execute(AppActor actor, UnlinkSocialAccountRequestDto request) { + User user = actor.getUser(); + + UserSocial userSocial = userSocialQueryRepository + .findByUserIdAndSocialProvider(user.getId(), request.getProvider()) + .orElseThrow(() -> new CustomException(ErrorCode.SOCIAL_ACCOUNT_NOT_LINKED)); + + if (ObjectUtils.isEmpty(user.getPassword())) { + long count = userSocialQueryRepository.countByUserIdForUpdate(user.getId()); + if (count <= 1) { + throw new CustomException(ErrorCode.SOCIAL_UNLINK_NOT_ALLOWED); + } + } + + userSocialRepository.delete(userSocial); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java new file mode 100644 index 00000000..2f02913e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/user/usecase/UpdatePassword.java @@ -0,0 +1,40 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.common.util.PasswordValidator; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.inbound.UpdatePasswordUseCase; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("updatePassword") +@RequiredArgsConstructor +@Transactional +public class UpdatePassword implements UpdatePasswordUseCase { + + private final PasswordEncoder passwordEncoder; + + @Override + public void execute(AppActor actor, UpdatePasswordRequestDto request) { + User user = actor.getUser(); + + if (ObjectUtils.isNotEmpty(user.getPassword())) { + if (ObjectUtils.isEmpty(request.getCurrentPassword()) || + !passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { + throw new CustomException(ErrorCode.INVALID_CURRENT_PASSWORD); + } + } + + if (!PasswordValidator.isValid(request.getNewPassword())) { + throw new CustomException(ErrorCode.INVALID_PASSWORD_FORMAT); + } + + user.updatePassword(passwordEncoder.encode(request.getNewPassword())); + } +} diff --git a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java index d3702580..700f91d6 100644 --- a/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java +++ b/src/main/java/com/dreamteam/alter/common/exception/ErrorCode.java @@ -21,6 +21,9 @@ public enum ErrorCode { SOCIAL_PROVIDER_ALREADY_LINKED(400, "A012", "이미 연동되어 있는 소셜 플랫폼입니다."), PASSWORD_RESET_SESSION_NOT_EXIST(400, "A013", "비밀번호 재설정 세션이 존재하지 않거나 만료되었습니다."), INVALID_PASSWORD_FORMAT(400, "A014", "비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."), + SOCIAL_ACCOUNT_NOT_LINKED(400, "A015", "연동되지 않은 소셜 플랫폼입니다."), + SOCIAL_UNLINK_NOT_ALLOWED(400, "A016", "비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다."), + INVALID_CURRENT_PASSWORD(400, "A017", "현재 비밀번호가 올바르지 않습니다."), ILLEGAL_ARGUMENT(400, "B001", "잘못된 요청입니다."), REFRESH_TOKEN_REQUIRED(400, "B002", "RefreshToken을 통해 요청해야 합니다."), diff --git a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java index 7a6d86f5..b25244d6 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/entity/User.java +++ b/src/main/java/com/dreamteam/alter/domain/user/entity/User.java @@ -31,7 +31,7 @@ public class User { @Column(name = "email", length = 255, nullable = true, unique = true) private String email; - @Column(name = "password", length = 255, nullable = false) + @Column(name = "password", length = 255, nullable = true) private String password; @Column(name = "name", length = 12, nullable = false) @@ -99,6 +99,27 @@ public static User create( .build(); } + public static User createWithSocial( + String contact, + String name, + String nickname, + UserGender gender, + String birthday, + String email + ) { + return User.builder() + .email(email) + .password(null) + .name(name) + .nickname(nickname) + .contact(contact) + .birthday(birthday) + .gender(gender) + .role(UserRole.ROLE_USER) + .status(UserStatus.ACTIVE) + .build(); + } + public void updateEmail(String email) { this.email = email; } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java new file mode 100644 index 00000000..f226519b --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/CreateUserWithSocialUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; + +public interface CreateUserWithSocialUseCase { + GenerateTokenResponseDto execute(CreateUserWithSocialRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java new file mode 100644 index 00000000..93aecc4f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UnlinkSocialAccountUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface UnlinkSocialAccountUseCase { + void execute(AppActor actor, UnlinkSocialAccountRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java new file mode 100644 index 00000000..196544ec --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/inbound/UpdatePasswordUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.user.port.inbound; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.domain.user.context.AppActor; + +public interface UpdatePasswordUseCase { + void execute(AppActor actor, UpdatePasswordRequestDto request); +} diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java index 99699956..5b379fbf 100644 --- a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialQueryRepository.java @@ -8,8 +8,14 @@ public interface UserSocialQueryRepository { Optional findBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - + + Optional findByUserIdAndSocialProvider(Long userId, SocialProvider socialProvider); + boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - + boolean existsByUserAndSocialProvider(Long userId, SocialProvider socialProvider); + + long countByUserId(Long userId); + + long countByUserIdForUpdate(Long userId); } diff --git a/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java new file mode 100644 index 00000000..b4f438b3 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/user/port/outbound/UserSocialRepository.java @@ -0,0 +1,7 @@ +package com.dreamteam.alter.domain.user.port.outbound; + +import com.dreamteam.alter.domain.user.entity.UserSocial; + +public interface UserSocialRepository { + void delete(UserSocial userSocial); +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java new file mode 100644 index 00000000..2e2555e3 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/CreateUserWithSocialTests.java @@ -0,0 +1,258 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.auth.dto.SocialAuthInfo; +import com.dreamteam.alter.adapter.inbound.general.user.dto.CreateUserWithSocialRequestDto; +import com.dreamteam.alter.adapter.inbound.general.user.dto.GenerateTokenResponseDto; +import com.dreamteam.alter.adapter.outbound.user.persistence.SignupSessionCacheRepository; +import com.dreamteam.alter.application.auth.manager.SocialAuthenticationManager; +import com.dreamteam.alter.application.auth.service.AuthService; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.auth.entity.Authorization; +import com.dreamteam.alter.domain.auth.port.outbound.AuthLogRepository; +import com.dreamteam.alter.domain.auth.type.TokenScope; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.port.outbound.UserQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.type.PlatformType; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import com.dreamteam.alter.domain.user.type.UserGender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CreateUserWithSocial 테스트") +class CreateUserWithSocialTests { + + @Mock + private UserRepository userRepository; + + @Mock + private UserQueryRepository userQueryRepository; + + @Mock + private UserSocialQueryRepository userSocialQueryRepository; + + @Mock + private SocialAuthenticationManager socialAuthenticationManager; + + @Mock + private AuthService authService; + + @Mock + private AuthLogRepository authLogRepository; + + @Mock + private SignupSessionCacheRepository cacheRepository; + + @InjectMocks + private CreateUserWithSocial createUserWithSocial; + + private CreateUserWithSocialRequestDto request; + + @BeforeEach + void setUp() { + request = new CreateUserWithSocialRequestDto( + "signup-session-id", + SocialProvider.KAKAO, + null, + "auth-code", + PlatformType.WEB, + "김철수", + "유땡땡", + UserGender.GENDER_MALE, + "19900101" + ); + } + + private SocialAuthInfo createSocialAuthInfo(String socialId, String email, String refreshToken) { + SocialAuthInfo authInfo = mock(SocialAuthInfo.class); + given(authInfo.getProvider()).willReturn(SocialProvider.KAKAO); + given(authInfo.getSocialId()).willReturn(socialId); + if (email != null) given(authInfo.getEmail()).willReturn(email); + if (refreshToken != null) given(authInfo.getRefreshToken()).willReturn(refreshToken); + return authInfo; + } + + @Nested + @DisplayName("execute") + class ExecuteTests { + + @Test + @DisplayName("회원가입 세션이 존재하지 않을 경우 SIGNUP_SESSION_NOT_EXIST 예외 발생") + void execute_signupSessionNotFound_throwsSignupSessionNotExist() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn(null); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SIGNUP_SESSION_NOT_EXIST)); + + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("닉네임이 중복될 경우 NICKNAME_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void execute_nicknameDuplicated_throwsNicknameDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.NICKNAME_DUPLICATED)); + + then(cacheRepository).should().deleteAll(anyList()); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("연락처가 중복될 경우 USER_CONTACT_DUPLICATED 예외 발생 및 Redis 세션 삭제") + void execute_contactDuplicated_throwsUserContactDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.USER_CONTACT_DUPLICATED)); + + then(cacheRepository).should().deleteAll(anyList()); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("이미 등록된 소셜 계정일 경우 SOCIAL_ID_DUPLICATED 예외 발생") + void execute_socialIdAlreadyRegistered_throwsSocialIdDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, null); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.SOCIAL_ID_DUPLICATED)); + + then(cacheRepository).should(never()).deleteAll(anyList()); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("소셜 계정 이메일이 이미 가입된 경우 EMAIL_DUPLICATED 예외 발생") + void execute_socialEmailAlreadyExists_throwsEmailDuplicated() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", null); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + given(userQueryRepository.findByEmail("social@example.com")).willReturn(Optional.of(mock(User.class))); + + // when & then + assertThatThrownBy(() -> createUserWithSocial.execute(request)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(ErrorCode.EMAIL_DUPLICATED)); + + then(cacheRepository).should(never()).deleteAll(anyList()); + then(userRepository).should(never()).save(any()); + } + + @Test + @DisplayName("유효한 입력으로 소셜 회원가입 성공 - 소셜 계정 이메일 자동 저장") + void execute_withValidInput_succeeds() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", "social@example.com", "kakao-refresh-token"); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + given(userQueryRepository.findByEmail("social@example.com")).willReturn(Optional.empty()); + + User savedUser = mock(User.class); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + Authorization authorization = mock(Authorization.class); + given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + + // when + GenerateTokenResponseDto result = createUserWithSocial.execute(request); + + // then + assertThat(result).isNotNull(); + then(userRepository).should().save(any(User.class)); + then(userQueryRepository).should().findByEmail("social@example.com"); + then(authService).should().generateAuthorization(savedUser, TokenScope.APP); + then(authLogRepository).should().save(any()); + then(cacheRepository).should().deleteAll(anyList()); + } + + @Test + @DisplayName("소셜 계정에 이메일이 없을 경우 이메일 없이 회원가입 성공") + void execute_withNoEmailFromSocial_succeeds() { + // given + given(cacheRepository.get("SIGNUP:PENDING:signup-session-id")).willReturn("01012345678"); + given(userQueryRepository.findByNickname("유땡땡")).willReturn(Optional.empty()); + given(userQueryRepository.findByContact("01012345678")).willReturn(Optional.empty()); + + SocialAuthInfo authInfo = createSocialAuthInfo("kakao-social-id", null, "kakao-refresh-token"); + given(socialAuthenticationManager.authenticate(any())).willReturn(authInfo); + given(userSocialQueryRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, "kakao-social-id")) + .willReturn(false); + + User savedUser = mock(User.class); + given(userRepository.save(any(User.class))).willReturn(savedUser); + + Authorization authorization = mock(Authorization.class); + given(authService.generateAuthorization(eq(savedUser), eq(TokenScope.APP))).willReturn(authorization); + + // when + GenerateTokenResponseDto result = createUserWithSocial.execute(request); + + // then + assertThat(result).isNotNull(); + then(userRepository).should().save(any(User.class)); + then(userQueryRepository).should(never()).findByEmail(any()); + then(cacheRepository).should().deleteAll(anyList()); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java new file mode 100644 index 00000000..e754ecc8 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UnlinkSocialAccountTests.java @@ -0,0 +1,139 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import com.dreamteam.alter.domain.user.entity.UserSocial; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialQueryRepository; +import com.dreamteam.alter.domain.user.port.outbound.UserSocialRepository; +import com.dreamteam.alter.domain.user.type.SocialProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UnlinkSocialAccount 테스트") +class UnlinkSocialAccountTests { + + @Mock + private UserSocialQueryRepository userSocialQueryRepository; + + @Mock + private UserSocialRepository userSocialRepository; + + @InjectMocks + private UnlinkSocialAccount unlinkSocialAccount; + + private AppActor actor; + private User user; + private UserSocial userSocial; + private UnlinkSocialAccountRequestDto request; + + @BeforeEach + void setUp() { + user = mock(User.class); + actor = mock(AppActor.class); + userSocial = mock(UserSocial.class); + request = new UnlinkSocialAccountRequestDto(SocialProvider.KAKAO); + + given(actor.getUser()).willReturn(user); + given(user.getId()).willReturn(1L); + } + + @Test + @DisplayName("연동되지 않은 provider 해제 시 SOCIAL_ACCOUNT_NOT_LINKED 예외 발생") + void execute_withNotLinkedProvider_throwsException() { + // given + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> unlinkSocialAccount.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SOCIAL_ACCOUNT_NOT_LINKED); + then(userSocialRepository).shouldHaveNoInteractions(); + } + + @Nested + @DisplayName("비밀번호가 있는 사용자") + class UserWithPassword { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn("encodedPassword"); + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.of(userSocial)); + } + + @Test + @DisplayName("연동된 소셜 계정을 정상 해제한다") + void execute_withLinkedSocial_succeeds() { + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + then(userSocialQueryRepository).should(never()).countByUserIdForUpdate(any()); + } + + @Test + @DisplayName("소셜 계정이 1개뿐이어도 비밀번호 있는 사용자는 해제 가능하다") + void execute_withSingleSocial_succeeds() { + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + then(userSocialQueryRepository).should(never()).countByUserIdForUpdate(any()); + } + } + + @Nested + @DisplayName("비밀번호가 없는 소셜 전용 사용자") + class SocialOnlyUser { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn(null); + given(userSocialQueryRepository.findByUserIdAndSocialProvider(1L, SocialProvider.KAKAO)) + .willReturn(Optional.of(userSocial)); + } + + @Test + @DisplayName("소셜 계정이 2개일 때 1개 해제 성공") + void execute_withMultipleSocials_succeeds() { + // given + given(userSocialQueryRepository.countByUserIdForUpdate(1L)).willReturn(2L); + + // when & then + assertThatNoException().isThrownBy(() -> unlinkSocialAccount.execute(actor, request)); + then(userSocialRepository).should().delete(userSocial); + } + + @Test + @DisplayName("마지막 소셜 계정(1개) 해제 시도 시 SOCIAL_UNLINK_NOT_ALLOWED 예외 발생") + void execute_withSingleSocial_throwsException() { + // given + given(userSocialQueryRepository.countByUserIdForUpdate(1L)).willReturn(1L); + + // when & then + assertThatThrownBy(() -> unlinkSocialAccount.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.SOCIAL_UNLINK_NOT_ALLOWED); + then(userSocialRepository).shouldHaveNoInteractions(); + } + } +} diff --git a/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java new file mode 100644 index 00000000..26a1dd19 --- /dev/null +++ b/src/test/java/com/dreamteam/alter/application/user/usecase/UpdatePasswordTests.java @@ -0,0 +1,145 @@ +package com.dreamteam.alter.application.user.usecase; + +import com.dreamteam.alter.adapter.inbound.general.user.dto.UpdatePasswordRequestDto; +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.user.context.AppActor; +import com.dreamteam.alter.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UpdatePassword 테스트") +class UpdatePasswordTests { + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UpdatePassword updatePassword; + + private AppActor actor; + private User user; + + @BeforeEach + void setUp() { + user = mock(User.class); + actor = mock(AppActor.class); + given(actor.getUser()).willReturn(user); + } + + @Nested + @DisplayName("비밀번호가 있는 사용자") + class UserWithPassword { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn("encodedCurrentPassword"); + } + + @Test + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호로 변경 성공") + void execute_withCorrectCurrentPassword_succeeds() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("currentPass1!", "newPass1!"); + given(passwordEncoder.matches("currentPass1!", "encodedCurrentPassword")).willReturn(true); + given(passwordEncoder.encode("newPass1!")).willReturn("encodedNewPassword"); + + // when & then + assertThatNoException().isThrownBy(() -> updatePassword.execute(actor, request)); + then(user).should().updatePassword("encodedNewPassword"); + } + + @Test + @DisplayName("틀린 현재 비밀번호 입력 시 INVALID_CURRENT_PASSWORD 예외 발생") + void execute_withWrongCurrentPassword_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("wrongPass1!", "newPass1!"); + given(passwordEncoder.matches("wrongPass1!", "encodedCurrentPassword")).willReturn(false); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); + then(user).should(never()).updatePassword(anyString()); + } + + @Test + @DisplayName("현재 비밀번호를 null로 전송 시 INVALID_CURRENT_PASSWORD 예외 발생") + void execute_withNullCurrentPassword_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "newPass1!"); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_CURRENT_PASSWORD); + then(user).should(never()).updatePassword(anyString()); + } + + @Test + @DisplayName("유효하지 않은 새 비밀번호 형식으로 변경 시 INVALID_PASSWORD_FORMAT 예외 발생") + void execute_withInvalidNewPasswordFormat_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto("currentPass1!", "short"); + given(passwordEncoder.matches("currentPass1!", "encodedCurrentPassword")).willReturn(true); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); + then(user).should(never()).updatePassword(anyString()); + } + } + + @Nested + @DisplayName("비밀번호가 없는 소셜 전용 사용자") + class SocialOnlyUser { + + @BeforeEach + void setUp() { + given(user.getPassword()).willReturn(null); + } + + @Test + @DisplayName("currentPassword 없이 유효한 새 비밀번호만으로 설정 성공") + void execute_withoutCurrentPassword_succeeds() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "newPass1!"); + given(passwordEncoder.encode("newPass1!")).willReturn("encodedNewPassword"); + + // when & then + assertThatNoException().isThrownBy(() -> updatePassword.execute(actor, request)); + then(user).should().updatePassword("encodedNewPassword"); + then(passwordEncoder).should(org.mockito.Mockito.never()).matches(anyString(), anyString()); + } + + @Test + @DisplayName("유효하지 않은 새 비밀번호 형식으로 설정 시 INVALID_PASSWORD_FORMAT 예외 발생") + void execute_withInvalidNewPasswordFormat_throwsException() { + // given + UpdatePasswordRequestDto request = new UpdatePasswordRequestDto(null, "weak"); + + // when & then + assertThatThrownBy(() -> updatePassword.execute(actor, request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PASSWORD_FORMAT); + then(user).should(never()).updatePassword(anyString()); + } + } +}