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/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 0903bd3..42398bf 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 @@ -53,7 +54,9 @@ 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/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/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/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/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/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/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/common/resolver/userId/UserIdResolverTest.java b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java new file mode 100644 index 0000000..371c4a0 --- /dev/null +++ b/src/test/java/org/runnect/server/common/resolver/userId/UserIdResolverTest.java @@ -0,0 +1,122 @@ +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.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.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); + } + + 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를_반환한다() { + NativeWebRequest webRequest = webRequestWith("visitor", "visitor", "GET", "/api/public-course/123"); + + Object result = userIdResolver.resolveArgument(methodParameter, null, webRequest, null); + + assertThat(result).isEqualTo(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를_반환한다() { + 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); + } + + @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); + } +} 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..75c9059 --- /dev/null +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -0,0 +1,465 @@ +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토큰 미만이면 BadRequestException") + void 출발지_주소가_불완전하면_BadRequestException() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(validPath(), "경기 시흥시"), "img")) + .isInstanceOf(BadRequestException.class); + + verify(courseRepository, never()).save(any()); + } + } + + @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가 같으면 객체 인스턴스가 달라도 같은 사람으로 판정된다") + 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()).isTrue(); + } + + @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.findByCourseIdAndUserId(10L, 1L)).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.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.updateCourse(1L, 10L, "새 제목")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("코스 소유자가 아니면 NotFoundException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { + Long 다른유저Id = 999L; + when(courseRepository.findByCourseIdAndUserId(10L, 다른유저Id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경")) + .isInstanceOf(NotFoundException.class); + } + } + + @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); + } + } +} 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()); + } + } +} 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("관리자 수정"); + } + } +} 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); + } + } +} 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); + } + } +} 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()); + } +} 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); + } + } +}