Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/main/java/com/mr/domain/user/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -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<UserResponseDTO.NicknameCheckResponse> verifyNickname(
@Parameter(description = "확인할 닉네임", example = "김뮤즈")
@RequestParam(required = false) String nickname
) {
return ApiResponse.onSuccess(userService.checkNicknameAvailable(nickname));
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/mr/domain/user/dto/res/UserResponseDTO.java
Original file line number Diff line number Diff line change
@@ -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
) {}
}
4 changes: 2 additions & 2 deletions src/main/java/com/mr/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public interface UserRepository extends JpaRepository<User, Long> {

boolean existsByNicknameAndUserIdNot(String nickname, Long userId);

boolean existsByNickname(String nickname);

// 유저 단위로 쓰기 작업을 직렬화하기 위한 비관적 락 조회.
// 학습 결과 저장(saveResult)에서 같은 유저의 동시 요청 간 TOCTOU를 막는 데 사용 —
// 패키지(Learning) 행이 아니라 유저 행에 걸어서, 서로 다른 유저끼리는 잠기지 않게 한다.
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/com/mr/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
p1001q marked this conversation as resolved.
Resolved

return UserResponseDTO.NicknameCheckResponse.builder()
.nickname(trimmedNickname)
.available(available)
.message(available ? NICKNAME_AVAILABLE_MESSAGE : NICKNAME_TAKEN_MESSAGE)
.build();
}
}
3 changes: 2 additions & 1 deletion src/main/java/com/mr/global/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
84 changes: 84 additions & 0 deletions src/test/java/com/mr/domain/user/service/UserServiceTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading