diff --git a/src/main/java/umc/th/juinjang/api/member/controller/MemberController.java b/src/main/java/umc/th/juinjang/api/member/controller/MemberController.java index 6bef24e0..2e98747c 100644 --- a/src/main/java/umc/th/juinjang/api/member/controller/MemberController.java +++ b/src/main/java/umc/th/juinjang/api/member/controller/MemberController.java @@ -2,6 +2,8 @@ import static umc.th.juinjang.common.code.status.ErrorStatus.*; +import java.util.Map; + import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.CrossOrigin; @@ -9,6 +11,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -81,4 +84,10 @@ public ApiResponse createMemberAgreeVersion(@AuthenticationPrincipal Membe return ApiResponse.onSuccess(null); } + @Operation(summary = "닉네임 중복 여부") + @GetMapping("/members/nickname/exists") + public ApiResponse> isNicknameExists(@RequestParam String nickname) { + return ApiResponse.onSuccess(Map.of("exists", memberService.isNicknameExists(nickname))); + } + } diff --git a/src/main/java/umc/th/juinjang/api/member/service/MemberService.java b/src/main/java/umc/th/juinjang/api/member/service/MemberService.java index e00e69a3..1953455c 100644 --- a/src/main/java/umc/th/juinjang/api/member/service/MemberService.java +++ b/src/main/java/umc/th/juinjang/api/member/service/MemberService.java @@ -103,6 +103,10 @@ public void createMemberAgreeVersion(final Member member, getMember(member).updateAgreeVersion(memberAgreeVersionPostRequest.agreeVersion()); } + public boolean isNicknameExists(String nickname) { + return memberRepository.existsByNickname(nickname); + } + private Member getMember(Member member) { return memberRepository.findById(member.getMemberId()).orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); } diff --git a/src/main/java/umc/th/juinjang/auth/config/SecurityConfig.java b/src/main/java/umc/th/juinjang/auth/config/SecurityConfig.java index 741f4eae..c07699df 100644 --- a/src/main/java/umc/th/juinjang/auth/config/SecurityConfig.java +++ b/src/main/java/umc/th/juinjang/auth/config/SecurityConfig.java @@ -1,6 +1,7 @@ package umc.th.juinjang.auth.config; -import lombok.RequiredArgsConstructor; +import java.util.Arrays; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -16,85 +17,106 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import lombok.RequiredArgsConstructor; import umc.th.juinjang.auth.jwt.JwtAuthenticationFilter; import umc.th.juinjang.auth.jwt.JwtExceptionFilter; import umc.th.juinjang.auth.jwt.JwtService; -import java.util.Arrays; - @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final AuthenticationConfiguration authenticationConfiguration; - - private final JwtService jwtService; - - private final JwtExceptionFilter jwtExceptionFilter; - - private final Environment environment; - @Bean - @Order(0) - public WebSecurityCustomizer webSecurityCustomizer(){ - String[] activeProfiles = environment.getActiveProfiles(); - boolean isProd = Arrays.asList(activeProfiles).contains("prod"); - - //prod아닐때 - if (!isProd) { - return web -> web.ignoring() - .requestMatchers("/swagger-ui/**", "/swagger/**", "/swagger-resources/**", "/swagger-ui.html", "/test", - "/configuration/ui", "/v3/api-docs/**", "/h2-console/**", "/api/auth/regenerate-token", - "/api/auth/kakao/**", "/api/auth/apple/**", "/actuator/prometheus", - "/api/auth/v2/apple/**", "/api/auth/v2/kakao/**"); - } - else { - return web -> web.ignoring() - .requestMatchers("/h2-console/**", "/api/auth/regenerate-token", - "/api/auth/kakao/**", "/api/auth/apple/**", "/actuator/prometheus", - "/api/auth/v2/apple/**", "/api/auth/v2/kakao/**"); - } - - } - - //선언 방식이 3.x에서 바뀜 - @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration authConfiguration) throws Exception - { return authConfiguration.getAuthenticationManager(); } - - @Bean - protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http - .csrf(AbstractHttpConfigurer::disable) - .formLogin(Customizer.withDefaults()) - .sessionManagement((sessionManagement) -> - sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) -// 세션을 사용하지 않는다고 설정함 - ) - .addFilter(new JwtAuthenticationFilter(authenticationManager(authenticationConfiguration),jwtService)) -// JwtAuthenticationFilter를 필터에 넣음 - .authorizeHttpRequests((authorizeRequests) -> - authorizeRequests - .requestMatchers( - AntPathRequestMatcher.antMatcher("/api/auth/**") - ).authenticated() - .requestMatchers( - AntPathRequestMatcher.antMatcher("/h2-console/**") - ).permitAll() - - .anyRequest().authenticated() - - ) - .headers( - headersConfigurer -> - headersConfigurer - .frameOptions( - HeadersConfigurer.FrameOptionsConfig::sameOrigin - ) - ) - .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); - - return http.build(); - } + private final AuthenticationConfiguration authenticationConfiguration; + + private final JwtService jwtService; + + private final JwtExceptionFilter jwtExceptionFilter; + + private final Environment environment; + + // 공통적으로 허용되는 URL 패턴 + private static final String[] COMMON_WHITELIST_URLS = { + "/h2-console/**", + "/api/auth/regenerate-token", + "/api/auth/kakao/**", + "/api/auth/apple/**", + "/actuator/prometheus", + "/api/auth/v2/apple/**", + "/api/auth/v2/kakao/**", + "/api/members/nickname/exists" + }; + + // 개발 환경에서만 추가로 허용되는 URL 패턴 + private static final String[] DEV_WHITELIST_URLS = { + "/swagger-ui/**", + "/swagger/**", + "/swagger-resources/**", + "/swagger-ui.html", + "/test", + "/configuration/ui", + "/v3/api-docs/**" + }; + + @Bean + @Order(0) + public WebSecurityCustomizer webSecurityCustomizer() { + String[] activeProfiles = environment.getActiveProfiles(); + boolean isProd = Arrays.asList(activeProfiles).contains("prod"); + + //prod아닐때 + if (!isProd) { + return web -> web.ignoring() + .requestMatchers(COMMON_WHITELIST_URLS) + .requestMatchers(DEV_WHITELIST_URLS); + } else { + return web -> web.ignoring() + .requestMatchers(COMMON_WHITELIST_URLS); + } + + } + + //선언 방식이 3.x에서 바뀜 + @Bean + AuthenticationManager authenticationManager(AuthenticationConfiguration authConfiguration) throws Exception { + return authConfiguration.getAuthenticationManager(); + } + + @Bean + protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(Customizer.withDefaults()) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + // 세션을 사용하지 않는다고 설정함 + ) + .addFilter(new JwtAuthenticationFilter(authenticationManager(authenticationConfiguration), jwtService)) + // JwtAuthenticationFilter를 필터에 넣음 + .authorizeHttpRequests((authorizeRequests) -> + authorizeRequests + .requestMatchers( + AntPathRequestMatcher.antMatcher("/api/members/nickname/exists"), + AntPathRequestMatcher.antMatcher("/h2-console/**") + ).permitAll() + .requestMatchers( + AntPathRequestMatcher.antMatcher("/api/auth/**") + ).authenticated() + .anyRequest().authenticated() + + ) + .headers( + headersConfigurer -> + headersConfigurer + .frameOptions( + HeadersConfigurer.FrameOptionsConfig::sameOrigin + ) + ) + .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); + + return http.build(); + } } diff --git a/src/main/java/umc/th/juinjang/domain/member/repository/MemberRepository.java b/src/main/java/umc/th/juinjang/domain/member/repository/MemberRepository.java index 7c122df0..ac3eaacf 100644 --- a/src/main/java/umc/th/juinjang/domain/member/repository/MemberRepository.java +++ b/src/main/java/umc/th/juinjang/domain/member/repository/MemberRepository.java @@ -24,4 +24,6 @@ public interface MemberRepository extends JpaRepository { @Modifying @Query("UPDATE Member m SET m.introduction = :introduction WHERE m.memberId = :id") void patchIntroduction(@Param("id") Long id, @Param("introduction") String introduction); + + boolean existsByNickname(String nickname); } diff --git a/src/test/java/umc/th/juinjang/api/member/controller/MemberControllerTest.java b/src/test/java/umc/th/juinjang/api/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..b6185026 --- /dev/null +++ b/src/test/java/umc/th/juinjang/api/member/controller/MemberControllerTest.java @@ -0,0 +1,63 @@ +package umc.th.juinjang.api.member.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import umc.th.juinjang.api.member.service.MemberService; + +@WebMvcTest(MemberController.class) +@WithMockUser +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private MemberService memberService; + + @DisplayName("닉네임 중복 체크 API - 중복되지 않은 닉네임") + @Test + void checkNickname_whenNicknameDoesNotExist_thenReturnFalse() throws Exception { + // given + String nickname = "newNickname"; + given(memberService.isNicknameExists(nickname)).willReturn(false); + + // when & then + mockMvc.perform(get("/api/members/nickname/exists") + .param("nickname", nickname) + ) + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.exists").value(false)); + } + + @DisplayName("닉네임 중복 체크 API - 중복된 닉네임") + @Test + void checkNickname_whenNicknameExists_thenReturnTrue() throws Exception { + // given + String nickname = "existingNickname"; + given(memberService.isNicknameExists(nickname)).willReturn(true); + + // when & then + mockMvc.perform(get("/api/members/nickname/exists") + .param("nickname", nickname)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.exists").value(true)); + } +} diff --git a/src/test/java/umc/th/juinjang/api/member/service/MemberServiceTest.java b/src/test/java/umc/th/juinjang/api/member/service/MemberServiceTest.java index ddd6bef9..48f27e5b 100644 --- a/src/test/java/umc/th/juinjang/api/member/service/MemberServiceTest.java +++ b/src/test/java/umc/th/juinjang/api/member/service/MemberServiceTest.java @@ -3,9 +3,12 @@ import static org.assertj.core.api.Assertions.*; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.AfterEach; +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.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -16,6 +19,7 @@ import umc.th.juinjang.domain.member.model.MemberProvider; import umc.th.juinjang.domain.member.repository.MemberRepository; import umc.th.juinjang.domain.pencilaccount.repository.PencilAccountRepository; +import umc.th.juinjang.testutil.fixture.MemberFixture; @ActiveProfiles("test") @SpringBootTest @@ -32,8 +36,8 @@ public class MemberServiceTest { @AfterEach void tearDown() { - memberRepository.deleteAllInBatch(); pencilAccountRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); } private final String DEFAULT_EMAIL = "test@naver.com"; @@ -73,6 +77,59 @@ void patchIntroduction() { assertThat(updatedMember.getIntroduction()).isEqualTo(changedIntroduction); } + @Nested + @DisplayName("닉네임 중복 검사") + class NicknameExistsTest { + + // static 필드로 선언 + private static String existingNickname; + + @BeforeEach + void setUp() { + // given - 여러 멤버 데이터 한 번만 설정 + existingNickname = "테스트1"; + String nickname2 = "테스트2"; + String nickname3 = "테스트3"; + + Member member1 = MemberFixture.createMemberWithParams( + "custom1@example.com", 11111111L, existingNickname, + "안녕하세요", "https://custom.image.url"); + + Member member2 = MemberFixture.createMemberWithParams( + "custom2@example.com", 2222222L, nickname2, + "안녕하세요", "https://custom.image.url"); + + Member member3 = MemberFixture.createMemberWithParams( + "custom3@example.com", 3333333L, nickname3, + "안녕하세요", "https://custom.image.url"); + + memberRepository.saveAll(List.of(member1, member2, member3)); + } + + @DisplayName("닉네임이 중복되었을 때, 중복 여부를 True 로 반환한다") + @Test + void returnsTrueWhenNicknameExists() { + // when + boolean result = memberService.isNicknameExists(existingNickname); + + // then + assertThat(result).isTrue(); + } + + @DisplayName("닉네임이 중복되지 않았을 때, 중복 여부를 False 로 반환한다") + @Test + void returnsFalseWhenNicknameDoesNotExist() { + // given + String nonExistingNickname = "존재하지않는닉네임"; + + // when + boolean result = memberService.isNicknameExists(nonExistingNickname); + + // then + assertThat(result).isFalse(); + } + } + private Member createDefaultMember() { return Member.builder() .email(DEFAULT_EMAIL) diff --git a/src/test/java/umc/th/juinjang/testutil/fixture/MemberFixture.java b/src/test/java/umc/th/juinjang/testutil/fixture/MemberFixture.java new file mode 100644 index 00000000..b7ad9d1c --- /dev/null +++ b/src/test/java/umc/th/juinjang/testutil/fixture/MemberFixture.java @@ -0,0 +1,54 @@ +package umc.th.juinjang.testutil.fixture; + +import java.time.LocalDateTime; + +import umc.th.juinjang.domain.member.model.Member; +import umc.th.juinjang.domain.member.model.MemberProvider; + +public class MemberFixture { + + public static final String DEFAULT_EMAIL = "test@naver.com"; + public static final String DEFAULT_IMAGE_URL = "https://image.url.com"; + public static final String DEFAULT_NICKNAME = "test"; + public static final String DEFAULT_INTRODUCTION = ""; + public static final Long DEFAULT_KAKAO_ID = 91681234L; + + public static Member createDefaultMember() { + return createDefaultMemberBuilder().build(); + } + + public static Member.MemberBuilder createDefaultMemberBuilder() { + return Member.builder() + .email(DEFAULT_EMAIL) + .provider(MemberProvider.KAKAO) + .kakaoTargetId(DEFAULT_KAKAO_ID) + .nickname(DEFAULT_NICKNAME) + .refreshToken("") + .refreshTokenExpiresAt(LocalDateTime.now().plusDays(7L)) + .introduction(DEFAULT_INTRODUCTION) + .imageUrl(DEFAULT_IMAGE_URL); + } + + public static Member createMemberWithParams( + String email, + Long kakaoTargetId, + String nickname, + String introduction, + String imageUrl) { + + Member.MemberBuilder builder = createDefaultMemberBuilder(); + + if (email != null) + builder.email(email); + if (kakaoTargetId != null) + builder.kakaoTargetId(kakaoTargetId); + if (nickname != null) + builder.nickname(nickname); + if (introduction != null) + builder.introduction(introduction); + if (imageUrl != null) + builder.imageUrl(imageUrl); + + return builder.build(); + } +}