diff --git a/common-module/src/main/java/com/comatching/common/util/CookieUtil.java b/common-module/src/main/java/com/comatching/common/util/CookieUtil.java index a369c37..b88b45d 100644 --- a/common-module/src/main/java/com/comatching/common/util/CookieUtil.java +++ b/common-module/src/main/java/com/comatching/common/util/CookieUtil.java @@ -14,10 +14,10 @@ public static ResponseCookie createAccessTokenCookie(String accessToken) { return ResponseCookie.from("accessToken", accessToken) .path("/") .httpOnly(true) - .secure(false) // HTTPS 환경에서는 true + .secure(false) .maxAge(Duration.ofDays(1).toSeconds()) .sameSite("Lax") - .domain("comatching.site") + // .domain("comatching.site") .build(); } @@ -26,7 +26,7 @@ public static ResponseCookie createRefreshTokenCookie(String refreshToken) { return ResponseCookie.from("refreshToken", refreshToken) .path("/api/auth") .httpOnly(true) - .secure(false) // HTTPS 환경에서는 true + .secure(false) .maxAge(Duration.ofDays(7).toSeconds()) .sameSite("Lax") .domain("comatching.site") diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index 2ce3c5d..1960afa 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -17,6 +17,7 @@ spring: allowedOrigins: - "https://comatching.site" - "http://localhost:3000" + - "http://localhost:5173" allowedMethods: [GET, POST, PUT, DELETE, OPTIONS] allowedHeaders: "*" allowCredentials: true @@ -26,7 +27,7 @@ spring: - id: user-service-public uri: http://user-service:9000 predicates: - - Path=/api/auth/login, /api/auth/signup, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css + - Path=/api/auth/login, /api/auth/signup, /api/auth/signup/nickname/availability, /api/auth/participants, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css - id: user-service-protected uri: http://user-service:9000 diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 466d30f..960356c 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: - id: user-service-public uri: http://localhost:9000 predicates: - - Path=/api/auth/login, /api/auth/signup, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css + - Path=/api/auth/login, /api/auth/signup, /api/auth/signup/nickname/availability, /api/auth/participants, /api/auth/email/**, /oauth2/**, /login/**, /default-ui.css - id: user-service-protected uri: http://localhost:9000 diff --git a/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java b/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java new file mode 100644 index 0000000..0b3f428 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/auth/dto/NicknameAvailabilityResponse.java @@ -0,0 +1,6 @@ +package com.comatching.user.domain.auth.dto; + +public record NicknameAvailabilityResponse( + boolean available +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java b/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java deleted file mode 100644 index c6f8c8d..0000000 --- a/user-service/src/main/java/com/comatching/user/domain/member/component/RandomNicknameGenerator.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.comatching.user.domain.member.component; - -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; - -import org.springframework.stereotype.Component; - -@Component -public class RandomNicknameGenerator { - - private static final List DETERMINERS = List.of( - "노래하는", "춤추는", "뛰어노는", "구르는", "빙글도는", "날아가는", "흐느적이는", "뒹구는", "달리는", "흔들리는", - "웃는", "반짝이는", "두근대는", "깔깔대는", "놀라는", "감탄하는", "감동한", "행복한", "수줍은", "즐거운", - "반짝반짝한", "포근한", "시원한", "따뜻한", "향기나는", "알록달록한", "몽글몽글한", "부드러운", "촉촉한", - "장난치는", "휘파람 부는", "손흔드는", "인사하는", "하품하는", "손뻗는", "끄덕이는", "기지개 켜는", "숨바꼭질하는", - "멋부린", "잠에서 깬", "바람 타는", "구경하는", "편지 쓰는", "노을 보는", "초콜릿 든", "선물 고르는" - ); - - private static final List ANIMALS_AND_THINGS = List.of( - "나무늘보", "햄스터", "다람쥐", "고슴도치", "기린", "오리", "판다", "앵무새", "물개", "코알라", "돌고래", "코끼리", "라마", "고래", "아기곰", - "데이지", "민들레", "코스모스", "라벤더", "동백꽃", "연꽃", "수국", "벚꽃잎", "클로버", "벚꽃", "해바라기", - "파도", "구름", "별똥별", "바람", "노을", "햇살", "모래성", "무지개", "달빛", "눈송이", - "화가", "작가", "바리스타", "제빵사", "소방관", "탐험가", "마술사", "사진작가", "연기자", "시인", "조향사", "고고학자" - ); - - public String generate() { - String determiner = DETERMINERS.get(ThreadLocalRandom.current().nextInt(DETERMINERS.size())); - String animalOrThing = ANIMALS_AND_THINGS.get(ThreadLocalRandom.current().nextInt(ANIMALS_AND_THINGS.size())); - int randomNumber = ThreadLocalRandom.current().nextInt(10000); - - return determiner + " " + animalOrThing + randomNumber; - } -} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java b/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java index 78e972a..3e74103 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/entity/Profile.java @@ -9,13 +9,10 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import com.comatching.common.domain.enums.ContactFrequency; import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.HobbyCategory; -import com.comatching.common.domain.enums.ProfileTagCategory; import com.comatching.common.domain.enums.SocialAccountType; import com.comatching.common.exception.BusinessException; import com.comatching.user.global.exception.UserErrorCode; @@ -161,7 +158,7 @@ public void update( } public void addHobbies(List newHobbies) { - if (newHobbies == null || newHobbies.isEmpty() || newHobbies.size() > 10 || newHobbies.size() < 1) { + if (newHobbies == null || newHobbies.isEmpty() || newHobbies.size() > 10 || newHobbies.size() < 2) { throw new BusinessException(UserErrorCode.INVALID_HOBBY_COUNT); } @@ -179,17 +176,8 @@ public List getHobbyCategories() { } public void addTags(List newTags) { - if (newTags != null) { - Map countByCategory = newTags.stream() - .collect(Collectors.groupingBy( - t -> t.getTag().getGroup().getCategory(), - Collectors.counting() - )); - countByCategory.forEach((cat, count) -> { - if (count > 3) { - throw new BusinessException(UserErrorCode.TAG_LIMIT_PER_CATEGORY_EXCEEDED); - } - }); + if (newTags != null && newTags.size() > 5) { + throw new BusinessException(UserErrorCode.TAG_LIMIT_PER_CATEGORY_EXCEEDED); } this.tags.clear(); if (newTags != null) { diff --git a/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java b/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java index 2fa31e8..b2176c3 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/repository/ProfileRepository.java @@ -14,6 +14,8 @@ public interface ProfileRepository extends JpaRepository { Optional findByMemberId(Long memberId); + boolean existsByNickname(String nickname); + boolean existsByNicknameAndMemberIdNot(String nickname, Long memberId); @Query("SELECT DISTINCT p FROM Profile p " + "JOIN FETCH p.member m " + diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java index 3ad7263..50eb651 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileManageService.java @@ -8,6 +8,7 @@ public interface ProfileManageService { ProfileResponse getProfile(Long memberId); + boolean isNicknameAvailable(String nickname); List getProfilesByIds(List memberIds); diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java index 55e3ecf..65bf15c 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/ProfileServiceImpl.java @@ -1,7 +1,9 @@ package com.comatching.user.domain.member.service; import java.util.List; -import java.util.concurrent.ThreadLocalRandom; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +17,8 @@ import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.dto.member.ProfileTagDto; import com.comatching.common.exception.BusinessException; +import com.comatching.common.service.S3Service; import com.comatching.user.domain.event.UserEventPublisher; -import com.comatching.user.domain.member.component.RandomNicknameGenerator; import com.comatching.user.domain.member.dto.ProfileUpdateRequest; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; @@ -34,11 +36,18 @@ @Transactional public class ProfileServiceImpl implements ProfileCreateService, ProfileManageService { + private static final String DEFAULT_IMAGE_VALUE = "default"; + private static final String DEFAULT_IMAGE_PREFIX = "default_"; + private static final String DEFAULT_IMAGE_EXTENSION = ".png"; + private static final Set DEFAULT_IMAGE_ANIMALS = Set.of( + "dog", "cat", "dinosaur", "otter", "bear", "fox", "penguin", "wolf", "rabbit", "snake", "horse", "frog" + ); + private final MemberRepository memberRepository; private final ProfileRepository profileRepository; private final UserEventPublisher eventPublisher; private final ProfileImageProperties profileImageProperties; - private final RandomNicknameGenerator nicknameGenerator; + private final S3Service s3Service; @Override public ProfileResponse createProfile(Long memberId, ProfileCreateRequest request) { @@ -70,6 +79,13 @@ public ProfileResponse getProfile(Long memberId) { return toProfileResponse(profile); } + @Override + @Transactional(readOnly = true) + public boolean isNicknameAvailable(String nickname) { + String normalizedNickname = normalizeNickname(nickname); + return !profileRepository.existsByNickname(normalizedNickname); + } + @Override @Transactional(readOnly = true) public List getProfilesByIds(List memberIds) { @@ -87,11 +103,14 @@ public ProfileResponse updateProfile(Long memberId, ProfileUpdateRequest request Profile profile = profileRepository.findByMemberId(memberId) .orElseThrow(() -> new BusinessException(UserErrorCode.PROFILE_NOT_EXISTS)); + String normalizedNickname = normalizeNicknameForUpdate(request.nickname(), profile.getNickname(), memberId); + String profileImageUrl = resolveProfileImageUrlForUpdate(request.profileImageUrl()); + profile.update( - request.nickname(), + normalizedNickname, request.intro(), request.mbti(), - request.profileImageUrl(), + profileImageUrl, request.gender(), request.birthDate(), request.socialType(), @@ -139,13 +158,10 @@ private void publishMatchingEvent(Profile profile) { private Profile saveProfile(ProfileCreateRequest request, Member member) { + String finalNickname = normalizeNickname(request.nickname()); + validateNicknameDuplicateOnCreate(finalNickname); String finalProfileImageUrl = resolveProfileImageUrl(request.profileImageKey()); - String finalNickname = request.nickname(); - if (!StringUtils.hasText(finalNickname)) { - finalNickname = nicknameGenerator.generate(); - } - Profile profile = Profile.builder() .member(member) .nickname(finalNickname) @@ -167,20 +183,72 @@ private Profile saveProfile(ProfileCreateRequest request, Member member) { return profileRepository.save(profile); } - private String resolveProfileImageUrl(String inputImageKey) { - if (StringUtils.hasText(inputImageKey)) { - return profileImageProperties.baseUrl() + inputImageKey; + private String normalizeNickname(String nickname) { + if (!StringUtils.hasText(nickname)) { + throw new BusinessException(UserErrorCode.INVALID_NICKNAME); } - List defaults = profileImageProperties.filenames(); - if (defaults == null || defaults.isEmpty()) { + return nickname.trim(); + } + + private String normalizeNicknameForUpdate(String nickname, String currentNickname, Long memberId) { + if (nickname == null) { return null; } - int randomIndex = ThreadLocalRandom.current().nextInt(defaults.size()); - String selectedFilename = defaults.get(randomIndex); + String normalizedNickname = normalizeNickname(nickname); + if (!Objects.equals(normalizedNickname, currentNickname) + && profileRepository.existsByNicknameAndMemberIdNot(normalizedNickname, memberId)) { + throw new BusinessException(UserErrorCode.DUPLICATE_NICKNAME); + } + + return normalizedNickname; + } + + private void validateNicknameDuplicateOnCreate(String nickname) { + if (profileRepository.existsByNickname(nickname)) { + throw new BusinessException(UserErrorCode.DUPLICATE_NICKNAME); + } + } - return profileImageProperties.baseUrl() + selectedFilename; + private String resolveProfileImageUrlForUpdate(String profileImageValue) { + if (profileImageValue == null) { + return null; + } + return resolveProfileImageUrl(profileImageValue); + } + + private String resolveProfileImageUrl(String profileImageValue) { + if (!StringUtils.hasText(profileImageValue)) { + return buildDefaultProfileImageUrl(DEFAULT_IMAGE_VALUE + DEFAULT_IMAGE_EXTENSION); + } + + String normalizedValue = profileImageValue.trim(); + String loweredValue = normalizedValue.toLowerCase(Locale.ROOT); + + if (DEFAULT_IMAGE_VALUE.equals(loweredValue)) { + return buildDefaultProfileImageUrl(DEFAULT_IMAGE_VALUE + DEFAULT_IMAGE_EXTENSION); + } + + if (loweredValue.startsWith(DEFAULT_IMAGE_PREFIX)) { + String animalName = loweredValue.substring(DEFAULT_IMAGE_PREFIX.length()); + if (DEFAULT_IMAGE_ANIMALS.contains(animalName)) { + return buildDefaultProfileImageUrl(animalName + DEFAULT_IMAGE_EXTENSION); + } + } + + if (normalizedValue.startsWith("http://") || normalizedValue.startsWith("https://")) { + return normalizedValue; + } + + return s3Service.getFileUrl(normalizedValue); + } + + private String buildDefaultProfileImageUrl(String filename) { + if (!StringUtils.hasText(profileImageProperties.baseUrl())) { + return null; + } + return profileImageProperties.baseUrl() + filename; } private static List getProfileHobbies(List hobbies) { diff --git a/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java b/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java index 70192ab..a8acdbd 100644 --- a/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java +++ b/user-service/src/main/java/com/comatching/user/global/config/SecurityConfig.java @@ -57,6 +57,11 @@ public AuthenticationManager authenticationManager() throws Exception { return authenticationConfiguration.getAuthenticationManager(); } + @Bean + public LoginSuccessHandler loginSuccessHandler() { + return new LoginSuccessHandler(jwtUtil, objectMapper, refreshTokenRepository); + } + @Bean public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter() throws Exception { @@ -67,7 +72,7 @@ public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePassword filter.setAuthenticationManager(authenticationManager()); // 핸들러 설정 - filter.setAuthenticationSuccessHandler(new LoginSuccessHandler(jwtUtil, objectMapper, refreshTokenRepository)); + filter.setAuthenticationSuccessHandler(loginSuccessHandler()); filter.setAuthenticationFailureHandler(new LoginFailureHandler(objectMapper)); return filter; diff --git a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java index 201bf75..e25339f 100644 --- a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java +++ b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java @@ -26,8 +26,10 @@ public enum UserErrorCode implements ErrorCode { PROFILE_ALREADY_EXISTS("MEM-003", HttpStatus.BAD_REQUEST, "프로필이 이미 존재합니다."), PROFILE_NOT_EXISTS("MEM-004", HttpStatus.BAD_REQUEST, "프로필이 존재하지 않습니다."), INVALID_SOCIAL_INFO("MEM-005", HttpStatus.BAD_REQUEST, "소셜 정보는 타입과 ID가 함께 입력되어야 합니다."), - INVALID_HOBBY_COUNT("MEM-006", HttpStatus.BAD_REQUEST, "취미는 최소 2개 이상 최대 5개 이하를 등록해야 합니다."), - TAG_LIMIT_PER_CATEGORY_EXCEEDED("MEM-007", HttpStatus.BAD_REQUEST, "카테고리별 태그는 최대 3개까지 선택 가능합니다."), + INVALID_HOBBY_COUNT("MEM-006", HttpStatus.BAD_REQUEST, "취미는 최소 2개 이상 최대 10개 이하를 등록해야 합니다."), + TAG_LIMIT_PER_CATEGORY_EXCEEDED("MEM-007", HttpStatus.BAD_REQUEST, "장점 태그는 전체 최대 5개까지 선택 가능합니다."), + DUPLICATE_NICKNAME("MEM-008", HttpStatus.BAD_REQUEST, "이미 사용 중인 닉네임입니다."), + INVALID_NICKNAME("MEM-009", HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."), ; private final String code; diff --git a/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java b/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java index 1d9d567..00ea90c 100644 --- a/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java +++ b/user-service/src/main/java/com/comatching/user/global/security/handler/LoginSuccessHandler.java @@ -2,6 +2,7 @@ import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -27,11 +28,15 @@ public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper objectMapper; private final RefreshTokenRepository refreshTokenRepository; + @Value("${client.url}") + private String clientUrl; + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { UserPrincipal principal = (UserPrincipal)authentication.getPrincipal(); + String role = principal.getRole(); // 토큰 생성 String accessToken = jwtUtil.createAccessToken(principal.getId(), principal.getUsername(), principal.getRole(), @@ -52,12 +57,19 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.addHeader("Set-Cookie", accessCookie.toString()); response.addHeader("Set-Cookie", refreshCookie.toString()); - // json 응답 - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding("UTF-8"); - ApiResponse apiResponse = ApiResponse.ok(); + if (role.equals("ROLE_ADMIN")) { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + ApiResponse apiResponse = ApiResponse.ok(); + objectMapper.writeValue(response.getWriter(), apiResponse); + return; + } - objectMapper.writeValue(response.getWriter(), apiResponse); + if (role.equals("ROLE_GUEST")) { + response.sendRedirect(clientUrl + "/onboarding"); + } else { + response.sendRedirect(clientUrl + "/main"); + } } } diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java index 9923c56..964ef41 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AuthController.java @@ -4,20 +4,24 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.comatching.user.domain.auth.dto.ChangePasswordRequest; import com.comatching.user.domain.auth.dto.CompleteSignupResponse; +import com.comatching.user.domain.auth.dto.NicknameAvailabilityResponse; import com.comatching.user.domain.auth.dto.PasswordResetCodeRequest; import com.comatching.user.domain.auth.dto.ResetPasswordRequest; import com.comatching.user.domain.auth.dto.TokenResponse; import com.comatching.user.domain.auth.service.AuthService; import com.comatching.user.domain.auth.service.SignupService; import com.comatching.user.domain.mail.service.EmailService; +import com.comatching.user.domain.member.service.ProfileManageService; import com.comatching.common.annotation.CurrentMember; import com.comatching.common.annotation.RequireRole; import com.comatching.common.domain.enums.MemberRole; @@ -39,6 +43,7 @@ public class AuthController { private final AuthService authService; private final SignupService signupService; private final EmailService emailService; + private final ProfileManageService profileManageService; @PostMapping("/signup") public ResponseEntity> signup(@RequestBody @Valid SignupRequest request) { @@ -56,6 +61,14 @@ public ResponseEntity> completeSignup( return ResponseEntity.ok(ApiResponse.ok(result)); } + @GetMapping("/signup/nickname/availability") + public ResponseEntity> checkNicknameAvailability( + @RequestParam String nickname + ) { + boolean available = profileManageService.isNicknameAvailable(nickname); + return ResponseEntity.ok(ApiResponse.ok(new NicknameAvailabilityResponse(available))); + } + @PostMapping("/reissue") public ResponseEntity> reissue( @CookieValue(name = "refreshToken") String refreshToken, diff --git a/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java b/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java index 31b3723..b3a74ae 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/entity/ProfileTest.java @@ -55,8 +55,8 @@ void shouldAddTagsSuccessfully() { } @Test - @DisplayName("카테고리별 3개를 초과하면 BusinessException이 발생한다") - void shouldThrowWhenExceedingCategoryLimit() { + @DisplayName("같은 카테고리여도 전체 5개 이하면 허용된다") + void shouldAllowSameCategoryWhenWithinTotalLimit() { // given - 외모(APPEARANCE) 카테고리 태그 4개 List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), @@ -65,9 +65,11 @@ void shouldThrowWhenExceedingCategoryLimit() { new ProfileTag(ProfileTagItem.SHARP_FACE) ); - // when & then - assertThatThrownBy(() -> profile.addTags(tags)) - .isInstanceOf(BusinessException.class); + // when + profile.addTags(tags); + + // then + assertThat(profile.getTags()).hasSize(4); } @Test @@ -107,9 +109,9 @@ void shouldReplaceExistingTags() { } @Test - @DisplayName("서로 다른 카테고리의 태그는 각각 최대 3개까지 허용된다") - void shouldAllowThreeTagsPerCategory() { - // given - 외모 3개 + 성격 3개 = 총 6개 + @DisplayName("전체 태그가 5개를 초과하면 BusinessException이 발생한다") + void shouldThrowWhenExceedingTotalTagLimit() { + // given - 총 6개 List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), new ProfileTag(ProfileTagItem.DIMPLE), @@ -119,11 +121,9 @@ void shouldAllowThreeTagsPerCategory() { new ProfileTag(ProfileTagItem.LOGICAL) ); - // when - profile.addTags(tags); - - // then - assertThat(profile.getTags()).hasSize(6); + // when & then + assertThatThrownBy(() -> profile.addTags(tags)) + .isInstanceOf(BusinessException.class); } @Test diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java index 9609d85..7759949 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/ProfileServiceImplTest.java @@ -26,8 +26,8 @@ import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.dto.member.ProfileTagDto; import com.comatching.common.exception.BusinessException; +import com.comatching.common.service.S3Service; import com.comatching.user.domain.event.UserEventPublisher; -import com.comatching.user.domain.member.component.RandomNicknameGenerator; import com.comatching.user.domain.member.dto.ProfileUpdateRequest; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; @@ -36,6 +36,7 @@ import com.comatching.user.domain.member.repository.MemberRepository; import com.comatching.user.domain.member.repository.ProfileRepository; import com.comatching.user.global.config.ProfileImageProperties; +import com.comatching.user.global.exception.UserErrorCode; @ExtendWith(MockitoExtension.class) @DisplayName("ProfileServiceImpl 테스트") @@ -57,7 +58,7 @@ class ProfileServiceImplTest { private ProfileImageProperties profileImageProperties; @Mock - private RandomNicknameGenerator nicknameGenerator; + private S3Service s3Service; @Nested @DisplayName("프로필 생성") @@ -77,7 +78,10 @@ void shouldCreateProfileWithTags() { .university("한국대학교") .major("컴퓨터공학과") .contactFrequency(ContactFrequency.FREQUENT) - .hobbies(List.of(new HobbyDto(HobbyCategory.SPORTS, "축구"))) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) .tags(List.of( new ProfileTagDto("EGG_FACE"), new ProfileTagDto("BRIGHT") @@ -86,7 +90,6 @@ void shouldCreateProfileWithTags() { given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); - given(profileImageProperties.filenames()).willReturn(List.of("default.png")); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); willDoNothing().given(eventPublisher).sendSignupEvent(any()); @@ -114,13 +117,15 @@ void shouldCreateProfileWithoutTags() { .university("한국대학교") .major("컴퓨터공학과") .contactFrequency(ContactFrequency.FREQUENT) - .hobbies(List.of(new HobbyDto(HobbyCategory.SPORTS, "축구"))) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) .tags(null) .build(); given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); given(profileImageProperties.baseUrl()).willReturn("https://img.com/"); - given(profileImageProperties.filenames()).willReturn(List.of("default.png")); given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); willDoNothing().given(eventPublisher).sendSignupEvent(any()); @@ -132,6 +137,109 @@ void shouldCreateProfileWithoutTags() { assertThat(response).isNotNull(); assertThat(response.tags()).isEmpty(); } + + @Test + @DisplayName("프로필 이미지 값이 S3 key면 퍼블릭 URL로 변환해 저장한다") + void shouldConvertS3KeyToPublicUrlWhenCreatingProfile() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + String imageKey = "profiles/1/test.png"; + String imageUrl = "https://bucket.s3.ap-northeast-2.amazonaws.com/profiles/1/test.png"; + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("테스트유저") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .profileImageKey(imageKey) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(s3Service.getFileUrl(imageKey)).willReturn(imageUrl); + given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendSignupEvent(any()); + + // when + ProfileResponse response = profileService.createProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo(imageUrl); + } + + @Test + @DisplayName("프로필 이미지 값이 default_동물이름이면 해당 기본 이미지 URL을 저장한다") + void shouldUseAnimalDefaultImageWhenCreatingProfile() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("테스트유저") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .profileImageKey("default_dog") + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile/"); + given(profileRepository.save(any(Profile.class))).willAnswer(invocation -> invocation.getArgument(0)); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendSignupEvent(any()); + + // when + ProfileResponse response = profileService.createProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo("https://img.com/defaults/profile/dog.png"); + } + + @Test + @DisplayName("닉네임이 중복되면 프로필 생성 시 예외가 발생한다") + void shouldThrowWhenNicknameDuplicatedOnCreate() { + // given + Long memberId = 1L; + Member member = createMember(memberId); + ProfileCreateRequest request = ProfileCreateRequest.builder() + .nickname("중복닉네임") + .gender(Gender.MALE) + .birthDate(LocalDate.of(2000, 1, 1)) + .mbti("ENFP") + .university("한국대학교") + .major("컴퓨터공학과") + .contactFrequency(ContactFrequency.FREQUENT) + .hobbies(List.of( + new HobbyDto(HobbyCategory.SPORTS, "축구"), + new HobbyDto(HobbyCategory.CULTURE, "영화감상") + )) + .tags(null) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(profileRepository.existsByNickname("중복닉네임")).willReturn(true); + + // when & then + assertThatThrownBy(() -> profileService.createProfile(memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.DUPLICATE_NICKNAME); + } } @Nested @@ -164,21 +272,23 @@ void shouldUpdateTagsSuccessfully() { } @Test - @DisplayName("카테고리별 3개 초과 태그로 수정 시 예외가 발생한다") - void shouldThrowWhenExceedingCategoryLimitOnUpdate() { + @DisplayName("전체 5개 초과 태그로 수정 시 예외가 발생한다") + void shouldThrowWhenExceedingTotalTagLimitOnUpdate() { // given Long memberId = 1L; Profile profile = createProfileWithTags(memberId); - // 외모 카테고리 태그 4개 (FACE_SHAPE 그룹) + // 총 6개 태그 ProfileUpdateRequest request = new ProfileUpdateRequest( null, null, null, null, null, null, null, null, null, null, null, null, null, List.of( new ProfileTagDto("EGG_FACE"), - new ProfileTagDto("ANGULAR_FACE"), - new ProfileTagDto("ROUND_FACE"), - new ProfileTagDto("SHARP_FACE") + new ProfileTagDto("DIMPLE"), + new ProfileTagDto("FAIR_SKIN"), + new ProfileTagDto("EXTROVERT"), + new ProfileTagDto("CARING"), + new ProfileTagDto("LOGICAL") ), null ); @@ -189,6 +299,60 @@ void shouldThrowWhenExceedingCategoryLimitOnUpdate() { assertThatThrownBy(() -> profileService.updateProfile(memberId, request)) .isInstanceOf(BusinessException.class); } + + @Test + @DisplayName("프로필 이미지 값이 default면 기본 프로필 이미지로 변경된다") + void shouldSetDefaultProfileImageOnUpdateWhenDefaultValueProvided() { + // given + Long memberId = 1L; + Profile profile = createProfileWithTags(memberId); + ProfileUpdateRequest request = new ProfileUpdateRequest( + null, null, null, "default", null, null, null, null, + null, null, null, null, null, null, null + ); + + given(profileRepository.findByMemberId(memberId)).willReturn(Optional.of(profile)); + given(profileImageProperties.baseUrl()).willReturn("https://img.com/defaults/profile/"); + willDoNothing().given(eventPublisher).sendProfileUpdatedMatchingEvent(any()); + willDoNothing().given(eventPublisher).sendUpdateEvent(any()); + + // when + ProfileResponse response = profileService.updateProfile(memberId, request); + + // then + assertThat(response.profileImageUrl()).isEqualTo("https://img.com/defaults/profile/default.png"); + } + } + + @Nested + @DisplayName("닉네임 중복 확인") + class NicknameAvailability { + + @Test + @DisplayName("중복 닉네임이면 사용 불가를 반환한다") + void shouldReturnFalseWhenNicknameDuplicated() { + // given + given(profileRepository.existsByNickname("중복닉네임")).willReturn(true); + + // when + boolean available = profileService.isNicknameAvailable("중복닉네임"); + + // then + assertThat(available).isFalse(); + } + + @Test + @DisplayName("미사용 닉네임이면 사용 가능을 반환한다") + void shouldReturnTrueWhenNicknameAvailable() { + // given + given(profileRepository.existsByNickname("신규닉네임")).willReturn(false); + + // when + boolean available = profileService.isNicknameAvailable("신규닉네임"); + + // then + assertThat(available).isTrue(); + } } @Nested @@ -225,7 +389,10 @@ private Member createMember(Long memberId) { private Profile createProfileWithTags(Long memberId) { Member member = createMember(memberId); - List hobbies = List.of(new ProfileHobby(HobbyCategory.SPORTS, "축구")); + List hobbies = List.of( + new ProfileHobby(HobbyCategory.SPORTS, "축구"), + new ProfileHobby(HobbyCategory.CULTURE, "영화감상") + ); List tags = List.of( new ProfileTag(ProfileTagItem.EGG_FACE), new ProfileTag(ProfileTagItem.BRIGHT)