From d80d6604fb81092784165a3b34bfde9296483b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 19:26:37 +0900 Subject: [PATCH] =?UTF-8?q?test:=20PublicCourseService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?+=20=EB=B2=84=EA=B7=B8=204=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPublicCourseTotalPageCount/getMarathonPublicCourse/searchPublicCourse/ recommendPublicCourse/getPublicCourseByUser/getPublicCourseDetail/ createPublicCourse/deletePublicCourses/updatePublicCourse 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값 검증 (38개). 테스트 작성 중 발견해서 함께 수정한 버그 4건: 1. PublicCourse에 equals/hashCode 부재 — RunnectUser와 동일한 참조비교 문제. scrap 목록과 publicCourse 목록을 서로 다른 쿼리로 가져와 비교하는 곳이 5곳(getMarathonPublicCourse, searchPublicCourse, recommendPublicCourse, getPublicCourseByUser, getPublicCourseDetail)이라 isScrap이 잘못 표시될 수 있었음. id 기준 equals/hashCode 추가로 일괄 해결. 2. getPublicCourseDetail: 삭제된 코스 체크 조건이 반대(`== null`)였고, 심지어 예외를 생성만 하고 throw를 안 해서 완전히 죽은 코드였음. 조건 반전 + throw 추가. 3. updatePublicCourse: userId를 받으면서 소유권 검증을 안 해 다른 사람의 공개 코스 제목/설명도 수정 가능했음 (IDOR). deletePublicCourses와 동일한 관리자 예외 패턴으로 소유권 검증 추가. ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION 추가. 4. recommendPublicCourse: sort 파라미터가 "scrap"/"date" 둘 다 아니면 Page 변수가 null로 남아 NPE. 이미 정의돼 있던 INVALID_SORT_PARAMETER_EXCEPTION을 실제로 사용하도록 수정. --- .../server/common/constant/ErrorStatus.java | 1 + .../publicCourse/entity/PublicCourse.java | 19 + .../service/PublicCourseService.java | 14 +- .../service/PublicCourseServiceTest.java | 717 ++++++++++++++++++ 4 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index bf314e5..42398bf 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -54,6 +54,7 @@ public enum ErrorStatus { * 403 FORBIDDEN */ PERMISSION_DENIED_PUBLIC_COURSE_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 삭제할 권한이 존재하지 않습니다."), + PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "퍼블릭 코스를 수정할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_DELETE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 삭제할 권한이 존재하지 않습니다."), PERMISSION_DENIED_RECORD_UPDATE_EXCEPTION(HttpStatus.FORBIDDEN, "기록을 수정할 권한이 존재하지 않습니다."), PERMISSION_DENIED_HEALTH_DATA_EXCEPTION(HttpStatus.FORBIDDEN, "건강 데이터에 대한 접근 권한이 없습니다"), diff --git a/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java b/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java index 6c69625..21b855a 100644 --- a/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java +++ b/src/main/java/org/runnect/server/publicCourse/entity/PublicCourse.java @@ -63,4 +63,23 @@ public void updatePublicCourse(String title, String description) { public void updateDeletedAt() { throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); } + + // RunnectUser와 동일한 이유(참조 동일성 의존 방지)로 id 기준 equals/hashCode를 둔다. + // scrap 목록과 publicCourse 목록을 서로 다른 쿼리로 가져와 비교하는 곳(isScrap 매칭)이 많아서 영향이 크다. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof PublicCourse)) { + return false; + } + PublicCourse that = (PublicCourse) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java b/src/main/java/org/runnect/server/publicCourse/service/PublicCourseService.java index 9d6956a..8b500cb 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; @@ -181,6 +182,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 -> { @@ -263,8 +267,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()); } @@ -394,6 +398,12 @@ public UpdatePublicCourseResponseDto updatePublicCourse(Long userId, Long public PublicCourse publicCourse = publicCourseRepository.findById(publicCourseId) .orElseThrow(() -> new NotFoundException(ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION, ErrorStatus.NOT_FOUND_PUBLIC_COURSE_EXCEPTION.getMessage())); + boolean isAdmin = userId.equals(ADMIN_USER_ID); + if (!isAdmin && !publicCourse.getCourse().getRunnectUser().getId().equals(userId)) { + throw new PermissionDeniedException(ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION, + ErrorStatus.PERMISSION_DENIED_PUBLIC_COURSE_UPDATE_EXCEPTION.getMessage()); + } + publicCourse.updatePublicCourse(title, description); return UpdatePublicCourseResponseDto.of(publicCourse); diff --git a/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java b/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java new file mode 100644 index 0000000..50591d2 --- /dev/null +++ b/src/test/java/org/runnect/server/publicCourse/service/PublicCourseServiceTest.java @@ -0,0 +1,717 @@ +package org.runnect.server.publicCourse.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.LineString; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.exception.PermissionDeniedException; +import org.runnect.server.common.module.convert.CoordinateDto; +import org.runnect.server.common.module.convert.CoordinatePathConverter; +import org.runnect.server.course.entity.Course; +import org.runnect.server.course.repository.CourseRepository; +import org.runnect.server.publicCourse.dto.request.CreatePublicCourseRequestDto; +import org.runnect.server.publicCourse.dto.request.DeletePublicCoursesRequestDto; +import org.runnect.server.publicCourse.dto.response.CreatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.DeletePublicCoursesResponseDto; +import org.runnect.server.publicCourse.dto.response.GetPublicCourseDetailResponseDto; +import org.runnect.server.publicCourse.dto.response.GetPublicCourseTotalPageCountResponseDto; +import org.runnect.server.publicCourse.dto.response.UpdatePublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getMarathonPublicCourse.GetMarathonPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.getPublicCourseByUser.GetPublicCourseByUserResponseDto; +import org.runnect.server.publicCourse.dto.response.recommendPublicCourse.RecommendPublicCourseResponseDto; +import org.runnect.server.publicCourse.dto.response.searchPublicCourse.SearchPublicCourseResponseDto; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.scrap.entity.Scrap; +import org.runnect.server.scrap.repository.ScrapRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PublicCourseServiceTest { + + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserRepository userRepository; + @Mock + private ScrapRepository scrapRepository; + @Mock + private CourseRepository courseRepository; + + private PublicCourseService publicCourseService; + + @BeforeEach + void setUp() { + publicCourseService = new PublicCourseService(publicCourseRepository, userRepository, scrapRepository, + courseRepository); + ReflectionTestUtils.invokeMethod(publicCourseService, "setMARATHON_PUBLIC_COURSE_IDS", "100,200"); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + )); + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate, String departureName) { + Course course = Course.builder() + .runnectUser(owner) + .title("코스 제목") + .departureRegion("경기") + .departureCity("시흥시") + .departureTown("정왕동") + .departureDetail("정왕본동") + .departureName(departureName) + .distance(5.2f) + .image("https://image.example/course.png") + .path(validLineString()) + .build(); + ReflectionTestUtils.setField(course, "id", id); + if (!isPrivate) { + ReflectionTestUtils.setField(course, "isPrivate", false); + } + return course; + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate) { + return buildCourse(id, owner, isPrivate, "정왕역"); + } + + private PublicCourse buildPublicCourse(Long id, Course course) { + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스 제목") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", id); + return publicCourse; + } + + private Scrap buildScrap(RunnectUser user, PublicCourse publicCourse) { + return Scrap.builder().runnectUser(user).publicCourse(publicCourse).scrapTF(true).build(); + } + + @Nested + @DisplayName("getPublicCourseTotalPageCount") + class GetPublicCourseTotalPageCount { + + @Test + @DisplayName("정확히 나누어 떨어지면 그대로 페이지 수가 된다") + void 나누어_떨어짐() { + when(publicCourseRepository.countBy()).thenReturn(20L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(2L); + } + + @Test + @DisplayName("나누어 떨어지지 않으면 올림해서 한 페이지를 더한다") + void 나누어_안_떨어짐() { + when(publicCourseRepository.countBy()).thenReturn(21L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(3L); + } + + @Test + @DisplayName("코스가 없으면 0페이지다") + void 코스가_없음() { + when(publicCourseRepository.countBy()).thenReturn(0L); + + GetPublicCourseTotalPageCountResponseDto response = publicCourseService.getPublicCourseTotalPageCount(); + + assertThat(response.getTotalPageCount()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("getMarathonPublicCourse") + class GetMarathonPublicCourse { + + @Test + @DisplayName("마라톤 코스 목록을 스크랩 여부와 함께 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, false); + Course course2 = buildCourse(11L, user, false); + PublicCourse pc1 = buildPublicCourse(100L, course1); + PublicCourse pc2 = buildPublicCourse(200L, course2); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course1))))); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 200L))).thenReturn(Arrays.asList(pc1, pc2)); + + GetMarathonPublicCourseResponseDto response = publicCourseService.getMarathonPublicCourse(1L); + + assertThat(response.getMarathonPublicCourses()).hasSize(2); + assertThat(response.getMarathonPublicCourses().get(0).getScrap()).isTrue(); + assertThat(response.getMarathonPublicCourses().get(1).getScrap()).isFalse(); + } + + @Test + @DisplayName("설정된 마라톤 코스 중 일부가 존재하지 않으면 NotFoundException") + void 마라톤_코스_일부_없음() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, false); + PublicCourse pc1 = buildPublicCourse(100L, course1); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 200L))).thenReturn( + Collections.singletonList(pc1)); + + assertThatThrownBy(() -> publicCourseService.getMarathonPublicCourse(1L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getMarathonPublicCourse(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("searchPublicCourse") + class SearchPublicCourse { + + @Test + @DisplayName("키워드로 검색된 코스를 스크랩 여부와 함께 반환한다") + void 정상_검색() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course))))); + when(publicCourseRepository.searchPublicCourseByKeyword("정왕")).thenReturn( + Collections.singletonList(publicCourse)); + + SearchPublicCourseResponseDto response = publicCourseService.searchPublicCourse(1L, "정왕"); + + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getPublicCourses().get(0).getScrap()).isTrue(); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 목록을 반환한다") + void 검색_결과_없음() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.searchPublicCourseByKeyword("없는키워드")).thenReturn(Collections.emptyList()); + + SearchPublicCourseResponseDto response = publicCourseService.searchPublicCourse(1L, "없는키워드"); + + assertThat(response.getPublicCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.searchPublicCourse(1L, "정왕")) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("recommendPublicCourse") + class RecommendPublicCourse { + + @Test + @DisplayName("scrap 정렬로 조회한다") + void 스크랩순_정렬() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + Page page = new PageImpl<>(Collections.singletonList(publicCourse), + PageRequest.of(0, 10), 1); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findAll(any(Pageable.class))).thenReturn(page); + + RecommendPublicCourseResponseDto response = publicCourseService.recommendPublicCourse(1L, 1, "scrap"); + + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getOrdering()).isEqualTo("scrap"); + assertThat(response.getIsEnd()).isTrue(); + } + + @Test + @DisplayName("date 정렬로 조회한다") + void 최신순_정렬() { + RunnectUser user = buildUser(1L); + Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + when(publicCourseRepository.findAll(any(Pageable.class))).thenReturn(page); + + RecommendPublicCourseResponseDto response = publicCourseService.recommendPublicCourse(1L, 1, "date"); + + assertThat(response.getOrdering()).isEqualTo("date"); + } + + @Test + @DisplayName("정렬 값이 scrap/date 둘 다 아니면 BadRequestException") + void 잘못된_정렬값() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + assertThatThrownBy(() -> publicCourseService.recommendPublicCourse(1L, 1, "인기순")) + .isInstanceOf(BadRequestException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.recommendPublicCourse(1L, 1, "scrap")) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPublicCourseByUser") + class GetPublicCourseByUser { + + @Test + @DisplayName("유저가 공개한 코스 목록을 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + ReflectionTestUtils.setField(course, "publicCourse", publicCourse); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCoursesByRunnectUserAndIsPrivateIsFalseAndDeletedAtIsNull(user)) + .thenReturn(Collections.singletonList(course)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseByUserResponseDto response = publicCourseService.getPublicCourseByUser(1L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getPublicCourses()).hasSize(1); + assertThat(response.getPublicCourses().get(0).getId()).isEqualTo(100L); + } + + @Test + @DisplayName("공개한 코스가 없으면 빈 목록을 반환한다") + void 공개한_코스_없음() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCoursesByRunnectUserAndIsPrivateIsFalseAndDeletedAtIsNull(user)) + .thenReturn(Collections.emptyList()); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseByUserResponseDto response = publicCourseService.getPublicCourseByUser(1L); + + assertThat(response.getPublicCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPublicCourseDetail") + class GetPublicCourseDetail { + + @Test + @DisplayName("정상 조회 시 출발지 건물명이 있으면 포함해서 반환한다") + void 정상_조회_건물명_있음() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false, "정왕역"); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getUser().getIsNowUser()).isTrue(); + assertThat(response.getPublicCourse().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("출발지 건물명이 없어도 정상 조회된다") + void 정상_조회_건물명_없음() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false, null); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getPublicCourse().getId()).isEqualTo(100L); + } + + @Test + @DisplayName("삭제된 코스면 NotFoundException") + void 삭제된_코스() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + ReflectionTestUtils.setField(course, "deletedAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("다른 사람이 올린 코스면 isNowUser가 false다") + void 타인_코스() { + RunnectUser uploader = buildUser(1L); + RunnectUser requester = buildUser(2L); + Course course = buildCourse(10L, uploader, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(2L)).thenReturn(Optional.of(requester)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(2L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(2L, 100L); + + assertThat(response.getUser().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("업로더가 탈퇴한 코스면 '알 수 없음' 유저로 대체된다") + void 업로더가_없는_코스() { + RunnectUser requester = buildUser(1L); + Course course = buildCourse(10L, null, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn(Optional.of(Collections.emptyList())); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getUser().getNickname()).isEqualTo("알 수 없음"); + assertThat(response.getUser().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("본인이 스크랩한 코스면 scrap이 true다") + void 스크랩_매칭() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + when(scrapRepository.findAllByUserIdAndScrapTF(1L)).thenReturn( + Optional.of(Collections.singletonList(buildScrap(user, buildPublicCourse(100L, course))))); + + GetPublicCourseDetailResponseDto response = publicCourseService.getPublicCourseDetail(1L, 100L); + + assertThat(response.getPublicCourse().getScrap()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.getPublicCourseDetail(1L, 100L)) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("createPublicCourse") + class CreatePublicCourse { + + @Test + @DisplayName("본인 소유의 비공개 코스를 정상적으로 공개한다") + void 정상_생성() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(publicCourseRepository.save(any(PublicCourse.class))).thenAnswer(invocation -> { + PublicCourse saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 100L); + ReflectionTestUtils.setField(saved, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + return saved; + }); + + CreatePublicCourseRequestDto request = new CreatePublicCourseRequestDto(10L, "제목", "설명"); + + CreatePublicCourseResponseDto response = publicCourseService.createPublicCourse(1L, request); + + assertThat(response.getPublicCourse()).isNotNull(); + assertThat(course.getIsPrivate()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인이 그린 코스가 아니면 PermissionDeniedException") + void 소유자가_아님() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, buildUser(2L), true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(PermissionDeniedException.class); + } + + @Test + @DisplayName("이미 공개된 코스면 ConflictException") + void 이미_공개된_코스() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> publicCourseService.createPublicCourse(1L, + new CreatePublicCourseRequestDto(10L, "제목", "설명"))) + .isInstanceOf(ConflictException.class); + } + } + + @Nested + @DisplayName("deletePublicCourses") + class DeletePublicCourses { + + @Test + @DisplayName("본인 소유 공개 코스를 정상 삭제한다") + void 정상_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = buildPublicCourse(100L, course); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Collections.singletonList(100L))).thenReturn( + Collections.singletonList(publicCourse)); + + DeletePublicCoursesResponseDto response = publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Collections.singletonList(100L))); + + assertThat(response.getDeletedPublicCourseCount()).isEqualTo(1); + assertThat(course.getIsPrivate()).isTrue(); + verify(scrapRepository).deleteByPublicCourseIn(Collections.singletonList(publicCourse)); + verify(publicCourseRepository).deleteAll(Collections.singletonList(publicCourse)); + } + + @Test + @DisplayName("존재하지 않는 id가 포함되면 NotFoundException") + void 존재하지_않는_공개코스_포함() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 999L))).thenReturn(Collections.emptyList()); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Arrays.asList(100L, 999L)))) + .isInstanceOf(NotFoundException.class); + + verify(publicCourseRepository, never()).deleteAll(any()); + } + + @Test + @DisplayName("본인 소유가 아닌 코스가 섞여 있으면 PermissionDeniedException") + void 소유자가_아닌_코스_포함() { + RunnectUser user = buildUser(1L); + RunnectUser otherUser = buildUser(2L); + PublicCourse ownPublicCourse = buildPublicCourse(100L, buildCourse(10L, user, false)); + PublicCourse othersPublicCourse = buildPublicCourse(101L, buildCourse(11L, otherUser, false)); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findByIdIn(Arrays.asList(100L, 101L))).thenReturn( + Arrays.asList(ownPublicCourse, othersPublicCourse)); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Arrays.asList(100L, 101L)))) + .isInstanceOf(PermissionDeniedException.class); + + verify(publicCourseRepository, never()).deleteAll(any()); + } + + @Test + @DisplayName("관리자는 본인 소유가 아니어도 삭제할 수 있다") + void 관리자는_소유자가_아니어도_삭제_가능() { + Long adminId = 280L; + RunnectUser admin = buildUser(adminId); + RunnectUser otherUser = buildUser(2L); + PublicCourse othersPublicCourse = buildPublicCourse(101L, buildCourse(11L, otherUser, false)); + + when(userRepository.findById(adminId)).thenReturn(Optional.of(admin)); + when(publicCourseRepository.findByIdIn(Collections.singletonList(101L))).thenReturn( + Collections.singletonList(othersPublicCourse)); + + DeletePublicCoursesResponseDto response = publicCourseService.deletePublicCourses(adminId, + new DeletePublicCoursesRequestDto(Collections.singletonList(101L))); + + assertThat(response.getDeletedPublicCourseCount()).isEqualTo(1); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.deletePublicCourses(1L, + new DeletePublicCoursesRequestDto(Collections.singletonList(100L)))) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("updatePublicCourse") + class UpdatePublicCourse { + + @Test + @DisplayName("본인 소유 공개 코스면 제목/설명을 수정한다") + void 정상_수정() { + RunnectUser user = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, user, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + UpdatePublicCourseResponseDto response = publicCourseService.updatePublicCourse(1L, 100L, "새 제목", + "새 설명"); + + assertThat(publicCourse.getTitle()).isEqualTo("새 제목"); + assertThat(publicCourse.getDescription()).isEqualTo("새 설명"); + assertThat(response.getPublicCourse().getTitle()).isEqualTo("새 제목"); + } + + @Test + @DisplayName("존재하지 않는 공개 코스면 NotFoundException") + void 존재하지_않는_공개코스() { + when(publicCourseRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> publicCourseService.updatePublicCourse(1L, 100L, "새 제목", "새 설명")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("본인 소유가 아니면 PermissionDeniedException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { + RunnectUser owner = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, owner, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + Long 다른유저Id = 999L; + + assertThatThrownBy(() -> publicCourseService.updatePublicCourse(다른유저Id, 100L, "남의 코스", "수정 시도")) + .isInstanceOf(PermissionDeniedException.class); + + assertThat(publicCourse.getTitle()).isEqualTo("공개 코스 제목"); + } + + @Test + @DisplayName("관리자는 본인 소유가 아니어도 수정할 수 있다") + void 관리자는_소유자가_아니어도_수정_가능() { + Long adminId = 280L; + RunnectUser owner = buildUser(1L); + PublicCourse publicCourse = buildPublicCourse(100L, buildCourse(10L, owner, false)); + when(publicCourseRepository.findById(100L)).thenReturn(Optional.of(publicCourse)); + + UpdatePublicCourseResponseDto response = publicCourseService.updatePublicCourse(adminId, 100L, + "관리자 수정", "관리자 설명"); + + assertThat(response.getPublicCourse().getTitle()).isEqualTo("관리자 수정"); + } + } +}