From 750d19163dd56ca9930d76d3043d222a3219f871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:29:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?test:=20CourseService=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCourse/getCourseByUser/getPrivateCourseByUser/getCourseDetail/updateCourse/ deleteCourses 전체 메서드에 대해 정상 케이스 + 예외 케이스 + 경계값을 검증. 테스트 작성 중 실제 프로덕션 코드에서 3가지 의심되는 부분을 발견해 별도로 표시해둠: - createCourse: 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환하고 이후 NPE로 이어짐 (요청값 검증 부재) - updateCourse: courseId로만 조회하고 userId로 소유자 검증을 하지 않아, 다른 사람의 코스 제목도 수정 가능 (IDOR 의심) - getCourseDetail: RunnectUser가 equals/hashCode를 오버라이드하지 않아 isNowUser 판정이 참조 동일성에 의존함 (같은 id라도 인스턴스가 다르면 다른 사람으로 판정될 수 있음) --- .../course/service/CourseServiceTest.java | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 src/test/java/org/runnect/server/course/service/CourseServiceTest.java diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java new file mode 100644 index 0000000..3f2a5b2 --- /dev/null +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -0,0 +1,468 @@ +package org.runnect.server.course.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.locationtech.jts.geom.LineString; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.common.exception.BadRequestException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.common.module.convert.CoordinateDto; +import org.runnect.server.common.module.convert.CoordinatePathConverter; +import org.runnect.server.course.dto.request.CourseCreateRequestDto; +import org.runnect.server.course.dto.response.CourseCreateResponseDto; +import org.runnect.server.course.dto.response.CourseGetByUserResponseDto; +import org.runnect.server.course.dto.response.DeleteCoursesResponseDto; +import org.runnect.server.course.dto.response.GetCourseDetailResponseDto; +import org.runnect.server.course.dto.response.UpdateCourseResponseDto; +import org.runnect.server.course.entity.Course; +import org.runnect.server.course.repository.CourseRepository; +import org.runnect.server.publicCourse.entity.PublicCourse; +import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.entity.StampType; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.runnect.server.user.service.UserStampService; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CourseServiceTest { + + @Mock + private CourseRepository courseRepository; + @Mock + private PublicCourseRepository publicCourseRepository; + @Mock + private UserRepository userRepository; + @Mock + private UserStampService userStampService; + + private CourseService courseService; + + @BeforeEach + void setUp() { + courseService = new CourseService(courseRepository, publicCourseRepository, userRepository, + userStampService); + } + + private RunnectUser buildUser(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email("user" + id + "@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private List validPath() { + return Arrays.asList( + new CoordinateDto(37.5665, 126.9780), + new CoordinateDto(37.5651, 126.9895) + ); + } + + private LineString validLineString() { + return CoordinatePathConverter.coorConvertPath(validPath()); + } + + private CourseCreateRequestDto createRequestDto(List path, String departureAddress) { + return new CourseCreateRequestDto(path, "정왕역 코스", 5.2f, "정왕역", departureAddress); + } + + private Course buildCourse(Long id, RunnectUser owner, boolean isPrivate) { + Course course = Course.builder() + .runnectUser(owner) + .title("코스 제목") + .departureRegion("경기") + .departureCity("시흥시") + .departureTown("정왕동") + .departureDetail("정왕본동") + .departureName("정왕역") + .distance(5.2f) + .image("https://image.example/course.png") + .path(validLineString()) + .build(); + ReflectionTestUtils.setField(course, "id", id); + if (!isPrivate) { + ReflectionTestUtils.setField(course, "isPrivate", false); + } + return course; + } + + @Nested + @DisplayName("createCourse") + class CreateCourse { + + @Test + @DisplayName("정상 요청이면 코스를 저장하고 유저 코스 카운트/스탬프를 갱신한다") + void 정상_생성() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Course savedCourse = buildCourse(100L, user, true); + ReflectionTestUtils.setField(savedCourse, "createdAt", LocalDateTime.of(2026, 1, 1, 0, 0)); + when(courseRepository.save(any(Course.class))).thenReturn(savedCourse); + + CourseCreateRequestDto requestDto = createRequestDto(validPath(), "경기 시흥시 정왕동"); + + CourseCreateResponseDto response = courseService.createCourse(1L, requestDto, "image-key.png"); + + assertThat(response.getId()).isEqualTo(100L); + assertThat(response.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0)); + assertThat(user.getCreatedCourse()).isEqualTo(1L); + verify(userStampService).createStampByUser(user, StampType.c); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Course.class); + verify(courseRepository).save(captor.capture()); + Course passed = captor.getValue(); + assertThat(passed.getRunnectUser()).isEqualTo(user); + assertThat(passed.getTitle()).isEqualTo("정왕역 코스"); + assertThat(passed.getDepartureRegion()).isEqualTo("경기"); + assertThat(passed.getDepartureCity()).isEqualTo("시흥시"); + assertThat(passed.getDepartureTown()).isEqualTo("정왕동"); + assertThat(passed.getImage()).isEqualTo("image-key.png"); + assertThat(passed.getPath()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(validPath(), "경기 시흥시 정왕동"), "img")) + .isInstanceOf(NotFoundUserException.class); + + verify(courseRepository, never()).save(any()); + } + + @Test + @DisplayName("좌표가 1개 뿐이면 LineString을 만들 수 없어 BadRequestException") + void 좌표가_부족하면_경로_생성_실패() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + List onePoint = Collections.singletonList(new CoordinateDto(37.5665, 126.9780)); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(onePoint, "경기 시흥시 정왕동"), "img")) + .isInstanceOf(BadRequestException.class); + + verify(courseRepository, never()).save(any()); + } + + @Test + @DisplayName("[버그 의심] 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환해 NPE가 난다") + void 출발지_주소가_불완전하면_NPE() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> courseService.createCourse(1L, + createRequestDto(validPath(), "경기 시흥시"), "img")) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("getCourseByUser") + class GetCourseByUser { + + @Test + @DisplayName("유저의 코스 목록을 CourseResponse로 매핑해 반환한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course1 = buildCourse(10L, user, true); + Course course2 = buildCourse(11L, user, true); + when(courseRepository.findCourseByUserId(1L)).thenReturn(Arrays.asList(course1, course2)); + + CourseGetByUserResponseDto response = courseService.getCourseByUser(1L); + + assertThat(response.getUser().getId()).isEqualTo(1L); + assertThat(response.getCourses()).hasSize(2) + .extracting("id") + .containsExactly(10L, 11L); + assertThat(response.getCourses().get(0).getDeparture().getRegion()).isEqualTo("경기"); + assertThat(response.getCourses().get(0).getDeparture().getCity()).isEqualTo("시흥시"); + } + + @Test + @DisplayName("코스가 없으면 빈 목록을 반환한다") + void 코스가_없으면_빈_목록() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findCourseByUserId(1L)).thenReturn(Collections.emptyList()); + + CourseGetByUserResponseDto response = courseService.getCourseByUser(1L); + + assertThat(response.getCourses()).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getPrivateCourseByUser") + class GetPrivateCourseByUser { + + @Test + @DisplayName("비공개 코스 전용 조회 메서드를 호출한다") + void 정상_조회() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course = buildCourse(10L, user, true); + when(courseRepository.findCourseByUserIdOnlyPrivate(1L)).thenReturn( + Collections.singletonList(course)); + + CourseGetByUserResponseDto response = courseService.getPrivateCourseByUser(1L); + + assertThat(response.getCourses()).hasSize(1); + verify(courseRepository).findCourseByUserIdOnlyPrivate(1L); + verify(courseRepository, never()).findCourseByUserId(any()); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getPrivateCourseByUser(1L)) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("getCourseDetail") + class GetCourseDetail { + + @Test + @DisplayName("본인이 업로드한 코스면 isNowUser가 true다") + void 본인_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + Course course = buildCourse(10L, user, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isTrue(); + assertThat(response.getUser().getUserId()).isEqualTo(1L); + } + + @Test + @DisplayName("다른 사람이 업로드한 코스면 isNowUser가 false다") + void 타인_코스() { + RunnectUser requester = buildUser(1L); + RunnectUser uploader = buildUser(2L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, uploader, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("[주의] 업로더와 요청자의 id가 같아도 객체 인스턴스가 다르면 isNowUser가 false로 나온다" + + " (RunnectUser에 equals/hashCode 미구현, 참조 동일성으로 비교됨)") + void 같은_id여도_인스턴스가_다르면_다른_사람으로_판정된다() { + RunnectUser requester = buildUser(1L); + RunnectUser uploaderWithSameIdButDifferentInstance = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, uploaderWithSameIdButDifferentInstance, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + } + + @Test + @DisplayName("업로더 정보가 없는 코스는 isNowUser가 false이고 userId는 -1이다") + void 업로더가_없는_코스() { + RunnectUser requester = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); + Course course = buildCourse(10L, null, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); + + assertThat(response.getCourse().getIsNowUser()).isFalse(); + assertThat(response.getUser().getUserId()).isEqualTo(-1L); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseDetail(10L, 1L)) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + RunnectUser user = buildUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseDetail(10L, 1L)) + .isInstanceOf(NotFoundException.class); + } + } + + @Nested + @DisplayName("updateCourse") + class UpdateCourse { + + @Test + @DisplayName("코스 제목을 수정한다") + void 정상_수정() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + UpdateCourseResponseDto response = courseService.updateCourse(1L, 10L, "새 제목"); + + assertThat(course.getTitle()).isEqualTo("새 제목"); + assertThat(response.getCourse().getTitle()).isEqualTo("새 제목"); + assertThat(response.getCourse().getId()).isEqualTo(10L); + } + + @Test + @DisplayName("존재하지 않는 코스면 NotFoundException") + void 존재하지_않는_코스() { + when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.updateCourse(1L, 10L, "새 제목")) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("[버그 의심] userId로 소유자 검증을 하지 않아, 코스 소유자가 아니어도 제목을 수정할 수 있다") + void 소유자가_아니어도_수정_가능() { + RunnectUser owner = buildUser(1L); + Course course = buildCourse(10L, owner, true); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + Long 다른유저Id = 999L; + UpdateCourseResponseDto response = courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경"); + + assertThat(course.getTitle()).isEqualTo("남의 코스 제목 변경"); + assertThat(response.getCourse().getTitle()).isEqualTo("남의 코스 제목 변경"); + } + } + + @Nested + @DisplayName("deleteCourses") + class DeleteCourses { + + @Test + @DisplayName("비공개 코스는 soft delete만 수행한다") + void 비공개_코스_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); + + DeleteCoursesResponseDto response = courseService.deleteCourses( + Collections.singletonList(10L), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(1); + assertThat(course.getDeletedAt()).isNotNull(); + verify(publicCourseRepository, never()).delete(any()); + } + + @Test + @DisplayName("공개된 코스는 공개 코스를 함께 삭제하고 비공개로 되돌린 뒤 soft delete한다") + void 공개_코스_삭제() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user, false); + PublicCourse publicCourse = mock(PublicCourse.class); + ReflectionTestUtils.setField(course, "publicCourse", publicCourse); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); + + courseService.deleteCourses(Collections.singletonList(10L), 1L); + + verify(publicCourseRepository).delete(publicCourse); + assertThat(course.getIsPrivate()).isTrue(); + assertThat(course.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("여러 개를 한 번에 삭제하면 개수가 그대로 반환된다") + void 여러개_삭제() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, true); + Course course2 = buildCourse(11L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course1)); + when(courseRepository.findByCourseIdAndUserId(11L, 1L)).thenReturn(Optional.of(course2)); + + DeleteCoursesResponseDto response = courseService.deleteCourses(Arrays.asList(10L, 11L), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(2); + } + + @Test + @DisplayName("본인 소유가 아니거나 존재하지 않는 코스면 NotFoundException") + void 존재하지_않거나_소유자가_아닌_코스() { + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.deleteCourses(Collections.singletonList(10L), 1L)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("빈 목록을 요청하면 아무 것도 삭제하지 않고 0을 반환한다") + void 빈_목록() { + DeleteCoursesResponseDto response = courseService.deleteCourses(Collections.emptyList(), 1L); + + assertThat(response.getDeletedCourseCount()).isEqualTo(0); + verify(courseRepository, never()).findByCourseIdAndUserId(any(), any()); + } + + @Test + @DisplayName("목록 중간에 실패하면 그 이후 항목은 조회조차 되지 않고 예외가 전파된다") + void 중간에_실패하면_이후_항목은_처리되지_않는다() { + RunnectUser user = buildUser(1L); + Course course1 = buildCourse(10L, user, true); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course1)); + when(courseRepository.findByCourseIdAndUserId(11L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.deleteCourses(Arrays.asList(10L, 11L, 12L), 1L)) + .isInstanceOf(NotFoundException.class); + + assertThat(course1.getDeletedAt()).isNotNull(); + verify(courseRepository, never()).findByCourseIdAndUserId(12L, 1L); + } + } +} From e917fcd3054b9575b2c24968481247f0fe02afdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 17:51:06 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=A4=91=20=EB=B0=9C=EA=B2=AC=ED=95=9C=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=20=EA=B4=80=EB=A0=A8=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?3=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DepartureConverter: 출발지 주소가 3토큰 미만이면 null 대신 BadRequestException(VALIDATION_DEPARTURE_ADDRESS_EXCEPTION)을 던지도록 변경 (기존엔 CourseService에서 바로 NPE로 이어짐) 2. CourseService.updateCourse: findById → findByCourseIdAndUserId로 변경해 본인 소유 코스만 수정 가능하도록 수정 (IDOR 방지, deleteCourses와 동일 패턴) 3. RunnectUser: equals/hashCode를 id 기준으로 구현. 기존엔 참조 동일성에 의존해 같은 유저라도 인스턴스가 다르면(Course.isMatchedUser, RecordService, PublicCourseService 등에서) 다른 사람으로 오판정될 수 있었음 CourseServiceTest의 관련 3개 테스트를 수정된 동작에 맞게 갱신하고, RunnectUserTest를 새로 추가해 equals/hashCode 자체를 검증. --- .../server/common/constant/ErrorStatus.java | 1 + .../module/convert/DepartureConverter.java | 5 +- .../server/course/service/CourseService.java | 2 +- .../server/user/entity/RunnectUser.java | 20 +++++ .../course/service/CourseServiceTest.java | 33 ++++----- .../server/user/entity/RunnectUserTest.java | 74 +++++++++++++++++++ 6 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 src/test/java/org/runnect/server/user/entity/RunnectUserTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index 0903bd3..d771ffd 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -34,6 +34,7 @@ public enum ErrorStatus { INVALID_HEALTH_DATA_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 건강 데이터입니다"), INVALID_DATE_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 날짜 범위입니다"), EXCEED_HEART_RATE_SAMPLES_EXCEPTION(HttpStatus.BAD_REQUEST, "심박수 샘플은 최대 5000건까지 허용됩니다"), + VALIDATION_DEPARTURE_ADDRESS_EXCEPTION(HttpStatus.BAD_REQUEST, "출발지 주소 형식이 올바르지 않습니다."), /** * 401 UNAUTHORIZED diff --git a/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java b/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java index 207e12d..46a640f 100644 --- a/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java +++ b/src/main/java/org/runnect/server/common/module/convert/DepartureConverter.java @@ -1,6 +1,8 @@ package org.runnect.server.common.module.convert; +import org.runnect.server.common.constant.ErrorStatus; import org.runnect.server.common.dto.DepartureResponse; +import org.runnect.server.common.exception.BadRequestException; import java.util.Arrays; @@ -8,7 +10,8 @@ public class DepartureConverter { public static DepartureResponse requestConvertDeparture(String departureAddress, String departureName) { String[] departures = departureAddress.split(" "); if (departures.length < 3) { - return null; + throw new BadRequestException(ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION, + ErrorStatus.VALIDATION_DEPARTURE_ADDRESS_EXCEPTION.getMessage()); } else if (departures.length == 3) { return new DepartureResponse( departures[0], diff --git a/src/main/java/org/runnect/server/course/service/CourseService.java b/src/main/java/org/runnect/server/course/service/CourseService.java index 9a485c9..1f6c6ca 100644 --- a/src/main/java/org/runnect/server/course/service/CourseService.java +++ b/src/main/java/org/runnect/server/course/service/CourseService.java @@ -128,7 +128,7 @@ public GetCourseDetailResponseDto getCourseDetail(Long courseId, Long userId) { @Transactional public UpdateCourseResponseDto updateCourse(Long userId, Long courseId, String title) { - Course course = courseRepository.findById(courseId) + Course course = courseRepository.findByCourseIdAndUserId(courseId, userId) .orElseThrow(()->new NotFoundException(NOT_FOUND_COURSE_EXCEPTION, NOT_FOUND_COURSE_EXCEPTION.getMessage())); course.updateCourse(title); diff --git a/src/main/java/org/runnect/server/user/entity/RunnectUser.java b/src/main/java/org/runnect/server/user/entity/RunnectUser.java index 69ed968..a37f7e3 100644 --- a/src/main/java/org/runnect/server/user/entity/RunnectUser.java +++ b/src/main/java/org/runnect/server/user/entity/RunnectUser.java @@ -131,4 +131,24 @@ public void updateCreatedPublicCourse() { public void updateDeletedAt() { throw new RuntimeException("Course를 제외한 테이블은 정상적으로 삭제됩니다."); } + + // 기본 equals(참조 비교)에 의존하면, 같은 유저를 서로 다른 조회 경로로 가져왔을 때 + // (예: 로그인 유저 vs 코스에 매핑된 유저) 같은 사람인데도 다르다고 판정될 수 있다. + // id 기준으로 비교하고, hashCode는 영속화 전후로 값이 바뀌지 않도록 상수로 고정한다. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof RunnectUser)) { + return false; + } + RunnectUser that = (RunnectUser) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java index 3f2a5b2..75c9059 100644 --- a/src/test/java/org/runnect/server/course/service/CourseServiceTest.java +++ b/src/test/java/org/runnect/server/course/service/CourseServiceTest.java @@ -173,14 +173,16 @@ class CreateCourse { } @Test - @DisplayName("[버그 의심] 출발지 주소가 3토큰 미만이면 DepartureConverter가 null을 반환해 NPE가 난다") - void 출발지_주소가_불완전하면_NPE() { + @DisplayName("출발지 주소가 3토큰 미만이면 BadRequestException") + void 출발지_주소가_불완전하면_BadRequestException() { RunnectUser user = buildUser(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); assertThatThrownBy(() -> courseService.createCourse(1L, createRequestDto(validPath(), "경기 시흥시"), "img")) - .isInstanceOf(NullPointerException.class); + .isInstanceOf(BadRequestException.class); + + verify(courseRepository, never()).save(any()); } } @@ -292,9 +294,8 @@ class GetCourseDetail { } @Test - @DisplayName("[주의] 업로더와 요청자의 id가 같아도 객체 인스턴스가 다르면 isNowUser가 false로 나온다" - + " (RunnectUser에 equals/hashCode 미구현, 참조 동일성으로 비교됨)") - void 같은_id여도_인스턴스가_다르면_다른_사람으로_판정된다() { + @DisplayName("업로더와 요청자의 id가 같으면 객체 인스턴스가 달라도 같은 사람으로 판정된다") + void 같은_id면_인스턴스가_달라도_같은_사람으로_판정된다() { RunnectUser requester = buildUser(1L); RunnectUser uploaderWithSameIdButDifferentInstance = buildUser(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(requester)); @@ -303,7 +304,7 @@ class GetCourseDetail { GetCourseDetailResponseDto response = courseService.getCourseDetail(10L, 1L); - assertThat(response.getCourse().getIsNowUser()).isFalse(); + assertThat(response.getCourse().getIsNowUser()).isTrue(); } @Test @@ -350,7 +351,7 @@ class UpdateCourse { void 정상_수정() { RunnectUser user = buildUser(1L); Course course = buildCourse(10L, user, true); - when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.of(course)); UpdateCourseResponseDto response = courseService.updateCourse(1L, 10L, "새 제목"); @@ -362,24 +363,20 @@ class UpdateCourse { @Test @DisplayName("존재하지 않는 코스면 NotFoundException") void 존재하지_않는_코스() { - when(courseRepository.findById(10L)).thenReturn(Optional.empty()); + when(courseRepository.findByCourseIdAndUserId(10L, 1L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> courseService.updateCourse(1L, 10L, "새 제목")) .isInstanceOf(NotFoundException.class); } @Test - @DisplayName("[버그 의심] userId로 소유자 검증을 하지 않아, 코스 소유자가 아니어도 제목을 수정할 수 있다") - void 소유자가_아니어도_수정_가능() { - RunnectUser owner = buildUser(1L); - Course course = buildCourse(10L, owner, true); - when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); - + @DisplayName("코스 소유자가 아니면 NotFoundException (IDOR 방지)") + void 소유자가_아니면_수정_불가() { Long 다른유저Id = 999L; - UpdateCourseResponseDto response = courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경"); + when(courseRepository.findByCourseIdAndUserId(10L, 다른유저Id)).thenReturn(Optional.empty()); - assertThat(course.getTitle()).isEqualTo("남의 코스 제목 변경"); - assertThat(response.getCourse().getTitle()).isEqualTo("남의 코스 제목 변경"); + assertThatThrownBy(() -> courseService.updateCourse(다른유저Id, 10L, "남의 코스 제목 변경")) + .isInstanceOf(NotFoundException.class); } } diff --git a/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java b/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java new file mode 100644 index 0000000..e189629 --- /dev/null +++ b/src/test/java/org/runnect/server/user/entity/RunnectUserTest.java @@ -0,0 +1,74 @@ +package org.runnect.server.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class RunnectUserTest { + + private RunnectUser userWithId(Long id) { + RunnectUser user = RunnectUser.builder() + .nickname("러너") + .socialId("social") + .email("runner@runnect.io") + .provider(SocialType.KAKAO) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + void 자기_자신과는_같다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isEqualTo(user); + } + + @Test + void id가_같으면_인스턴스가_달라도_같다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(1L); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + void id가_다르면_다르다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(2L); + + assertThat(a).isNotEqualTo(b); + } + + @Test + void id가_없는_인스턴스끼리는_다르다() { + RunnectUser a = userWithId(null); + RunnectUser b = userWithId(null); + + assertThat(a).isNotEqualTo(b); + } + + @Test + void null과_비교하면_다르다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isNotEqualTo(null); + } + + @Test + void 다른_타입과_비교하면_다르다() { + RunnectUser user = userWithId(1L); + + assertThat(user).isNotEqualTo("1"); + } + + @Test + void hashCode는_id와_무관하게_동일_클래스면_같다() { + RunnectUser a = userWithId(1L); + RunnectUser b = userWithId(2L); + + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } +}