diff --git a/src/main/java/com/mr/domain/user/controller/UserController.java b/src/main/java/com/mr/domain/user/controller/UserController.java new file mode 100644 index 00000000..c7bbe02f --- /dev/null +++ b/src/main/java/com/mr/domain/user/controller/UserController.java @@ -0,0 +1,35 @@ +package com.mr.domain.user.controller; + +import com.mr.domain.user.dto.res.UserResponseDTO; +import com.mr.domain.user.service.UserService; +import com.mr.global.apipayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users") +@Tag(name = "사용자", description = "인증 불필요한 사용자 관련 API") +public class UserController { + + private final UserService userService; + + @Operation( + summary = "닉네임 중복 확인", + description = "닉네임이 형식 규칙(한글/영문/숫자 2~10자)을 만족하는지, 이미 사용 중인지 확인합니다. " + + "인증 없이 호출 가능합니다." + ) + @GetMapping("/verify-nickname") + public ApiResponse verifyNickname( + @Parameter(description = "확인할 닉네임", example = "김뮤즈") + @RequestParam(required = false) String nickname + ) { + return ApiResponse.onSuccess(userService.checkNicknameAvailable(nickname)); + } +} diff --git a/src/main/java/com/mr/domain/user/dto/res/UserResponseDTO.java b/src/main/java/com/mr/domain/user/dto/res/UserResponseDTO.java new file mode 100644 index 00000000..ef3343f1 --- /dev/null +++ b/src/main/java/com/mr/domain/user/dto/res/UserResponseDTO.java @@ -0,0 +1,20 @@ +package com.mr.domain.user.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +public class UserResponseDTO { + + @Builder + @Schema(description = "닉네임 중복 확인 응답") + public record NicknameCheckResponse( + @Schema(description = "확인한 닉네임", example = "김뮤즈") + String nickname, + + @Schema(description = "사용 가능 여부 (true: 사용 가능, false: 이미 사용 중)", example = "true") + boolean available, + + @Schema(description = "화면에 바로 표시할 안내 문구", example = "사용 가능한 닉네임입니다.") + String message + ) {} +} diff --git a/src/main/java/com/mr/domain/user/entity/User.java b/src/main/java/com/mr/domain/user/entity/User.java index 354202cd..449f5cd1 100644 --- a/src/main/java/com/mr/domain/user/entity/User.java +++ b/src/main/java/com/mr/domain/user/entity/User.java @@ -61,11 +61,11 @@ public boolean isOnboardingCompleted() { public void updateNickname(String nickname) { String trimmedNickname = nickname == null ? null : nickname.trim(); - validateNickname(trimmedNickname); + validateNicknameFormat(trimmedNickname); this.nickname = trimmedNickname; } - private static void validateNickname(String nickname) { + public static void validateNicknameFormat(String nickname) { if (nickname == null || nickname.isBlank()) { throw new GeneralException(UserErrorStatus.NICKNAME_REQUIRED); } diff --git a/src/main/java/com/mr/domain/user/repository/UserRepository.java b/src/main/java/com/mr/domain/user/repository/UserRepository.java index 4917c050..e5004844 100644 --- a/src/main/java/com/mr/domain/user/repository/UserRepository.java +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -14,6 +14,8 @@ public interface UserRepository extends JpaRepository { boolean existsByNicknameAndUserIdNot(String nickname, Long userId); + boolean existsByNickname(String nickname); + // 유저 단위로 쓰기 작업을 직렬화하기 위한 비관적 락 조회. // 학습 결과 저장(saveResult)에서 같은 유저의 동시 요청 간 TOCTOU를 막는 데 사용 — // 패키지(Learning) 행이 아니라 유저 행에 걸어서, 서로 다른 유저끼리는 잠기지 않게 한다. diff --git a/src/main/java/com/mr/domain/user/service/UserService.java b/src/main/java/com/mr/domain/user/service/UserService.java new file mode 100644 index 00000000..27e9c913 --- /dev/null +++ b/src/main/java/com/mr/domain/user/service/UserService.java @@ -0,0 +1,32 @@ +package com.mr.domain.user.service; + +import com.mr.domain.user.dto.res.UserResponseDTO; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private static final String NICKNAME_AVAILABLE_MESSAGE = "사용 가능한 닉네임입니다."; + private static final String NICKNAME_TAKEN_MESSAGE = "사용 중인 닉네임입니다."; + + private final UserRepository userRepository; + + public UserResponseDTO.NicknameCheckResponse checkNicknameAvailable(String nickname) { + String trimmedNickname = nickname == null ? null : nickname.trim(); + User.validateNicknameFormat(trimmedNickname); + + boolean available = !userRepository.existsByNickname(trimmedNickname); + + return UserResponseDTO.NicknameCheckResponse.builder() + .nickname(trimmedNickname) + .available(available) + .message(available ? NICKNAME_AVAILABLE_MESSAGE : NICKNAME_TAKEN_MESSAGE) + .build(); + } +} diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java index 178e09c3..27945280 100644 --- a/src/main/java/com/mr/global/security/SecurityConfig.java +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -34,7 +34,8 @@ public class SecurityConfig { "/api/auth/login/**", "/api/auth/**/callback", "/api/auth/reissue", - "/api/auth/token/exchange" + "/api/auth/token/exchange", + "/api/users/verify-nickname" }; /** diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java index 7a615c39..1a652505 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java @@ -7,9 +7,9 @@ import com.mr.domain.analysis.exception.AnalysisErrorStatus; import com.mr.domain.analysis.factory.AnalysisRequestFactory; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.Level; -import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.Level; +import com.mr.domain.backingtrack.entity.enums.ScaleType; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.MidiType; diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java index e58c7bf6..d862f6df 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java @@ -25,8 +25,8 @@ import com.mr.domain.analysis.factory.AnalysisRequestFactory; import com.mr.domain.analysis.repository.AnalysisReportRepository; import com.mr.domain.analysis.repository.AnalysisRepository; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.ScaleType; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.repository.PlayingRepository; diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java index 907d96e8..ddb2a14c 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -1,8 +1,8 @@ package com.mr.domain.playing.service; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.AccessLevel; -import com.mr.domain.backingTrack.repository.BackingTrackRepository; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.AccessLevel; +import com.mr.domain.backingtrack.repository.BackingTrackRepository; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; import com.mr.domain.playing.dto.req.PlayingStartRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse; diff --git a/src/test/java/com/mr/domain/user/controller/UserControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserControllerTest.java new file mode 100644 index 00000000..e2211499 --- /dev/null +++ b/src/test/java/com/mr/domain/user/controller/UserControllerTest.java @@ -0,0 +1,95 @@ +package com.mr.domain.user.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.mr.domain.user.dto.res.UserResponseDTO; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.service.UserService; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class UserControllerTest { + + private MockMvc mockMvc; + + @Mock + private UserService userService; + + private MockMvc setUp() { + return MockMvcBuilders.standaloneSetup(new UserController(userService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Test + @DisplayName("GET /api/users/verify-nickname - nickname 파라미터를 정상적으로 넘기면 200") + void verifyNickname_success() throws Exception { + mockMvc = setUp(); + + UserResponseDTO.NicknameCheckResponse response = UserResponseDTO.NicknameCheckResponse.builder() + .nickname("김뮤즈") + .available(true) + .message("사용 가능한 닉네임입니다.") + .build(); + given(userService.checkNicknameAvailable("김뮤즈")).willReturn(response); + + mockMvc.perform(get("/api/users/verify-nickname").param("nickname", "김뮤즈")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.nickname").value("김뮤즈")) + .andExpect(jsonPath("$.data.available").value(true)) + .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")); + } + + @Test + @DisplayName("GET /api/users/verify-nickname - nickname 파라미터 자체가 누락되면 400 NICKNAME_REQUIRED로 응답한다 (500 아님)") + void verifyNickname_missingParam_returns400NicknameRequired() throws Exception { + mockMvc = setUp(); + + willThrow(new GeneralException(UserErrorStatus.NICKNAME_REQUIRED)) + .given(userService).checkNicknameAvailable(eq(null)); + + mockMvc.perform(get("/api/users/verify-nickname")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER_400_01")); + } + + @Test + @DisplayName("GET /api/users/verify-nickname - nickname이 빈 문자열이면 400 NICKNAME_REQUIRED로 응답한다") + void verifyNickname_blankParam_returns400NicknameRequired() throws Exception { + mockMvc = setUp(); + + willThrow(new GeneralException(UserErrorStatus.NICKNAME_REQUIRED)) + .given(userService).checkNicknameAvailable(" "); + + mockMvc.perform(get("/api/users/verify-nickname").param("nickname", " ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER_400_01")); + } + + @Test + @DisplayName("GET /api/users/verify-nickname - 형식 위반이면 400 NICKNAME_INVALID_FORMAT으로 응답한다") + void verifyNickname_invalidFormat_returns400InvalidFormat() throws Exception { + mockMvc = setUp(); + + willThrow(new GeneralException(UserErrorStatus.NICKNAME_INVALID_FORMAT)) + .given(userService).checkNicknameAvailable("김@뮤즈"); + + mockMvc.perform(get("/api/users/verify-nickname").param("nickname", "김@뮤즈")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("USER_400_02")); + } +} diff --git a/src/test/java/com/mr/domain/user/service/UserServiceTest.java b/src/test/java/com/mr/domain/user/service/UserServiceTest.java new file mode 100644 index 00000000..2a5dc95c --- /dev/null +++ b/src/test/java/com/mr/domain/user/service/UserServiceTest.java @@ -0,0 +1,84 @@ +package com.mr.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.mr.domain.user.dto.res.UserResponseDTO; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @Test + @DisplayName("checkNicknameAvailable - 아무도 안 쓰는 닉네임이면 available=true를 반환한다") + void checkNicknameAvailable_notTaken_returnsAvailableTrue() { + given(userRepository.existsByNickname("김뮤즈")).willReturn(false); + + UserResponseDTO.NicknameCheckResponse response = userService.checkNicknameAvailable("김뮤즈"); + + assertThat(response.nickname()).isEqualTo("김뮤즈"); + assertThat(response.available()).isTrue(); + assertThat(response.message()).isEqualTo("사용 가능한 닉네임입니다."); + } + + @Test + @DisplayName("checkNicknameAvailable - 이미 사용 중인 닉네임이면 available=false를 반환한다") + void checkNicknameAvailable_alreadyTaken_returnsAvailableFalse() { + given(userRepository.existsByNickname("김뮤즈")).willReturn(true); + + UserResponseDTO.NicknameCheckResponse response = userService.checkNicknameAvailable("김뮤즈"); + + assertThat(response.available()).isFalse(); + assertThat(response.message()).isEqualTo("사용 중인 닉네임입니다."); + } + + @Test + @DisplayName("checkNicknameAvailable - 앞뒤 공백은 trim한 뒤 조회하고 응답에도 trim된 값을 돌려준다") + void checkNicknameAvailable_trimsWhitespaceBeforeLookup() { + given(userRepository.existsByNickname("김뮤즈")).willReturn(false); + + UserResponseDTO.NicknameCheckResponse response = userService.checkNicknameAvailable(" 김뮤즈 "); + + assertThat(response.nickname()).isEqualTo("김뮤즈"); + verify(userRepository).existsByNickname("김뮤즈"); + } + + @Test + @DisplayName("checkNicknameAvailable - 닉네임이 null/공백이면 NICKNAME_REQUIRED 예외를 던지고 DB 조회는 하지 않는다") + void checkNicknameAvailable_blank_throwsNicknameRequiredWithoutDbLookup() { + assertThatThrownBy(() -> userService.checkNicknameAvailable(" ")) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_REQUIRED); + + verify(userRepository, never()).existsByNickname(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("checkNicknameAvailable - 형식(한글/영문/숫자 2~10자)에 안 맞으면 NICKNAME_INVALID_FORMAT 예외를 던지고 DB 조회는 하지 않는다") + void checkNicknameAvailable_invalidFormat_throwsInvalidFormatWithoutDbLookup() { + assertThatThrownBy(() -> userService.checkNicknameAvailable("김@뮤즈")) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_INVALID_FORMAT); + + verify(userRepository, never()).existsByNickname(org.mockito.ArgumentMatchers.any()); + } +}