From 07597e0a98e6a1894fa8353082a3556bbd5ccd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 19:42:18 +0900 Subject: [PATCH] =?UTF-8?q?test:=20UserService=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=9E=90=EA=B8=B0=EC=9E=AC?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMyPage/updateUserNickname/getUserProfile/deleteUser 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (17개). 테스트 작성 중 발견해서 수정한 버그: - updateUserNickname: 중복 닉네임 체크를 유저 조회보다 먼저, 그리고 본인의 현재 닉네임과 비교 없이 수행하고 있어서, 본인의 기존 닉네임을 그대로 다시 저장하려고 해도 "이미 존재하는 닉네임"으로 거부됐음. 유저 조회를 먼저 하고, 요청 닉네임이 현재 닉네임과 다를 때만 중복 체크를 하도록 순서/조건 수정. --- .../server/user/service/UserService.java | 13 +- .../server/user/service/UserServiceTest.java | 367 ++++++++++++++++++ 2 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 src/test/java/org/runnect/server/user/service/UserServiceTest.java diff --git a/src/main/java/org/runnect/server/user/service/UserService.java b/src/main/java/org/runnect/server/user/service/UserService.java index b05328f..79df0eb 100644 --- a/src/main/java/org/runnect/server/user/service/UserService.java +++ b/src/main/java/org/runnect/server/user/service/UserService.java @@ -46,15 +46,18 @@ public MyPageResponseDto getMyPage(Long userId) { public UpdateUserNicknameResponseDto updateUserNickname( Long userId, UpdateUserNicknameRequestDto updateUserNicknameRequestDto ) { - if (userRepository.existsByNickname(updateUserNicknameRequestDto.getNickname())) { - throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage()); - } - RunnectUser user = userRepository.findUserByIdWithUserStamps(userId) .orElseThrow(() -> new NotFoundUserException(ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage())); - user.updateUserNickname(updateUserNicknameRequestDto.getNickname()); + String newNickname = updateUserNicknameRequestDto.getNickname(); + // 기존 닉네임과 동일한 값으로 "변경"하는 경우까지 중복으로 처리하면 + // 본인의 현재 닉네임을 다시 저장할 수 없게 되므로 그 경우는 제외한다. + if (!user.getNickname().equals(newNickname) && userRepository.existsByNickname(newNickname)) { + throw new DuplicateNicknameException(ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION, ErrorStatus.ALREADY_EXIST_NICKNAME_EXCEPTION.getMessage()); + } + + user.updateUserNickname(newNickname); return UpdateUserNicknameResponseDto.of(user, calculateUserLevelPercent(user)); } diff --git a/src/test/java/org/runnect/server/user/service/UserServiceTest.java b/src/test/java/org/runnect/server/user/service/UserServiceTest.java new file mode 100644 index 0000000..78e2115 --- /dev/null +++ b/src/test/java/org/runnect/server/user/service/UserServiceTest.java @@ -0,0 +1,367 @@ +package org.runnect.server.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +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.locationtech.jts.geom.LineString; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.auth.service.AppleSignInService; +import org.runnect.server.common.exception.UnauthorizedException; +import org.runnect.server.common.module.convert.CoordinateDto; +import org.runnect.server.common.module.convert.CoordinatePathConverter; +import org.runnect.server.course.entity.Course; +import org.runnect.server.course.repository.CourseRepository; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.scrap.repository.ScrapRepository; +import org.runnect.server.user.dto.request.UpdateUserNicknameRequestDto; +import org.runnect.server.user.dto.response.DeleteUserResponseDto; +import org.runnect.server.user.dto.response.MyPageResponseDto; +import org.runnect.server.user.dto.response.UpdateUserNicknameResponseDto; +import org.runnect.server.user.dto.response.UserProfileResponseDto; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.UserStamp; +import org.runnect.server.user.exception.userException.DuplicateNicknameException; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private ScrapRepository scrapRepository; + @Mock + private AppleSignInService appleSignInService; + @Mock + private CourseRepository courseRepository; + + private UserService userService; + + @BeforeEach + void setUp() { + userService = new UserService(userRepository, scrapRepository, appleSignInService, courseRepository); + } + + private RunnectUser buildUser(Long id, String nickname, SocialType provider) { + RunnectUser user = RunnectUser.builder() + .nickname(nickname) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(provider) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private RunnectUser buildUser(Long id) { + return buildUser(id, "러너" + id, SocialType.KAKAO); + } + + private void setStampCount(RunnectUser user, int count) { + List stamps = Stream.generate(() -> mock(UserStamp.class)) + .limit(count) + .collect(Collectors.toList()); + ReflectionTestUtils.setField(user, "userStamps", stamps); + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(java.util.Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + )); + } + + private Course buildCourse(Long id, RunnectUser owner) { + Course course = Course.builder() + .runnectUser(owner) + .title("코스 제목") + .departureRegion("경기") + .departureCity("시흥시") + .departureTown("정왕동") + .departureDetail("정왕본동") + .departureName("정왕역") + .distance(5.2f) + .image("https://image.example/course.png") + .path(validLineString()) + .build(); + ReflectionTestUtils.setField(course, "id", id); + ReflectionTestUtils.setField(course, "isPrivate", false); + return course; + } + + private PublicCourse buildPublicCourse(Long id, Course course) { + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", id); + ReflectionTestUtils.setField(course, "publicCourse", publicCourse); + return publicCourse; + } + + @Nested + @DisplayName("getMyPage") + class GetMyPage { + + @Test + @DisplayName("정상 조회 시 유저 정보와 레벨 퍼센트를 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + setStampCount(user, 5); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + + MyPageResponseDto response = userService.getMyPage(1L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getUser().getLevelPercent()).isEqualTo(25); + } + + @Test + @DisplayName("스탬프가 0개면 레벨 퍼센트는 0이다") + void 스탬프_없음() { + RunnectUser user = buildUser(1L); + setStampCount(user, 0); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + + MyPageResponseDto response = userService.getMyPage(1L); + + assertThat(response.getUser().getLevelPercent()).isEqualTo(0); + } + + @Test + @DisplayName("스탬프가 4개면 한 바퀴 돌아서 레벨 퍼센트는 다시 0이다") + void 스탬프_4개면_한바퀴() { + RunnectUser user = buildUser(1L); + setStampCount(user, 4); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + + MyPageResponseDto response = userService.getMyPage(1L); + + assertThat(response.getUser().getLevelPercent()).isEqualTo(0); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getMyPage(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("updateUserNickname") + class UpdateUserNickname { + + @Test + @DisplayName("중복되지 않는 새 닉네임이면 정상 변경된다") + void 정상_변경() { + RunnectUser user = buildUser(1L, "기존닉네임", SocialType.KAKAO); + setStampCount(user, 0); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + when(userRepository.existsByNickname("새닉네임")).thenReturn(false); + + UpdateUserNicknameResponseDto response = userService.updateUserNickname(1L, + new UpdateUserNicknameRequestDto("새닉네임")); + + assertThat(user.getNickname()).isEqualTo("새닉네임"); + assertThat(response.getUser().getNickname()).isEqualTo("새닉네임"); + } + + @Test + @DisplayName("이미 존재하는 닉네임이면 DuplicateNicknameException") + void 중복_닉네임() { + RunnectUser user = buildUser(1L, "기존닉네임", SocialType.KAKAO); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + when(userRepository.existsByNickname("중복닉네임")).thenReturn(true); + + assertThatThrownBy(() -> userService.updateUserNickname(1L, + new UpdateUserNicknameRequestDto("중복닉네임"))) + .isInstanceOf(DuplicateNicknameException.class); + + assertThat(user.getNickname()).isEqualTo("기존닉네임"); + } + + @Test + @DisplayName("본인의 현재 닉네임으로 다시 저장해도 중복 처리하지 않는다") + void 본인_현재_닉네임으로_재저장() { + RunnectUser user = buildUser(1L, "내닉네임", SocialType.KAKAO); + setStampCount(user, 0); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(user)); + + UpdateUserNicknameResponseDto response = userService.updateUserNickname(1L, + new UpdateUserNicknameRequestDto("내닉네임")); + + assertThat(response.getUser().getNickname()).isEqualTo("내닉네임"); + verify(userRepository, never()).existsByNickname(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.updateUserNickname(1L, + new UpdateUserNicknameRequestDto("새닉네임"))) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getUserProfile") + class GetUserProfile { + + @Test + @DisplayName("프로필 유저가 공개한 코스 목록을 스크랩 여부와 함께 반환한다") + void 정상_조회() { + RunnectUser profileUser = buildUser(1L); + setStampCount(profileUser, 0); + RunnectUser requestUser = buildUser(2L); + Course course = buildCourse(10L, profileUser); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(profileUser)); + when(userRepository.findById(2L)).thenReturn(Optional.of(requestUser)); + when(scrapRepository.getScrappedTruePublicCourseIds(requestUser)).thenReturn( + Collections.singletonList(100L)); + when(courseRepository.findCoursesForUserProfile(profileUser)).thenReturn( + Collections.singletonList(course)); + + UserProfileResponseDto response = userService.getUserProfile(1L, 2L); + + assertThat(response.getUser().getUserId()).isEqualTo(1L); + assertThat(response.getCourses()).hasSize(1); + assertThat(response.getCourses().get(0).getScrapTF()).isTrue(); + } + + @Test + @DisplayName("스크랩하지 않은 코스는 scrapTF가 false다") + void 스크랩_안함() { + RunnectUser profileUser = buildUser(1L); + setStampCount(profileUser, 0); + RunnectUser requestUser = buildUser(2L); + Course course = buildCourse(10L, profileUser); + buildPublicCourse(100L, course); + + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(profileUser)); + when(userRepository.findById(2L)).thenReturn(Optional.of(requestUser)); + when(scrapRepository.getScrappedTruePublicCourseIds(requestUser)).thenReturn( + Collections.emptyList()); + when(courseRepository.findCoursesForUserProfile(profileUser)).thenReturn( + Collections.singletonList(course)); + + UserProfileResponseDto response = userService.getUserProfile(1L, 2L); + + assertThat(response.getCourses().get(0).getScrapTF()).isFalse(); + } + + @Test + @DisplayName("공개한 코스가 없으면 빈 목록을 반환한다") + void 공개한_코스_없음() { + RunnectUser profileUser = buildUser(1L); + setStampCount(profileUser, 0); + RunnectUser requestUser = buildUser(2L); + + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(profileUser)); + when(userRepository.findById(2L)).thenReturn(Optional.of(requestUser)); + when(scrapRepository.getScrappedTruePublicCourseIds(requestUser)).thenReturn( + Collections.emptyList()); + when(courseRepository.findCoursesForUserProfile(profileUser)).thenReturn(Collections.emptyList()); + + UserProfileResponseDto response = userService.getUserProfile(1L, 2L); + + assertThat(response.getCourses()).isEmpty(); + } + + @Test + @DisplayName("프로필 대상 유저가 없으면 NotFoundUserException") + void 프로필_유저_없음() { + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserProfile(1L, 2L)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("요청 유저가 없으면 NotFoundUserException") + void 요청_유저_없음() { + RunnectUser profileUser = buildUser(1L); + when(userRepository.findUserByIdWithUserStamps(1L)).thenReturn(Optional.of(profileUser)); + when(userRepository.findById(2L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserProfile(1L, 2L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("deleteUser") + class DeleteUser { + + @Test + @DisplayName("일반 소셜 유저는 애플 관련 처리 없이 바로 삭제된다") + void 일반_유저_삭제() { + RunnectUser user = buildUser(1L, "러너", SocialType.KAKAO); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + DeleteUserResponseDto response = userService.deleteUser(1L, null); + + assertThat(response.getDeletedUserId()).isEqualTo(1L); + verify(appleSignInService, never()).reportWithdrawalToApple(org.mockito.ArgumentMatchers.any()); + verify(userRepository).delete(user); + } + + @Test + @DisplayName("애플 유저는 accessToken이 있으면 애플에 탈퇴를 알리고 삭제된다") + void 애플_유저_토큰_있음() { + RunnectUser user = buildUser(1L, "러너", SocialType.APPLE); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + userService.deleteUser(1L, "apple-token"); + + verify(appleSignInService).reportWithdrawalToApple("apple-token"); + verify(userRepository).delete(user); + } + + @Test + @DisplayName("애플 유저인데 accessToken이 없으면 UnauthorizedException") + void 애플_유저_토큰_없음() { + RunnectUser user = buildUser(1L, "러너", SocialType.APPLE); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> userService.deleteUser(1L, null)) + .isInstanceOf(UnauthorizedException.class); + + verify(userRepository, never()).delete(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUser(1L, null)) + .isInstanceOf(NotFoundUserException.class); + } + } +}