From 4ff2a227077d772b76af8802590f8f19d6da370e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:08:23 +0900 Subject: [PATCH 1/9] =?UTF-8?q?test:=20JwtService/UserIdResolver=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20dev=20CI=EC=97=90=EC=84=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtServiceTest: 토큰 발급/검증/만료/클레임 추출 검증 - UserIdResolverTest: 토큰 누락/만료/무효, 방문자 모드, userId 파싱 및 MDC 반영 검증 - dev-ci.yml: `-x test` 제거 — 지금까지 테스트가 아예 실행 안 되고 있었음 --- .github/workflows/dev-ci.yml | 2 +- .../resolver/userId/UserIdResolverTest.java | 131 ++++++++++++++++++ .../server/config/jwt/JwtServiceTest.java | 57 ++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java create mode 100644 src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index af3ee51..eedef2a 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -49,4 +49,4 @@ jobs: run: chmod +x gradlew - name: Build with Gradle # 실제 application build - run: ./gradlew build -PactiveProfiles=local -x test + run: ./gradlew build -PactiveProfiles=local diff --git a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java new file mode 100644 index 0000000..acf2471 --- /dev/null +++ b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java @@ -0,0 +1,131 @@ +package org.runnect.server.common.resolver.userId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.user.exception.authException.InvalidAccessTokenException; +import org.runnect.server.user.exception.authException.NullAccessTokenException; +import org.runnect.server.user.exception.authException.TimeExpiredAccessTokenException; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.slf4j.MDC; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.NativeWebRequest; + +class UserIdResolverTest { + + private static final Long VISITOR_ID = 0L; + + private JwtService jwtService; + private UserIdResolver userIdResolver; + private MethodParameter methodParameter; + + @BeforeEach + void setUp() { + jwtService = mock(JwtService.class); + userIdResolver = new UserIdResolver(jwtService); + ReflectionTestUtils.setField(userIdResolver, "VISITOR_ID", VISITOR_ID); + ReflectionTestUtils.invokeMethod(userIdResolver, "setVISITOR_POSSIBLE_URLS", "/api/public-course"); + methodParameter = mock(MethodParameter.class); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private NativeWebRequest webRequestWith(String accessToken, String refreshToken, String method, String uri) { + HttpServletRequest servletRequest = mock(HttpServletRequest.class); + when(servletRequest.getHeader("accessToken")).thenReturn(accessToken); + when(servletRequest.getHeader("refreshToken")).thenReturn(refreshToken); + when(servletRequest.getMethod()).thenReturn(method); + when(servletRequest.getRequestURI()).thenReturn(uri); + + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getNativeRequest()).thenReturn(servletRequest); + return webRequest; + } + + @Test + void accessToken이_없으면_예외를_던진다() { + NativeWebRequest webRequest = webRequestWith(null, "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NullAccessTokenException.class); + } + + @Test + void refreshToken이_없으면_예외를_던진다() { + NativeWebRequest webRequest = webRequestWith("access", null, "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NullAccessTokenException.class); + } + + @Test + void 방문자_모드_허용_URL이면_VISITOR_ID를_반환하고_MDC에_채운다() { + NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123"); + + Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); + + assertThat(result).isEqualTo(VISITOR_ID); + assertThat(MDC.get("userId")).isEqualTo(String.valueOf(VISITOR_ID)); + } + + @Test + void 만료된_토큰이면_예외를_던진다() { + when(jwtService.verifyToken("expired")).thenReturn(TokenStatus.TOKEN_EXPIRED); + NativeWebRequest webRequest = webRequestWith("expired", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(TimeExpiredAccessTokenException.class); + } + + @Test + void 유효하지_않은_토큰이면_예외를_던진다() { + when(jwtService.verifyToken("invalid")).thenReturn(TokenStatus.TOKEN_INVALID); + NativeWebRequest webRequest = webRequestWith("invalid", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(InvalidAccessTokenException.class); + } + + @Test + void 유효한_토큰이면_userId를_반환하고_MDC에_채운다() { + when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("valid")).thenReturn("42"); + NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); + + Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); + + assertThat(result).isEqualTo(42L); + assertThat(MDC.get("userId")).isEqualTo("42"); + } + + @Test + void 토큰의_userId_클레임이_숫자가_아니면_예외를_던진다() { + when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("valid")).thenReturn("not-a-number"); + NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); + + assertThatThrownBy(() -> userIdResolver.resolveArgument(methodParameter, null, webRequest, null)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + void UserId_애노테이션과_Long_타입일_때만_지원한다() { + when(methodParameter.hasParameterAnnotation(UserId.class)).thenReturn(true); + when(methodParameter.getParameterType()).thenReturn((Class) Long.class); + + assertThat(userIdResolver.supportsParameter(methodParameter)).isTrue(); + } +} diff --git a/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java b/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java new file mode 100644 index 0000000..5b84596 --- /dev/null +++ b/src/test/java/org/runnect/server/config/jwt/JwtServiceTest.java @@ -0,0 +1,57 @@ +package org.runnect.server.config.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.redis.RedisService; +import org.springframework.test.util.ReflectionTestUtils; + +class JwtServiceTest { + + private JwtService jwtService; + + @BeforeEach + void setUp() { + RedisService redisService = mock(RedisService.class); + jwtService = new JwtService(redisService); + ReflectionTestUtils.setField(jwtService, "jwtSecret", "test-secret-key-for-jwt-service-unit-test-only"); + ReflectionTestUtils.invokeMethod(jwtService, "init"); + } + + @Test + void 발급한_액세스_토큰은_검증에_성공한다() { + String accessToken = jwtService.issuedAccessToken(1L); + + long status = jwtService.verifyToken(accessToken); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_VALID); + } + + @Test + void 발급한_토큰에서_userId_클레임을_그대로_추출한다() { + String accessToken = jwtService.issuedAccessToken(42L); + + String userId = jwtService.getJwtContents(accessToken); + + assertThat(userId).isEqualTo("42"); + } + + @Test + void 형식이_깨진_토큰은_INVALID로_판정한다() { + long status = jwtService.verifyToken("not-a-real-jwt"); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_INVALID); + } + + @Test + void 이미_만료된_토큰은_EXPIRED로_판정한다() { + String expiredToken = jwtService.issuedToken("access_token", -1000L, "1"); + + long status = jwtService.verifyToken(expiredToken); + + assertThat(status).isEqualTo(TokenStatus.TOKEN_EXPIRED); + } +} From 3df7550704f60d43811e7f065c75f72288f9cc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:29:47 +0900 Subject: [PATCH 2/9] =?UTF-8?q?test:=20CourseService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCourse/getCourseByUser/getPrivateCourseByUser/getCourseDetail/updateCourse/ deleteCourses 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값을 검증. 테스트 작성 중 실제 프로덕션 코드에서 3가지 의심되는 부분을 발견해 별도로 표시해둠: - createCourse: 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환하고 이후 NPE로 이어짐 (요청값 검증 부재) - updateCourse: courseId로만 조회하고 userId로 소유자 검증을 하지 않아, 다른 사람의 코스 제목도 수정 가능 (IDOR 의심) - getCourseDetail: RunnectUser가 equals/hashCode를 오버라이드하지 않아 isNowUser 판정이 참조 동일성에 의존함 (같은 id라도 인스턴스가 다르면 다른 사람으로 판정될 수 있음) --- .../course/service/CourseServiceTest.java | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 src/test/java/org/runnect/server/course/service/CourseServiceTest.java diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java new file mode 100644 index 0000000..3f2a5b2 --- /dev/null +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -0,0 +1,468 @@ +package org.runnect.server.course.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.convert.CoordinateDto; +import org.runnect.server.common.module.convert.CoordinatePathConverter; +import org.runnect.server.course.dto.request.CourseCreateRequestDto; +import org.runnect.server.course.dto.response.CourseCreateResponseDto; +import org.runnect.server.course.dto.response.CourseGetByUserResponseDto; +import org.runnect.server.course.dto.response.DeleteCoursesResponseDto; +import org.runnect.server.course.dto.response.GetCourseDetailResponseDto; +import org.runnect.server.course.dto.response.UpdateCourseResponseDto; +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.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.StampType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.runnect.server.user.service.UserStampService; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CourseServiceTest { + + @Mock + private CourseRepository courseRepository; + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserRepository userRepository; + @Mock + private UserStampService userStampService; + + private CourseService courseService; + + @BeforeEach + void setUp() { + courseService = new CourseService(courseRepository, publicCourseRepository, userRepository, + userStampService); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private List validPath() { + return Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + ); + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(validPath()); + } + + private CourseCreateRequestDto createRequestDto(List path, String departureAddress) { + return new CourseCreateRequestDto(path, "정왕역 코스", 5.2f, "정왕역", departureAddress); + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate) { + 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); + if (!isPrivate) { + ReflectionTestUtils.setField(course, "isPrivate", false); + } + return course; + } + + @Nested + @DisplayName("createCourse") + class CreateCourse { + + @Test + @DisplayName("정상 요청이면 코스를 저장하고 유저 코스 카운트/스탬프를 갱신한다") + void 정상_생성() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Course savedCourse = buildCourse(100L, user, true); + ReflectionTestUtils.setField(savedCourse, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + when(courseRepository.save(any(Course.class))).thenReturn(savedCourse); + + CourseCreateRequestDto requestDto = createRequestDto(validPath(), "경기 시흥시 정왕동"); + + CourseCreateResponseDto response = courseService.createCourse(1L, requestDto, "image-key.png"); + + assertThat(response.getId()).isEqualTo(100L); + assertThat(response.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0)); + assertThat(user.getCreatedCourse()).isEqualTo(1L); + verify(userStampService).createStampByUser(user, StampType.c); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Course.class); + verify(courseRepository).save(captor.capture()); + Course passed = captor.getValue(); + assertThat(passed.getRunnectUser()).isEqualTo(user); + assertThat(passed.getTitle()).isEqualTo("정왕역 코스"); + assertThat(passed.getDepartureRegion()).isEqualTo("경기"); + assertThat(passed.getDepartureCity()).isEqualTo("시흥시"); + assertThat(passed.getDepartureTown()).isEqualTo("정왕동"); + assertThat(passed.getImage()).isEqualTo("image-key.png"); + assertThat(passed.getPath()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(validPath(), "경기 시흥시 정왕동"), "img")) + .isInstanceOf(NotFoundUserException.class); + + verify(courseRepository, never()).save(any()); + } + + @Test + @DisplayName("좌표가 1개 뿐이면 LineString을 만들 수 없어 BadRequestException") + void 좌표가_부족하면_경로_생성_실패() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + List onePoint = Collections.singletonList(new CoordinateDto(37.5665, 126.9780)); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(onePoint, "경기 시흥시 정왕동"), "img")) + .isInstanceOf(BadRequestException.class); + + verify(courseRepository, never()).save(any()); + } + + @Test + @DisplayName("[버그 의심] 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환해 NPE가 난다") + void 출발지_주소가_불완전하면_NPE() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(validPath(), "경기 시흥시"), "img")) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("getCourseByUser") + class GetCourseByUser { + + @Test + @DisplayName("유저의 코스 목록을 CourseResponse로 매핑해 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course1 = buildCourse(10L, user, true); + Course course2 = buildCourse(11L, user, true); + when(courseRepository.findCourseByUserId(1L)).thenReturn(Arrays.asList(course1, course2)); + + CourseGetByUserResponseDto response = courseService.getCourseByUser(1L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getCourses()).hasSize(2) + .extracting("id") + .containsExactly(10L, 11L); + assertThat(response.getCourses().get(0).getDeparture().getRegion()).isEqualTo("경기"); + assertThat(response.getCourses().get(0).getDeparture().getCity()).isEqualTo("시흥시"); + } + + @Test + @DisplayName("코스가 없으면 빈 목록을 반환한다") + void 코스가_없으면_빈_목록() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCourseByUserId(1L)).thenReturn(Collections.emptyList()); + + CourseGetByUserResponseDto response = courseService.getCourseByUser(1L); + + assertThat(response.getCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPrivateCourseByUser") + class GetPrivateCourseByUser { + + @Test + @DisplayName("비공개 코스 전용 조회 메서드를 호출한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course = buildCourse(10L, user, true); + when(courseRepository.findCourseByUserIdOnlyPrivate(1L)).thenReturn( + Collections.singletonList(course)); + + CourseGetByUserResponseDto response = courseService.getPrivateCourseByUser(1L); + + assertThat(response.getCourses()).hasSize(1); + verify(courseRepository).findCourseByUserIdOnlyPrivate(1L); + verify(courseRepository, never()).findCourseByUserId(any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getPrivateCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getCourseDetail") + class GetCourseDetail { + + @Test + @DisplayName("본인이 업로드한 코스면 isNowUser가 true다") + void 본인_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course = buildCourse(10L, user, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isTrue(); + assertThat(response.getUser().getUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("다른 사람이 업로드한 코스면 isNowUser가 false다") + void 타인_코스() { + RunnectUser requester = buildUser(1L); + RunnectUser uploader = buildUser(2L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, uploader, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("[주의] 업로더와 요청자의 id가 같아도 객체 인스턴스가 다르면 isNowUser가 false로 나온다" + + " (RunnectUser에 equals/hashCode 미구현, 참조 동일성으로 비교됨)") + void 같은_id여도_인스턴스가_다르면_다른_사람으로_판정된다() { + RunnectUser requester = buildUser(1L); + RunnectUser uploaderWithSameIdButDifferentInstance = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, uploaderWithSameIdButDifferentInstance, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("업로더 정보가 없는 코스는 isNowUser가 false이고 userId는 -1이다") + void 업로더가_없는_코스() { + RunnectUser requester = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, null, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + assertThat(response.getUser().getUserId()).isEqualTo(-1L); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseDetail(10L, 1L)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseDetail(10L, 1L)) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("updateCourse") + class UpdateCourse { + + @Test + @DisplayName("코스 제목을 수정한다") + void 정상_수정() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + UpdateCourseResponseDto response = courseService.updateCourse(1L, 10L, "새 제목"); + + assertThat(course.getTitle()).isEqualTo("새 제목"); + assertThat(response.getCourse().getTitle()).isEqualTo("새 제목"); + assertThat(response.getCourse().getId()).isEqualTo(10L); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.updateCourse(1L, 10L, "새 제목")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("[버그 의심] userId로 소유자 검증을 하지 않아, 코스 소유자가 아니어도 제목을 수정할 수 있다") + void 소유자가_아니어도_수정_가능() { + RunnectUser owner = buildUser(1L); + Course course = buildCourse(10L, owner, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + Long 다른유저Id = 999L; + UpdateCourseResponseDto response = courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경"); + + assertThat(course.getTitle()).isEqualTo("남의 코스 제목 변경"); + assertThat(response.getCourse().getTitle()).isEqualTo("남의 코스 제목 변경"); + } + } + + @Nested + @DisplayName("deleteCourses") + class DeleteCourses { + + @Test + @DisplayName("비공개 코스는 soft delete만 수행한다") + void 비공개_코스_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); + + DeleteCoursesResponseDto response = courseService.deleteCourses( + Collections.singletonList(10L), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(1); + assertThat(course.getDeletedAt()).isNotNull(); + verify(publicCourseRepository, never()).delete(any()); + } + + @Test + @DisplayName("공개된 코스는 공개 코스를 함께 삭제하고 비공개로 되돌린 뒤 soft delete한다") + void 공개_코스_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = mock(PublicCourse.class); + ReflectionTestUtils.setField(course, "publicCourse", publicCourse); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); + + courseService.deleteCourses(Collections.singletonList(10L), 1L); + + verify(publicCourseRepository).delete(publicCourse); + assertThat(course.getIsPrivate()).isTrue(); + assertThat(course.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("여러 개를 한 번에 삭제하면 개수가 그대로 반환된다") + void 여러개_삭제() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, true); + Course course2 = buildCourse(11L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course1)); + when(courseRepository.findByCourseIdAndUserId(11L, 1L)).thenReturn(Optional.of(course2)); + + DeleteCoursesResponseDto response = courseService.deleteCourses(Arrays.asList(10L, 11L), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(2); + } + + @Test + @DisplayName("본인 소유가 아니거나 존재하지 않는 코스면 NotFoundException") + void 존재하지_않거나_소유자가_아닌_코스() { + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.deleteCourses(Collections.singletonList(10L), 1L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("빈 목록을 요청하면 아무 것도 삭제하지 않고 0을 반환한다") + void 빈_목록() { + DeleteCoursesResponseDto response = courseService.deleteCourses(Collections.emptyList(), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(0); + verify(courseRepository, never()).findByCourseIdAndUserId(any(), any()); + } + + @Test + @DisplayName("목록 중간에 실패하면 그 이후 항목은 조회조차 되지 않고 예외가 전파된다") + void 중간에_실패하면_이후_항목은_처리되지_않는다() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course1)); + when(courseRepository.findByCourseIdAndUserId(11L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.deleteCourses(Arrays.asList(10L, 11L, 12L), 1L)) + .isInstanceOf(NotFoundException.class); + + assertThat(course1.getDeletedAt()).isNotNull(); + verify(courseRepository, never()).findByCourseIdAndUserId(12L, 1L); + } + } +} From ae46de39ee4c5c543b3cbefedf85316863643067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:51:06 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=A4=91=20=EB=B0=9C=EA=B2=AC=ED=95=9C=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?3=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DepartureConverter: 출발지 주소가 3토큰 미만이면 null 대신 BadRequestException(VALIDATION_DEPARTURE_ADDRESS_EXCEPTION)을 던지도록 변경 (기존엔 CourseService에서 바로 NPE로 이어짐) 2. CourseService.updateCourse: findById → findByCourseIdAndUserId로 변경해 본인 소유 코스만 수정 가능하도록 수정 (IDOR 방지, deleteCourses와 동일 패턴) 3. RunnectUser: equals/hashCode를 id 기준으로 구현. 기존엔 참조 동일성에 의존해 같은 유저라도 인스턴스가 다르면(Course.isMatchedUser, RecordService, PublicCourseService 등에서) 다른 사람으로 오판정될 수 있었음 CourseServiceTest의 관련 3개 테스트를 수정된 동작에 맞게 갱신하고, RunnectUserTest를 새로 추가해 equals/hashCode 자체를 검증. --- .../server/common/constant/ErrorStatus.java | 1 + .../module/convert/DepartureConverter.java | 5 +- .../server/course/service/CourseService.java | 2 +- .../server/user/entity/RunnectUser.java | 20 +++++ .../course/service/CourseServiceTest.java | 33 ++++----- .../server/user/entity/RunnectUserTest.java | 74 +++++++++++++++++++ 6 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 src/test/java/org/runnect/server/user/entity/RunnectUserTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 0903bd3..d771ffd 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -34,6 +34,7 @@ public enum ErrorStatus { INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"), INVALID_DATE_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 날짜 범위입니다"), EXCEED_HEART_RATE_SAMPLES_EXCEPTION(HttpStatus.BAD_REQUEST, "심박수 샘플은 최대 5000건까지 허용됩니다"), + VALIDATION_DEPARTURE_ADDRESS_EXCEPTION(HttpStatus.BAD_REQUEST, "출발지 주소 형식이 올바르지 않습니다."), /** * 401 UNAUTHORIZED diff --git a/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java b/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java index 207e12d..46a640f 100644 --- a/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java +++ b/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java @@ -1,6 +1,8 @@ package org.runnect.server.common.module.convert; +import org.runnect.server.common.constant.ErrorStatus; import org.runnect.server.common.dto.DepartureResponse; +import org.runnect.server.common.exception.BadRequestException; import java.util.Arrays; @@ -8,7 +10,8 @@ public class DepartureConverter { public static DepartureResponse requestConvertDeparture(String departureAddress, String departureName) { String[] departures = departureAddress.split(" "); if (departures.length < 3) { - return null; + throw new BadRequestException(ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION, + ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION.getMessage()); } else if (departures.length == 3) { return new DepartureResponse( departures[0], diff --git a/src/main/java/org/runnect/server/course/service/CourseService.java b/src/main/java/org/runnect/server/course/service/CourseService.java index 7bfd3e0..ecec62f 100644 --- a/src/main/java/org/runnect/server/course/service/CourseService.java +++ b/src/main/java/org/runnect/server/course/service/CourseService.java @@ -129,7 +129,7 @@ public GetCourseDetailResponseDto getCourseDetail(Long courseId, Long userId) { @Transactional public UpdateCourseResponseDto updateCourse(Long userId, Long courseId, String title) { - Course course = courseRepository.findById(courseId) + Course course = courseRepository.findByCourseIdAndUserId(courseId, userId) .orElseThrow(()->new NotFoundException(NOT_FOUND_COURSE_EXCEPTION, NOT_FOUND_COURSE_EXCEPTION.getMessage())); course.updateCourse(title); diff --git a/src/main/java/org/runnect/server/user/entity/RunnectUser.java b/src/main/java/org/runnect/server/user/entity/RunnectUser.java index 69ed968..a37f7e3 100644 --- a/src/main/java/org/runnect/server/user/entity/RunnectUser.java +++ b/src/main/java/org/runnect/server/user/entity/RunnectUser.java @@ -131,4 +131,24 @@ public void updateCreatedPublicCourse() { public void updateDeletedAt() { throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); } + + // 기본 equals(참조 비교)에 의존하면, 같은 유저를 서로 다른 조회 경로로 가져왔을 때 + // (예: 로그인 유저 vs 코스에 매핑된 유저) 같은 사람인데도 다르다고 판정될 수 있다. + // id 기준으로 비교하고, hashCode는 영속화 전후로 값이 바뀌지 않도록 상수로 고정한다. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RunnectUser)) { + return false; + } + RunnectUser that = (RunnectUser) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java index 3f2a5b2..75c9059 100644 --- a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -173,14 +173,16 @@ class CreateCourse { } @Test - @DisplayName("[버그 의심] 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환해 NPE가 난다") - void 출발지_주소가_불완전하면_NPE() { + @DisplayName("출발지 주소가 3토큰 미만이면 BadRequestException") + void 출발지_주소가_불완전하면_BadRequestException() { RunnectUser user = buildUser(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); assertThatThrownBy(() -> courseService.createCourse(1L, createRequestDto(validPath(), "경기 시흥시"), "img")) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(BadRequestException.class); + + verify(courseRepository, never()).save(any()); } } @@ -292,9 +294,8 @@ class GetCourseDetail { } @Test - @DisplayName("[주의] 업로더와 요청자의 id가 같아도 객체 인스턴스가 다르면 isNowUser가 false로 나온다" - + " (RunnectUser에 equals/hashCode 미구현, 참조 동일성으로 비교됨)") - void 같은_id여도_인스턴스가_다르면_다른_사람으로_판정된다() { + @DisplayName("업로더와 요청자의 id가 같으면 객체 인스턴스가 달라도 같은 사람으로 판정된다") + void 같은_id면_인스턴스가_달라도_같은_사람으로_판정된다() { RunnectUser requester = buildUser(1L); RunnectUser uploaderWithSameIdButDifferentInstance = buildUser(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); @@ -303,7 +304,7 @@ class GetCourseDetail { GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); - assertThat(response.getCourse().getIsNowUser()).isFalse(); + assertThat(response.getCourse().getIsNowUser()).isTrue(); } @Test @@ -350,7 +351,7 @@ class UpdateCourse { void 정상_수정() { RunnectUser user = buildUser(1L); Course course = buildCourse(10L, user, true); - when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); UpdateCourseResponseDto response = courseService.updateCourse(1L, 10L, "새 제목"); @@ -362,24 +363,20 @@ class UpdateCourse { @Test @DisplayName("존재하지 않는 코스면 NotFoundException") void 존재하지_않는_코스() { - when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> courseService.updateCourse(1L, 10L, "새 제목")) .isInstanceOf(NotFoundException.class); } @Test - @DisplayName("[버그 의심] userId로 소유자 검증을 하지 않아, 코스 소유자가 아니어도 제목을 수정할 수 있다") - void 소유자가_아니어도_수정_가능() { - RunnectUser owner = buildUser(1L); - Course course = buildCourse(10L, owner, true); - when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); - + @DisplayName("코스 소유자가 아니면 NotFoundException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { Long 다른유저Id = 999L; - UpdateCourseResponseDto response = courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경"); + when(courseRepository.findByCourseIdAndUserId(10L, 다른유저Id)).thenReturn(Optional.empty()); - assertThat(course.getTitle()).isEqualTo("남의 코스 제목 변경"); - assertThat(response.getCourse().getTitle()).isEqualTo("남의 코스 제목 변경"); + assertThatThrownBy(() -> courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경")) + .isInstanceOf(NotFoundException.class); } } diff --git a/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java b/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java new file mode 100644 index 0000000..e189629 --- /dev/null +++ b/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java @@ -0,0 +1,74 @@ +package org.runnect.server.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class RunnectUserTest { + + private RunnectUser userWithId(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너") + .socialId("social") + .email("runner@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + void 자기_자신과는_같다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isEqualTo(user); + } + + @Test + void id가_같으면_인스턴스가_달라도_같다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(1L); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void id가_다르면_다르다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(2L); + + assertThat(a).isNotEqualTo(b); + } + + @Test + void id가_없는_인스턴스끼리는_다르다() { + RunnectUser a = userWithId(null); + RunnectUser b = userWithId(null); + + assertThat(a).isNotEqualTo(b); + } + + @Test + void null과_비교하면_다르다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isNotEqualTo(null); + } + + @Test + void 다른_타입과_비교하면_다르다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isNotEqualTo("1"); + } + + @Test + void hashCode는_id와_무관하게_동일_클래스면_같다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(2L); + + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } +} From 8144bef2c16ceba29c3f69e6b51897d26fee5273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 18:47:42 +0900 Subject: [PATCH 4/9] =?UTF-8?q?test:=20RecordService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20+=20updat?= =?UTF-8?q?eRecord=20IDOR=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createRecord/getRecordByUser/updateRecord/deleteRecords 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (18개). 테스트 작성 중 CourseService.updateCourse와 동일한 패턴의 버그 발견해 수정: - updateRecord: userId 파라미터를 받지만 소유권 검증을 하지 않아 다른 사람의 기록 제목도 수정 가능했음 (IDOR). deleteRecords는 이미 소유권을 검증하고 있어서(PermissionDeniedException), 동일 패턴으로 맞춰서 수정. ErrorStatus.PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION 추가. getRecordByUser의 건강 데이터 조회 실패 시 전체 요청은 실패하지 않고 healthData만 null로 우아하게 처리되는 방어 로직도 별도로 검증함. --- .../server/common/constant/ErrorStatus.java | 1 + .../server/record/service/RecordService.java | 5 + .../record/service/RecordServiceTest.java | 435 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 src/test/java/org/runnect/server/record/service/RecordServiceTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index d771ffd..bf314e5 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -55,6 +55,7 @@ public enum ErrorStatus { */ PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."), + PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 수정할 권한이 존재하지 않습니다."), PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"), /** diff --git a/src/main/java/org/runnect/server/record/service/RecordService.java b/src/main/java/org/runnect/server/record/service/RecordService.java index fdf2ca6..bfc3e42 100644 --- a/src/main/java/org/runnect/server/record/service/RecordService.java +++ b/src/main/java/org/runnect/server/record/service/RecordService.java @@ -138,6 +138,11 @@ public UpdateRecordResponseDto updateRecord(Long userId, Long recordId, UpdateRe Record record = recordRepository.findById(recordId) .orElseThrow(()->new NotFoundException(ErrorStatus.NOT_FOUND_RECORD_EXCEPTION, ErrorStatus.NOT_FOUND_RECORD_EXCEPTION.getMessage())); + if (!record.getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException(ErrorStatus.PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION.getMessage()); + } + record.updateRecord(request.getTitle()); return UpdateRecordResponseDto.of(UpdateRecordResponse.of(record.getId(), request.getTitle())); diff --git a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java new file mode 100644 index 0000000..726814c --- /dev/null +++ b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java @@ -0,0 +1,435 @@ +package org.runnect.server.record.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.course.entity.Course; +import org.runnect.server.course.repository.CourseRepository; +import org.runnect.server.health.entity.RecordHealthData; +import org.runnect.server.health.repository.RecordHealthDataRepository; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.record.dto.request.CreateRecordRequestDto; +import org.runnect.server.record.dto.request.DeleteRecordsRequestDto; +import org.runnect.server.record.dto.request.UpdateRecordRequestDto; +import org.runnect.server.record.dto.response.CreateRecordResponseDto; +import org.runnect.server.record.dto.response.DeleteRecordsResponseDto; +import org.runnect.server.record.dto.response.GetRecordResponseDto; +import org.runnect.server.record.dto.response.RecordResponse; +import org.runnect.server.record.dto.response.UpdateRecordResponseDto; +import org.runnect.server.record.entity.Record; +import org.runnect.server.record.repository.RecordRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.StampType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.runnect.server.user.service.UserStampService; +import org.springframework.beans.BeanUtils; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class RecordServiceTest { + + @Mock + private RecordRepository recordRepository; + @Mock + private UserRepository userRepository; + @Mock + private CourseRepository courseRepository; + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserStampService userStampService; + @Mock + private RecordHealthDataRepository recordHealthDataRepository; + + private RecordService recordService; + + @BeforeEach + void setUp() { + recordService = new RecordService(recordRepository, userRepository, courseRepository, + publicCourseRepository, userStampService, recordHealthDataRepository); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + 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(null) + .build(); + ReflectionTestUtils.setField(course, "id", id); + return course; + } + + private Record buildRecord(Long id, RunnectUser owner, Course course, PublicCourse publicCourse) { + Record record = Record.builder() + .runnectUser(owner) + .course(course) + .publicCourse(publicCourse) + .title("퇴근길 러닝") + .pace(java.sql.Time.valueOf("00:05:30")) + .time(java.sql.Time.valueOf("00:25:00")) + .build(); + ReflectionTestUtils.setField(record, "id", id); + ReflectionTestUtils.setField(record, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + return record; + } + + private CreateRecordRequestDto createRecordRequestDto(Long courseId, Long publicCourseId, String time, + String pace) { + CreateRecordRequestDto dto = BeanUtils.instantiateClass(CreateRecordRequestDto.class); + ReflectionTestUtils.setField(dto, "courseId", courseId); + ReflectionTestUtils.setField(dto, "publicCourseId", publicCourseId); + ReflectionTestUtils.setField(dto, "title", "퇴근길 러닝"); + ReflectionTestUtils.setField(dto, "time", time); + ReflectionTestUtils.setField(dto, "pace", pace); + return dto; + } + + private UpdateRecordRequestDto updateRecordRequestDto(String title) { + UpdateRecordRequestDto dto = BeanUtils.instantiateClass(UpdateRecordRequestDto.class); + ReflectionTestUtils.setField(dto, "title", title); + return dto; + } + + private void stubSaveSetsCreatedAt() { + doAnswer(invocation -> { + Record record = invocation.getArgument(0); + ReflectionTestUtils.setField(record, "id", 100L); + ReflectionTestUtils.setField(record, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + return null; + }).when(recordRepository).save(any(Record.class)); + } + + @Nested + @DisplayName("createRecord") + class CreateRecord { + + @Test + @DisplayName("courseId로 직접 생성하면 코스를 조회해서 기록을 저장한다") + void 코스아이디로_정상_생성() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + stubSaveSetsCreatedAt(); + + CreateRecordRequestDto request = createRecordRequestDto(10L, null, "00:25:00", "00:05:30"); + + CreateRecordResponseDto response = recordService.createRecord(1L, request); + + assertThat(response.getRecord().getId()).isEqualTo(100L); + assertThat(user.getCreatedRecord()).isEqualTo(1L); + verify(userStampService).createStampByUser(user, StampType.r); + verify(publicCourseRepository, never()).findById(any()); + } + + @Test + @DisplayName("publicCourseId로 생성하면 공개 코스를 통해 코스를 가져오고 courseRepository는 조회하지 않는다") + void 퍼블릭코스아이디로_정상_생성() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", 20L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(20L)).thenReturn(Optional.of(publicCourse)); + stubSaveSetsCreatedAt(); + + CreateRecordRequestDto request = createRecordRequestDto(null, 20L, "00:25:00", "00:05:30"); + + recordService.createRecord(1L, request); + + verify(courseRepository, never()).findById(any()); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + CreateRecordRequestDto request = createRecordRequestDto(10L, null, "00:25:00", "00:05:30"); + + assertThatThrownBy(() -> recordService.createRecord(1L, request)) + .isInstanceOf(NotFoundException.class); + + verify(recordRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개_코스() { + when(publicCourseRepository.findById(20L)).thenReturn(Optional.empty()); + + CreateRecordRequestDto request = createRecordRequestDto(null, 20L, "00:25:00", "00:05:30"); + + assertThatThrownBy(() -> recordService.createRecord(1L, request)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + Course course = buildCourse(10L, buildUser(2L)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + CreateRecordRequestDto request = createRecordRequestDto(10L, null, "00:25:00", "00:05:30"); + + assertThatThrownBy(() -> recordService.createRecord(1L, request)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("시간 형식이 잘못되면 IllegalArgumentException (컨트롤러 어드바이스가 400으로 매핑)") + void 시간_형식이_잘못됨() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + CreateRecordRequestDto request = createRecordRequestDto(10L, null, "잘못된시간", "00:05:30"); + + assertThatThrownBy(() -> recordService.createRecord(1L, request)) + .isInstanceOf(IllegalArgumentException.class); + + verify(recordRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("getRecordByUser") + class GetRecordByUser { + + @Test + @DisplayName("유저의 기록 목록을 매핑해서 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + Record record = buildRecord(50L, user, course, null); + when(recordRepository.findAllByUserId(1L)).thenReturn(Collections.singletonList(record)); + when(recordHealthDataRepository.findByRecordId(50L)).thenReturn(Optional.empty()); + + GetRecordResponseDto response = recordService.getRecordByUser(1L); + + assertThat(response.getUser().getUserId()).isEqualTo(1L); + assertThat(response.getRecords()).hasSize(1); + RecordResponse recordResponse = response.getRecords().get(0); + assertThat(recordResponse.getId()).isEqualTo(50L); + assertThat(recordResponse.getCourseId()).isEqualTo(10L); + assertThat(recordResponse.getPublicCourseId()).isNull(); + assertThat(recordResponse.getUserId()).isEqualTo(1L); + assertThat(recordResponse.getDeparture().getRegion()).isEqualTo("경기"); + assertThat(recordResponse.getHealthData()).isNull(); + } + + @Test + @DisplayName("공개 코스로 만든 기록이면 publicCourseId가 채워진다") + void 퍼블릭코스_아이디_매핑() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + PublicCourse publicCourse = PublicCourse.builder().course(course).title("t").description("d").build(); + ReflectionTestUtils.setField(publicCourse, "id", 20L); + Record record = buildRecord(50L, user, course, publicCourse); + when(recordRepository.findAllByUserId(1L)).thenReturn(Collections.singletonList(record)); + when(recordHealthDataRepository.findByRecordId(50L)).thenReturn(Optional.empty()); + + GetRecordResponseDto response = recordService.getRecordByUser(1L); + + assertThat(response.getRecords().get(0).getPublicCourseId()).isEqualTo(20L); + } + + @Test + @DisplayName("건강 데이터가 있으면 함께 반환한다") + void 건강_데이터_포함() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + Record record = buildRecord(50L, user, course, null); + RecordHealthData healthData = mock(RecordHealthData.class); + when(healthData.getAvgHeartRate()).thenReturn(145.5); + when(healthData.getCalories()).thenReturn(320.0); + when(recordRepository.findAllByUserId(1L)).thenReturn(Collections.singletonList(record)); + when(recordHealthDataRepository.findByRecordId(50L)).thenReturn(Optional.of(healthData)); + + GetRecordResponseDto response = recordService.getRecordByUser(1L); + + assertThat(response.getRecords().get(0).getHealthData().getAvgHeartRate()).isEqualTo(145.5); + assertThat(response.getRecords().get(0).getHealthData().getCalories()).isEqualTo(320.0); + } + + @Test + @DisplayName("건강 데이터 조회가 실패해도 기록 목록 조회 자체는 실패하지 않는다") + void 건강_데이터_조회_실패해도_정상_반환() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + Record record = buildRecord(50L, user, course, null); + when(recordRepository.findAllByUserId(1L)).thenReturn(Collections.singletonList(record)); + when(recordHealthDataRepository.findByRecordId(50L)).thenThrow(new RuntimeException("DB 오류")); + + GetRecordResponseDto response = recordService.getRecordByUser(1L); + + assertThat(response.getRecords()).hasSize(1); + assertThat(response.getRecords().get(0).getHealthData()).isNull(); + } + + @Test + @DisplayName("기록이 없으면 빈 목록을 반환한다") + void 기록이_없으면_빈_목록() { + when(recordRepository.findAllByUserId(1L)).thenReturn(Collections.emptyList()); + + GetRecordResponseDto response = recordService.getRecordByUser(1L); + + assertThat(response.getRecords()).isEmpty(); + } + } + + @Nested + @DisplayName("updateRecord") + class UpdateRecord { + + @Test + @DisplayName("본인 기록이면 제목을 수정한다") + void 정상_수정() { + RunnectUser owner = buildUser(1L); + Record record = buildRecord(50L, owner, buildCourse(10L, owner), null); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + UpdateRecordResponseDto response = recordService.updateRecord(1L, 50L, updateRecordRequestDto("새 제목")); + + assertThat(record.getTitle()).isEqualTo("새 제목"); + assertThat(response.getRecord().getTitle()).isEqualTo("새 제목"); + } + + @Test + @DisplayName("존재하지 않는 기록이면 NotFoundException") + void 존재하지_않는_기록() { + when(recordRepository.findById(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recordService.updateRecord(1L, 50L, updateRecordRequestDto("새 제목"))) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 기록이 아니면 PermissionDeniedException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { + RunnectUser owner = buildUser(1L); + Record record = buildRecord(50L, owner, buildCourse(10L, owner), null); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + Long 다른유저Id = 999L; + + assertThatThrownBy(() -> recordService.updateRecord(다른유저Id, 50L, updateRecordRequestDto("남의 기록 수정"))) + .isInstanceOf(PermissionDeniedException.class); + + assertThat(record.getTitle()).isEqualTo("퇴근길 러닝"); + } + } + + @Nested + @DisplayName("deleteRecords") + class DeleteRecords { + + @Test + @DisplayName("본인 기록만 있으면 정상 삭제한다") + void 정상_삭제() { + RunnectUser user = buildUser(1L); + Record record1 = buildRecord(50L, user, buildCourse(10L, user), null); + Record record2 = buildRecord(51L, user, buildCourse(10L, user), null); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(recordRepository.findByIdIn(Arrays.asList(50L, 51L))).thenReturn(Arrays.asList(record1, record2)); + when(recordRepository.deleteByIdIn(Arrays.asList(50L, 51L))).thenReturn(2L); + + DeleteRecordsResponseDto response = recordService.deleteRecords(1L, + new DeleteRecordsRequestDto(Arrays.asList(50L, 51L))); + + assertThat(response.getDeletedRecordIdCount()).isEqualTo(2L); + } + + @Test + @DisplayName("요청한 id 중 존재하지 않는 게 있으면 NotFoundException") + void 존재하지_않는_기록_포함() { + RunnectUser user = buildUser(1L); + Record record1 = buildRecord(50L, user, buildCourse(10L, user), null); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(recordRepository.findByIdIn(Arrays.asList(50L, 999L))).thenReturn( + Collections.singletonList(record1)); + + assertThatThrownBy(() -> recordService.deleteRecords(1L, + new DeleteRecordsRequestDto(Arrays.asList(50L, 999L)))) + .isInstanceOf(NotFoundException.class); + + verify(recordRepository, never()).deleteByIdIn(any()); + } + + @Test + @DisplayName("본인 소유가 아닌 기록이 섞여 있으면 PermissionDeniedException") + void 소유자가_아닌_기록_포함() { + RunnectUser user = buildUser(1L); + RunnectUser otherUser = buildUser(2L); + Record ownRecord = buildRecord(50L, user, buildCourse(10L, user), null); + Record othersRecord = buildRecord(51L, otherUser, buildCourse(11L, otherUser), null); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(recordRepository.findByIdIn(Arrays.asList(50L, 51L))).thenReturn( + Arrays.asList(ownRecord, othersRecord)); + + assertThatThrownBy(() -> recordService.deleteRecords(1L, + new DeleteRecordsRequestDto(Arrays.asList(50L, 51L)))) + .isInstanceOf(PermissionDeniedException.class); + + verify(recordRepository, never()).deleteByIdIn(any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recordService.deleteRecords(1L, + new DeleteRecordsRequestDto(Collections.singletonList(50L)))) + .isInstanceOf(NotFoundUserException.class); + } + } +} From d6c466ea6347bc428ec2c4c282bdce4f7b210c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 19:26:37 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20PublicCourseService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20=EB=B2=84=EA=B7=B8=204=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPublicCourseTotalPageCount/getMarathonPublicCourse/searchPublicCourse/ recommendPublicCourse/getPublicCourseByUser/getPublicCourseDetail/ createPublicCourse/deletePublicCourses/updatePublicCourse 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (38개). 테스트 작성 중 발견해서 함께 수정한 버그 4건: 1. PublicCourse에 equals/hashCode 부재 — RunnectUser와 동일한 참조비교 문제. scrap 목록과 publicCourse 목록을 서로 다른 쿼리로 가져와 비교하는 곳이 5곳(getMarathonPublicCourse, searchPublicCourse, recommendPublicCourse, getPublicCourseByUser, getPublicCourseDetail)이라 isScrap이 잘못 표시될 수 있었음. id 기준 equals/hashCode 추가로 일괄 해결. 2. getPublicCourseDetail: 삭제된 코스 체크 조건이 반대(`== null`)였고, 심지어 예외를 생성만 하고 throw를 안 해서 완전히 죽은 코드였음. 조건 반전 + throw 추가. 3. updatePublicCourse: userId를 받으면서 소유권 검증을 안 해 다른 사람의 공개 코스 제목/설명도 수정 가능했음 (IDOR). deletePublicCourses와 동일한 관리자 예외 패턴으로 소유권 검증 추가. ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION 추가. 4. recommendPublicCourse: sort 파라미터가 "scrap"/"date" 둘 다 아니면 Page 변수가 null로 남아 NPE. 이미 정의돼 있던 INVALID_SORT_PARAMETER_EXCEPTION을 실제로 사용하도록 수정. --- .../server/common/constant/ErrorStatus.java | 1 + .../publicCourse/entity/PublicCourse.java | 19 + .../service/PublicCourseService.java | 14 +- .../service/PublicCourseServiceTest.java | 717 ++++++++++++++++++ 4 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index bf314e5..42398bf 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -54,6 +54,7 @@ public enum ErrorStatus { * 403 FORBIDDEN */ PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."), + PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 수정할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 수정할 권한이 존재하지 않습니다."), PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"), diff --git a/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java b/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java index 6c69625..21b855a 100644 --- a/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java +++ b/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java @@ -63,4 +63,23 @@ public void updatePublicCourse(String title, String description) { public void updateDeletedAt() { throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); } + + // RunnectUser와 동일한 이유(참조 동일성 의존 방지)로 id 기준 equals/hashCode를 둔다. + // scrap 목록과 publicCourse 목록을 서로 다른 쿼리로 가져와 비교하는 곳(isScrap 매칭)이 많아서 영향이 크다. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PublicCourse)) { + return false; + } + PublicCourse that = (PublicCourse) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java b/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java index 5a757fd..7c40109 100644 --- a/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java +++ b/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.runnect.server.common.constant.ErrorStatus; import org.runnect.server.common.constant.SortStatus; +import org.runnect.server.common.exception.BadRequestException; import org.runnect.server.common.exception.ConflictException; import org.runnect.server.common.exception.NotFoundException; import org.runnect.server.common.exception.PermissionDeniedException; @@ -182,6 +183,9 @@ public RecommendPublicCourseResponseDto recommendPublicCourse(Long userId, Integ publicCourses = publicCourseRepository.findAll( PageRequest.of(pageNo - 1, PAGE_SIZE, Sort.by(Sort.Direction.DESC, SortStatus.DATE_DESC.getProperty()))); + } else { + throw new BadRequestException(ErrorStatus.INVALID_SORT_PARAMETER_EXCEPTION, + ErrorStatus.INVALID_SORT_PARAMETER_EXCEPTION.getMessage()); } publicCourses.forEach(publicCourse -> { @@ -264,8 +268,8 @@ public GetPublicCourseDetailResponseDto getPublicCourseDetail(final Long userId, Course course = publicCourse.getCourse(); //2. 이미 삭제된 코스인지 - if (course.getDeletedAt() == null) { - new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, + if (course.getDeletedAt() != null) { + throw new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage()); } @@ -401,6 +405,12 @@ public UpdatePublicCourseResponseDto updatePublicCourse(Long userId, Long public PublicCourse publicCourse = publicCourseRepository.findById(publicCourseId) .orElseThrow(() -> new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage())); + boolean isAdmin = userId.equals(ADMIN_USER_ID); + if (!isAdmin && !publicCourse.getCourse().getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException(ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION.getMessage()); + } + publicCourse.updatePublicCourse(title, description); return UpdatePublicCourseResponseDto.of(publicCourse); diff --git a/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java b/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java new file mode 100644 index 0000000..50591d2 --- /dev/null +++ b/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java @@ -0,0 +1,717 @@ +package org.runnect.server.publicCourse.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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.common.exception.BadRequestException; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +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.dto.request.CreatePublicCourseRequestDto; +import org.runnect.server.publicCourse.dto.request.DeletePublicCoursesRequestDto; +import org.runnect.server.publicCourse.dto.response.CreatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.DeletePublicCoursesResponseDto; +import org.runnect.server.publicCourse.dto.response.GetPublicCourseDetailResponseDto; +import org.runnect.server.publicCourse.dto.response.GetPublicCourseTotalPageCountResponseDto; +import org.runnect.server.publicCourse.dto.response.UpdatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getMarathonPublicCourse.GetMarathonPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getPublicCourseByUser.GetPublicCourseByUserResponseDto; +import org.runnect.server.publicCourse.dto.response.recommendPublicCourse.RecommendPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.searchPublicCourse.SearchPublicCourseResponseDto; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.scrap.entity.Scrap; +import org.runnect.server.scrap.repository.ScrapRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PublicCourseServiceTest { + + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserRepository userRepository; + @Mock + private ScrapRepository scrapRepository; + @Mock + private CourseRepository courseRepository; + + private PublicCourseService publicCourseService; + + @BeforeEach + void setUp() { + publicCourseService = new PublicCourseService(publicCourseRepository, userRepository, scrapRepository, + courseRepository); + ReflectionTestUtils.invokeMethod(publicCourseService, "setMARATHON_PUBLIC_COURSE_IDS", "100,200"); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + )); + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate, String departureName) { + Course course = Course.builder() + .runnectUser(owner) + .title("코스 제목") + .departureRegion("경기") + .departureCity("시흥시") + .departureTown("정왕동") + .departureDetail("정왕본동") + .departureName(departureName) + .distance(5.2f) + .image("https://image.example/course.png") + .path(validLineString()) + .build(); + ReflectionTestUtils.setField(course, "id", id); + if (!isPrivate) { + ReflectionTestUtils.setField(course, "isPrivate", false); + } + return course; + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate) { + return buildCourse(id, owner, isPrivate, "정왕역"); + } + + private PublicCourse buildPublicCourse(Long id, Course course) { + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스 제목") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", id); + return publicCourse; + } + + private Scrap buildScrap(RunnectUser user, PublicCourse publicCourse) { + return Scrap.builder().runnectUser(user).publicCourse(publicCourse).scrapTF(true).build(); + } + + @Nested + @DisplayName("getPublicCourseTotalPageCount") + class GetPublicCourseTotalPageCount { + + @Test + @DisplayName("정확히 나누어 떨어지면 그대로 페이지 수가 된다") + void 나누어_떨어짐() { + when(publicCourseRepository.countBy()).thenReturn(20L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(2L); + } + + @Test + @DisplayName("나누어 떨어지지 않으면 올림해서 한 페이지를 더한다") + void 나누어_안_떨어짐() { + when(publicCourseRepository.countBy()).thenReturn(21L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(3L); + } + + @Test + @DisplayName("코스가 없으면 0페이지다") + void 코스가_없음() { + when(publicCourseRepository.countBy()).thenReturn(0L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("getMarathonPublicCourse") + class GetMarathonPublicCourse { + + @Test + @DisplayName("마라톤 코스 목록을 스크랩 여부와 함께 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, false); + Course course2 = buildCourse(11L, user, false); + PublicCourse pc1 = buildPublicCourse(100L, course1); + PublicCourse pc2 = buildPublicCourse(200L, course2); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course1))))); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 200L))).thenReturn(Arrays.asList(pc1, pc2)); + + GetMarathonPublicCourseResponseDto response = publicCourseService.getMarathonPublicCourse(1L); + + assertThat(response.getMarathonPublicCourses()).hasSize(2); + assertThat(response.getMarathonPublicCourses().get(0).getScrap()).isTrue(); + assertThat(response.getMarathonPublicCourses().get(1).getScrap()).isFalse(); + } + + @Test + @DisplayName("설정된 마라톤 코스 중 일부가 존재하지 않으면 NotFoundException") + void 마라톤_코스_일부_없음() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, false); + PublicCourse pc1 = buildPublicCourse(100L, course1); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 200L))).thenReturn( + Collections.singletonList(pc1)); + + assertThatThrownBy(() -> publicCourseService.getMarathonPublicCourse(1L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getMarathonPublicCourse(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("searchPublicCourse") + class SearchPublicCourse { + + @Test + @DisplayName("키워드로 검색된 코스를 스크랩 여부와 함께 반환한다") + void 정상_검색() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course))))); + when(publicCourseRepository.searchPublicCourseByKeyword("정왕")).thenReturn( + Collections.singletonList(publicCourse)); + + SearchPublicCourseResponseDto response = publicCourseService.searchPublicCourse(1L, "정왕"); + + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getPublicCourses().get(0).getScrap()).isTrue(); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 목록을 반환한다") + void 검색_결과_없음() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.searchPublicCourseByKeyword("없는키워드")).thenReturn(Collections.emptyList()); + + SearchPublicCourseResponseDto response = publicCourseService.searchPublicCourse(1L, "없는키워드"); + + assertThat(response.getPublicCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.searchPublicCourse(1L, "정왕")) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("recommendPublicCourse") + class RecommendPublicCourse { + + @Test + @DisplayName("scrap 정렬로 조회한다") + void 스크랩순_정렬() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + Page page = new PageImpl<>(Collections.singletonList(publicCourse), + PageRequest.of(0, 10), 1); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findAll(any(Pageable.class))).thenReturn(page); + + RecommendPublicCourseResponseDto response = publicCourseService.recommendPublicCourse(1L, 1, "scrap"); + + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getOrdering()).isEqualTo("scrap"); + assertThat(response.getIsEnd()).isTrue(); + } + + @Test + @DisplayName("date 정렬로 조회한다") + void 최신순_정렬() { + RunnectUser user = buildUser(1L); + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findAll(any(Pageable.class))).thenReturn(page); + + RecommendPublicCourseResponseDto response = publicCourseService.recommendPublicCourse(1L, 1, "date"); + + assertThat(response.getOrdering()).isEqualTo("date"); + } + + @Test + @DisplayName("정렬 값이 scrap/date 둘 다 아니면 BadRequestException") + void 잘못된_정렬값() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + assertThatThrownBy(() -> publicCourseService.recommendPublicCourse(1L, 1, "인기순")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.recommendPublicCourse(1L, 1, "scrap")) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPublicCourseByUser") + class GetPublicCourseByUser { + + @Test + @DisplayName("유저가 공개한 코스 목록을 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + ReflectionTestUtils.setField(course, "publicCourse", publicCourse); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCoursesByRunnectUserAndIsPrivateIsFalseAndDeletedAtIsNull(user)) + .thenReturn(Collections.singletonList(course)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseByUserResponseDto response = publicCourseService.getPublicCourseByUser(1L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getPublicCourses().get(0).getId()).isEqualTo(100L); + } + + @Test + @DisplayName("공개한 코스가 없으면 빈 목록을 반환한다") + void 공개한_코스_없음() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCoursesByRunnectUserAndIsPrivateIsFalseAndDeletedAtIsNull(user)) + .thenReturn(Collections.emptyList()); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseByUserResponseDto response = publicCourseService.getPublicCourseByUser(1L); + + assertThat(response.getPublicCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPublicCourseDetail") + class GetPublicCourseDetail { + + @Test + @DisplayName("정상 조회 시 출발지 건물명이 있으면 포함해서 반환한다") + void 정상_조회_건물명_있음() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false, "정왕역"); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getUser().getIsNowUser()).isTrue(); + assertThat(response.getPublicCourse().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("출발지 건물명이 없어도 정상 조회된다") + void 정상_조회_건물명_없음() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false, null); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getPublicCourse().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("삭제된 코스면 NotFoundException") + void 삭제된_코스() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + ReflectionTestUtils.setField(course, "deletedAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("다른 사람이 올린 코스면 isNowUser가 false다") + void 타인_코스() { + RunnectUser uploader = buildUser(1L); + RunnectUser requester = buildUser(2L); + Course course = buildCourse(10L, uploader, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(2L)).thenReturn(Optional.of(requester)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(2L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(2L, 100L); + + assertThat(response.getUser().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("업로더가 탈퇴한 코스면 '알 수 없음' 유저로 대체된다") + void 업로더가_없는_코스() { + RunnectUser requester = buildUser(1L); + Course course = buildCourse(10L, null, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getUser().getNickname()).isEqualTo("알 수 없음"); + assertThat(response.getUser().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("본인이 스크랩한 코스면 scrap이 true다") + void 스크랩_매칭() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course))))); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getPublicCourse().getScrap()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("createPublicCourse") + class CreatePublicCourse { + + @Test + @DisplayName("본인 소유의 비공개 코스를 정상적으로 공개한다") + void 정상_생성() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(publicCourseRepository.save(any(PublicCourse.class))).thenAnswer(invocation -> { + PublicCourse saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 100L); + ReflectionTestUtils.setField(saved, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + return saved; + }); + + CreatePublicCourseRequestDto request = new CreatePublicCourseRequestDto(10L, "제목", "설명"); + + CreatePublicCourseResponseDto response = publicCourseService.createPublicCourse(1L, request); + + assertThat(response.getPublicCourse()).isNotNull(); + assertThat(course.getIsPrivate()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인이 그린 코스가 아니면 PermissionDeniedException") + void 소유자가_아님() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, buildUser(2L), true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(PermissionDeniedException.class); + } + + @Test + @DisplayName("이미 공개된 코스면 ConflictException") + void 이미_공개된_코스() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(ConflictException.class); + } + } + + @Nested + @DisplayName("deletePublicCourses") + class DeletePublicCourses { + + @Test + @DisplayName("본인 소유 공개 코스를 정상 삭제한다") + void 정상_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Collections.singletonList(100L))).thenReturn( + Collections.singletonList(publicCourse)); + + DeletePublicCoursesResponseDto response = publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Collections.singletonList(100L))); + + assertThat(response.getDeletedPublicCourseCount()).isEqualTo(1); + assertThat(course.getIsPrivate()).isTrue(); + verify(scrapRepository).deleteByPublicCourseIn(Collections.singletonList(publicCourse)); + verify(publicCourseRepository).deleteAll(Collections.singletonList(publicCourse)); + } + + @Test + @DisplayName("존재하지 않는 id가 포함되면 NotFoundException") + void 존재하지_않는_공개코스_포함() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 999L))).thenReturn(Collections.emptyList()); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Arrays.asList(100L, 999L)))) + .isInstanceOf(NotFoundException.class); + + verify(publicCourseRepository, never()).deleteAll(any()); + } + + @Test + @DisplayName("본인 소유가 아닌 코스가 섞여 있으면 PermissionDeniedException") + void 소유자가_아닌_코스_포함() { + RunnectUser user = buildUser(1L); + RunnectUser otherUser = buildUser(2L); + PublicCourse ownPublicCourse = buildPublicCourse(100L, buildCourse(10L, user, false)); + PublicCourse othersPublicCourse = buildPublicCourse(101L, buildCourse(11L, otherUser, false)); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 101L))).thenReturn( + Arrays.asList(ownPublicCourse, othersPublicCourse)); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Arrays.asList(100L, 101L)))) + .isInstanceOf(PermissionDeniedException.class); + + verify(publicCourseRepository, never()).deleteAll(any()); + } + + @Test + @DisplayName("관리자는 본인 소유가 아니어도 삭제할 수 있다") + void 관리자는_소유자가_아니어도_삭제_가능() { + Long adminId = 280L; + RunnectUser admin = buildUser(adminId); + RunnectUser otherUser = buildUser(2L); + PublicCourse othersPublicCourse = buildPublicCourse(101L, buildCourse(11L, otherUser, false)); + + when(userRepository.findById(adminId)).thenReturn(Optional.of(admin)); + when(publicCourseRepository.findByIdIn(Collections.singletonList(101L))).thenReturn( + Collections.singletonList(othersPublicCourse)); + + DeletePublicCoursesResponseDto response = publicCourseService.deletePublicCourses(adminId, + new DeletePublicCoursesRequestDto(Collections.singletonList(101L))); + + assertThat(response.getDeletedPublicCourseCount()).isEqualTo(1); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Collections.singletonList(100L)))) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("updatePublicCourse") + class UpdatePublicCourse { + + @Test + @DisplayName("본인 소유 공개 코스면 제목/설명을 수정한다") + void 정상_수정() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, user, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + UpdatePublicCourseResponseDto response = publicCourseService.updatePublicCourse(1L, 100L, "새 제목", + "새 설명"); + + assertThat(publicCourse.getTitle()).isEqualTo("새 제목"); + assertThat(publicCourse.getDescription()).isEqualTo("새 설명"); + assertThat(response.getPublicCourse().getTitle()).isEqualTo("새 제목"); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개코스() { + when(publicCourseRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.updatePublicCourse(1L, 100L, "새 제목", "새 설명")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 소유가 아니면 PermissionDeniedException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { + RunnectUser owner = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, owner, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + Long 다른유저Id = 999L; + + assertThatThrownBy(() -> publicCourseService.updatePublicCourse(다른유저Id, 100L, "남의 코스", "수정 시도")) + .isInstanceOf(PermissionDeniedException.class); + + assertThat(publicCourse.getTitle()).isEqualTo("공개 코스 제목"); + } + + @Test + @DisplayName("관리자는 본인 소유가 아니어도 수정할 수 있다") + void 관리자는_소유자가_아니어도_수정_가능() { + Long adminId = 280L; + RunnectUser owner = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, owner, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + UpdatePublicCourseResponseDto response = publicCourseService.updatePublicCourse(adminId, 100L, + "관리자 수정", "관리자 설명"); + + assertThat(response.getPublicCourse().getTitle()).isEqualTo("관리자 수정"); + } + } +} From 417ab078be7dd1b9f2ee373e4427ef6edf2aa6e8 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 6/9] =?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); + } + } +} From 1594abd3cd9ab502f4f09180f2b504d9550083d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 19:42:53 +0900 Subject: [PATCH 7/9] =?UTF-8?q?test:=20ScrapService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EC=B7=A8=EC=86=8C=20NPE=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAndDeleteScrap/getScrapCourseByUser 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (9개). 테스트 작성 중 발견해서 수정한 버그: - createAndDeleteScrap: 스크랩한 적 없는 코스를 "취소"(scrapTF=false) 요청하면 scrap 변수가 null이라 scrap.updateScrapTF(false) 호출 시 바로 NPE(500)가 났음. 클라이언트가 중복 취소 요청을 보내거나 race condition만 있어도 쉽게 재현 가능한 케이스라 조건 분기 추가로 null이면 조용히 무시하도록 수정 (idempotent하게). --- .../server/scrap/service/ScrapService.java | 3 +- .../scrap/service/ScrapServiceTest.java | 258 ++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java diff --git a/src/main/java/org/runnect/server/scrap/service/ScrapService.java b/src/main/java/org/runnect/server/scrap/service/ScrapService.java index 439c557..1afe4ba 100644 --- a/src/main/java/org/runnect/server/scrap/service/ScrapService.java +++ b/src/main/java/org/runnect/server/scrap/service/ScrapService.java @@ -54,7 +54,8 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA } } // 스크랩 삭제 - else { + else if (scrap != null) { + // 스크랩한 적이 없는 코스를 취소 요청하면 할 게 없으니 그냥 무시한다 (NPE 방지) scrap.updateScrapTF(false); } diff --git a/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java new file mode 100644 index 0000000..c42bdaa --- /dev/null +++ b/src/test/java/org/runnect/server/scrap/service/ScrapServiceTest.java @@ -0,0 +1,258 @@ +package org.runnect.server.scrap.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +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.common.exception.NotFoundException; +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.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.scrap.dto.request.CreateAndDeleteScrapRequestDto; +import org.runnect.server.scrap.dto.response.CreateAndDeleteScrapResponseDto; +import org.runnect.server.scrap.dto.response.GetScrapCourseResponseDto; +import org.runnect.server.scrap.entity.Scrap; +import org.runnect.server.scrap.repository.ScrapRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.StampType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.runnect.server.user.service.UserStampService; +import org.springframework.beans.BeanUtils; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ScrapServiceTest { + + @Mock + private ScrapRepository scrapRepository; + @Mock + private UserRepository userRepository; + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserStampService userStampService; + + private ScrapService scrapService; + + @BeforeEach + void setUp() { + scrapService = new ScrapService(scrapRepository, userRepository, publicCourseRepository, + userStampService); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + )); + } + + private PublicCourse buildPublicCourse(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", 10L); + ReflectionTestUtils.setField(course, "isPrivate", false); + + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", id); + return publicCourse; + } + + private Scrap buildScrap(Long id, RunnectUser user, PublicCourse publicCourse, boolean scrapTF) { + Scrap scrap = Scrap.builder().runnectUser(user).publicCourse(publicCourse).scrapTF(scrapTF).build(); + ReflectionTestUtils.setField(scrap, "id", id); + return scrap; + } + + private CreateAndDeleteScrapRequestDto requestDto(Long publicCourseId, boolean scrapTF) { + CreateAndDeleteScrapRequestDto dto = BeanUtils.instantiateClass(CreateAndDeleteScrapRequestDto.class); + ReflectionTestUtils.setField(dto, "publicCourseId", publicCourseId); + ReflectionTestUtils.setField(dto, "scrapTF", scrapTF); + return dto; + } + + @Nested + @DisplayName("createAndDeleteScrap") + class CreateAndDeleteScrap { + + @Test + @DisplayName("스크랩한 적 없는 코스를 새로 스크랩하면 신규 생성되고 유저 정보가 갱신된다") + void 신규_스크랩() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, user); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.countByPublicCourseAndScrapTFIsTrue(publicCourse)).thenReturn(1L); + + CreateAndDeleteScrapResponseDto response = scrapService.createAndDeleteScrap(1L, + requestDto(100L, true)); + + assertThat(response.getScrapCount()).isEqualTo(1L); + assertThat(response.getScrapTF()).isTrue(); + assertThat(user.getCreatedScrap()).isEqualTo(1L); + verify(userStampService).createStampByUser(user, StampType.s); + verify(scrapRepository).save(any(Scrap.class)); + } + + @Test + @DisplayName("이미 취소된 스크랩을 다시 스크랩하면 기존 행을 재활성화하고 새로 생성하지 않는다") + void 기존_스크랩_재활성화() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, user); + Scrap existingScrap = buildScrap(50L, user, publicCourse, false); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.of(existingScrap)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.countByPublicCourseAndScrapTFIsTrue(publicCourse)).thenReturn(1L); + + scrapService.createAndDeleteScrap(1L, requestDto(100L, true)); + + assertThat(existingScrap.getScrapTF()).isTrue(); + assertThat(user.getCreatedScrap()).isEqualTo(0L); + verify(scrapRepository, never()).save(any()); + verify(userStampService, never()).createStampByUser(any(), any()); + } + + @Test + @DisplayName("스크랩한 코스를 취소하면 scrapTF가 false가 된다") + void 스크랩_취소() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, user); + Scrap existingScrap = buildScrap(50L, user, publicCourse, true); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.of(existingScrap)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.countByPublicCourseAndScrapTFIsTrue(publicCourse)).thenReturn(0L); + + scrapService.createAndDeleteScrap(1L, requestDto(100L, false)); + + assertThat(existingScrap.getScrapTF()).isFalse(); + } + + @Test + @DisplayName("스크랩한 적 없는 코스를 취소 요청해도 예외 없이 처리된다") + void 스크랩한_적_없는_코스_취소_요청은_무시된다() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, user); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.countByPublicCourseAndScrapTFIsTrue(publicCourse)).thenReturn(0L); + + assertThatCode(() -> scrapService.createAndDeleteScrap(1L, requestDto(100L, false))) + .doesNotThrowAnyException(); + + verify(scrapRepository, never()).save(any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + RunnectUser dummyOwner = buildUser(2L); + PublicCourse publicCourse = buildPublicCourse(100L, dummyOwner); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> scrapService.createAndDeleteScrap(1L, requestDto(100L, true))) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개코스() { + RunnectUser user = buildUser(1L); + when(scrapRepository.findByUserIdAndPublicCourseId(1L, 100L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> scrapService.createAndDeleteScrap(1L, requestDto(100L, true))) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("getScrapCourseByUser") + class GetScrapCourseByUser { + + @Test + @DisplayName("스크랩한 코스 목록을 매핑해서 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, user); + Scrap scrap = buildScrap(50L, user, publicCourse, true); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(scrap))); + + GetScrapCourseResponseDto response = scrapService.getScrapCourseByUser(1L); + + assertThat(response.getUser().getUserId()).isEqualTo(1L); + assertThat(response.getScraps()).hasSize(1); + assertThat(response.getScraps().get(0).getPublicCourseId()).isEqualTo(100L); + assertThat(response.getScraps().get(0).getDeparture().getRegion()).isEqualTo("경기"); + } + + @Test + @DisplayName("스크랩한 코스가 없으면 빈 목록을 반환한다") + void 스크랩_없음() { + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetScrapCourseResponseDto response = scrapService.getScrapCourseByUser(1L); + + assertThat(response.getScraps()).isEmpty(); + } + + @Test + @DisplayName("레포지토리가 빈 Optional을 반환하면 NotFoundException") + void 조회_결과가_없으면_예외() { + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> scrapService.getScrapCourseByUser(1L)) + .isInstanceOf(NotFoundException.class); + } + } +} From 2c5ae19da94f94acb451bea7f734e11f3afb96e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 19:43:33 +0900 Subject: [PATCH 8/9] =?UTF-8?q?test:=20HealthService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createHealthData/getHealthData/getHealthSummary/deleteHealthData 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (22개). 버그는 발견되지 않음 — 소유권 검증(record.getRunnectUser().getId().equals(userId))을 일관되게 사용하고 있고, 동시성 경쟁으로 인한 유니크 제약 위반도 DataIntegrityViolationException을 잡아 409로 변환하는 등 이번에 테스트한 서비스 중 가장 방어적으로 잘 짜여있었음. --- .../health/service/HealthServiceTest.java | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 src/test/java/org/runnect/server/health/service/HealthServiceTest.java diff --git a/src/test/java/org/runnect/server/health/service/HealthServiceTest.java b/src/test/java/org/runnect/server/health/service/HealthServiceTest.java new file mode 100644 index 0000000..d241f9e --- /dev/null +++ b/src/test/java/org/runnect/server/health/service/HealthServiceTest.java @@ -0,0 +1,400 @@ +package org.runnect.server.health.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +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.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.health.dto.request.HealthDataRequestDto; +import org.runnect.server.health.dto.request.HeartRateSampleRequestDto; +import org.runnect.server.health.dto.response.CreateHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthDataResponseDto; +import org.runnect.server.health.dto.response.GetHealthSummaryResponseDto; +import org.runnect.server.health.entity.RecordHealthData; +import org.runnect.server.health.repository.RecordHealthDataRepository; +import org.runnect.server.record.entity.Record; +import org.runnect.server.record.repository.RecordRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class HealthServiceTest { + + @Mock + private RecordHealthDataRepository recordHealthDataRepository; + @Mock + private RecordRepository recordRepository; + + private HealthService healthService; + + @BeforeEach + void setUp() { + healthService = new HealthService(recordHealthDataRepository, recordRepository); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Record buildRecord(Long id, RunnectUser owner) { + Record record = Record.builder() + .runnectUser(owner) + .title("퇴근길 러닝") + .pace(java.sql.Time.valueOf("00:05:30")) + .time(java.sql.Time.valueOf("00:25:00")) + .build(); + ReflectionTestUtils.setField(record, "id", id); + return record; + } + + private HealthDataRequestDto healthDataRequestDto(Double avgHeartRate, Double calories, + List samples) { + return new HealthDataRequestDto(avgHeartRate, 180.0, 100.0, calories, 60, 120, 90, 30, 0, null, samples); + } + + @Nested + @DisplayName("createHealthData") + class CreateHealthData { + + @Test + @DisplayName("정상 요청이면 건강 데이터를 생성한다 (샘플 없음)") + void 정상_생성_샘플없음() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(false); + + CreateHealthDataResponseDto response = healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, 300.0, null)); + + assertThat(response.getHealthDataId()).isNull(); + verify(recordHealthDataRepository).save(any(RecordHealthData.class)); + } + + @Test + @DisplayName("심박수 샘플이 있으면 함께 저장된다") + void 정상_생성_샘플있음() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(false); + + List samples = Arrays.asList( + new HeartRateSampleRequestDto(140.0, 10, 2), + new HeartRateSampleRequestDto(150.0, 20, 3)); + + healthService.createHealthData(1L, 50L, healthDataRequestDto(150.0, 300.0, samples)); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(RecordHealthData.class); + verify(recordHealthDataRepository).save(captor.capture()); + assertThat(captor.getValue().getHeartRateSamples()).hasSize(2); + } + + @Test + @DisplayName("maxHeartRateConfig를 안 보내면 기본값(190.0)이 사용된다") + void 기본_최대심박수_사용() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(false); + + healthService.createHealthData(1L, 50L, healthDataRequestDto(150.0, 300.0, null)); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(RecordHealthData.class); + verify(recordHealthDataRepository).save(captor.capture()); + assertThat(captor.getValue().getMaxHeartRateConfig()).isEqualTo(190.0); + } + + @Test + @DisplayName("존재하지 않는 레코드면 NotFoundException") + void 존재하지_않는_레코드() { + when(recordRepository.findById(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, 300.0, null))) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 기록이 아니면 PermissionDeniedException") + void 소유자가_아님() { + RunnectUser owner = buildUser(1L); + Record record = buildRecord(50L, owner); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> healthService.createHealthData(999L, 50L, + healthDataRequestDto(150.0, 300.0, null))) + .isInstanceOf(PermissionDeniedException.class); + } + + @Test + @DisplayName("평균 심박수가 0 이하면 BadRequestException") + void 평균심박수_0이하() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(0.0, 300.0, null))) + .isInstanceOf(BadRequestException.class); + + verify(recordHealthDataRepository, never()).save(any()); + } + + @Test + @DisplayName("칼로리가 음수면 BadRequestException") + void 칼로리_음수() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, -1.0, null))) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("심박수 샘플이 5000건을 초과하면 BadRequestException") + void 심박수_샘플_초과() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + List tooMany = java.util.stream.Stream + .generate(() -> new HeartRateSampleRequestDto(140.0, 1, 2)) + .limit(5001) + .collect(java.util.stream.Collectors.toList()); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, 300.0, tooMany))) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("이미 건강 데이터가 존재하면 ConflictException") + void 이미_존재하는_건강데이터() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(true); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, 300.0, null))) + .isInstanceOf(ConflictException.class); + } + + @Test + @DisplayName("동시 요청으로 인한 유니크 제약 위반은 ConflictException으로 변환된다") + void 동시성_경쟁으로_인한_제약위반() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(false); + doThrow(new DataIntegrityViolationException("duplicate")) + .when(recordHealthDataRepository).save(any(RecordHealthData.class)); + + assertThatThrownBy(() -> healthService.createHealthData(1L, 50L, + healthDataRequestDto(150.0, 300.0, null))) + .isInstanceOf(ConflictException.class); + } + } + + @Nested + @DisplayName("getHealthData") + class GetHealthData { + + @Test + @DisplayName("건강 데이터가 있으면 상세 정보를 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + RecordHealthData healthData = RecordHealthData.builder() + .record(record) + .avgHeartRate(150.0) + .maxHeartRate(180.0) + .minHeartRate(100.0) + .calories(300.0) + .zone1Seconds(60) + .zone2Seconds(120) + .zone3Seconds(90) + .zone4Seconds(30) + .zone5Seconds(0) + .maxHeartRateConfig(190.0) + .build(); + ReflectionTestUtils.setField(healthData, "id", 200L); + + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.findByRecordIdWithSamples(50L)).thenReturn(Optional.of(healthData)); + + GetHealthDataResponseDto response = healthService.getHealthData(1L, 50L); + + assertThat(response.getHealthData().getId()).isEqualTo(200L); + assertThat(response.getHealthData().getAvgHeartRate()).isEqualTo(150.0); + assertThat(response.getHealthData().getHeartRateSamples()).isEmpty(); + } + + @Test + @DisplayName("건강 데이터가 없으면 404가 아니라 healthData가 null인 응답을 반환한다") + void 건강데이터_없음() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.findByRecordIdWithSamples(50L)).thenReturn(Optional.empty()); + + GetHealthDataResponseDto response = healthService.getHealthData(1L, 50L); + + assertThat(response.getHealthData()).isNull(); + } + + @Test + @DisplayName("존재하지 않는 레코드면 NotFoundException") + void 존재하지_않는_레코드() { + when(recordRepository.findById(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> healthService.getHealthData(1L, 50L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 기록이 아니면 PermissionDeniedException") + void 소유자가_아님() { + RunnectUser owner = buildUser(1L); + Record record = buildRecord(50L, owner); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> healthService.getHealthData(999L, 50L)) + .isInstanceOf(PermissionDeniedException.class); + } + } + + @Nested + @DisplayName("getHealthSummary") + class GetHealthSummary { + + @Test + @DisplayName("정상적인 기간이면 통계를 계산해서 반환한다") + void 정상_조회() { + Object[] row = new Object[]{5L, 3L, 145.5, 320.0, 960.0, 60, 120, 90, 30, 0}; + when(recordHealthDataRepository.getHealthSummary( + org.mockito.ArgumentMatchers.eq(1L), any(), any())) + .thenReturn(Collections.singletonList(row)); + + GetHealthSummaryResponseDto response = healthService.getHealthSummary(1L, "2026-01-01", "2026-01-31"); + + assertThat(response.getSummary().getTotalRecords()).isEqualTo(5L); + assertThat(response.getSummary().getRecordsWithHealth()).isEqualTo(3L); + assertThat(response.getSummary().getAvgHeartRate()).isEqualTo(145.5); + assertThat(response.getSummary().getZoneDistribution().getZone2Seconds()).isEqualTo(120); + } + + @Test + @DisplayName("집계 결과가 없으면 0/null로 채워진 기본값을 반환한다") + void 집계_결과_없음() { + when(recordHealthDataRepository.getHealthSummary( + org.mockito.ArgumentMatchers.eq(1L), any(), any())) + .thenReturn(Collections.emptyList()); + + GetHealthSummaryResponseDto response = healthService.getHealthSummary(1L, "2026-01-01", "2026-01-31"); + + assertThat(response.getSummary().getTotalRecords()).isEqualTo(0L); + assertThat(response.getSummary().getAvgHeartRate()).isNull(); + assertThat(response.getSummary().getZoneDistribution().getZone1Seconds()).isEqualTo(0); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 BadRequestException") + void 잘못된_날짜형식() { + assertThatThrownBy(() -> healthService.getHealthSummary(1L, "2026/01/01", "2026-01-31")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("종료일이 시작일보다 빠르면 BadRequestException") + void 종료일이_시작일보다_빠름() { + assertThatThrownBy(() -> healthService.getHealthSummary(1L, "2026-01-31", "2026-01-01")) + .isInstanceOf(BadRequestException.class); + } + } + + @Nested + @DisplayName("deleteHealthData") + class DeleteHealthData { + + @Test + @DisplayName("정상적으로 건강 데이터를 삭제한다") + void 정상_삭제() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(true); + + healthService.deleteHealthData(1L, 50L); + + verify(recordHealthDataRepository).deleteByRecordId(50L); + } + + @Test + @DisplayName("존재하지 않는 레코드면 NotFoundException") + void 존재하지_않는_레코드() { + when(recordRepository.findById(50L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> healthService.deleteHealthData(1L, 50L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 기록이 아니면 PermissionDeniedException") + void 소유자가_아님() { + RunnectUser owner = buildUser(1L); + Record record = buildRecord(50L, owner); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + + assertThatThrownBy(() -> healthService.deleteHealthData(999L, 50L)) + .isInstanceOf(PermissionDeniedException.class); + } + + @Test + @DisplayName("건강 데이터가 없으면 NotFoundException") + void 건강데이터_없음() { + RunnectUser user = buildUser(1L); + Record record = buildRecord(50L, user); + when(recordRepository.findById(50L)).thenReturn(Optional.of(record)); + when(recordHealthDataRepository.existsByRecordId(50L)).thenReturn(false); + + assertThatThrownBy(() -> healthService.deleteHealthData(1L, 50L)) + .isInstanceOf(NotFoundException.class); + + verify(recordHealthDataRepository, never()).deleteByRecordId(any()); + } + } +} From 8b8d5682416fb83d25a22450033b9ee57e056d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 20:02:18 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20MDC=20=EA=B8=B0=EB=8A=A5=EC=9D=B4=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9D=B4=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20UserIdResolverTest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20MDC=20=EA=B2=80=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MDC userId 로깅 기능(dev의 f1a7044, 2914ae5)은 이 브랜치가 아직 갖추지 못한 로깅 기반 인프라(MdcLoggingFilter, logback-spring.xml 등, dev 전용 모니터링 인프라 구축 작업에 딸려있음)에 의존하고 있어 이번엔 함께 가져오지 않았다. UserIdResolver 자체의 토큰 검증/파싱 로직은 동일하게 유효하므로 테스트는 유지하되, MDC 관련 assertion만 제거. --- .../common/resolver/userId/UserIdResolverTest.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java index acf2471..371c4a0 100644 --- a/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java +++ b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java @@ -7,7 +7,6 @@ import static org.mockito.Mockito.when; import javax.servlet.http.HttpServletRequest; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.runnect.server.common.constant.TokenStatus; @@ -16,7 +15,6 @@ import org.runnect.server.user.exception.authException.NullAccessTokenException; import org.runnect.server.user.exception.authException.TimeExpiredAccessTokenException; import org.runnect.server.user.exception.userException.NotFoundUserException; -import org.slf4j.MDC; import org.springframework.core.MethodParameter; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.NativeWebRequest; @@ -38,11 +36,6 @@ void setUp() { methodParameter = mock(MethodParameter.class); } - @AfterEach - void tearDown() { - MDC.clear(); - } - private NativeWebRequest webRequestWith(String accessToken, String refreshToken, String method, String uri) { HttpServletRequest servletRequest = mock(HttpServletRequest.class); when(servletRequest.getHeader("accessToken")).thenReturn(accessToken); @@ -72,13 +65,12 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, } @Test - void 방문자_모드_허용_URL이면_VISITOR_ID를_반환하고_MDC에_채운다() { + void 방문자_모드_허용_URL이면_VISITOR_ID를_반환한다() { NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123"); Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); assertThat(result).isEqualTo(VISITOR_ID); - assertThat(MDC.get("userId")).isEqualTo(String.valueOf(VISITOR_ID)); } @Test @@ -100,7 +92,7 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, } @Test - void 유효한_토큰이면_userId를_반환하고_MDC에_채운다() { + void 유효한_토큰이면_userId를_반환한다() { when(jwtService.verifyToken("valid")).thenReturn(TokenStatus.TOKEN_VALID); when(jwtService.getJwtContents("valid")).thenReturn("42"); NativeWebRequest webRequest = webRequestWith("valid", "refresh", "GET", "/api/user"); @@ -108,7 +100,6 @@ private NativeWebRequest webRequestWith(String accessToken, String refreshToken, Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); assertThat(result).isEqualTo(42L); - assertThat(MDC.get("userId")).isEqualTo("42"); } @Test