From edaf15ff121df9e5aea84e5afc2d0cf5ef7c2b57 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 28 Aug 2025 06:34:36 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[SCRUM-258]=20FEAT:=20GPX=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20API=20=EA=B5=AC=ED=98=84=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자신이 만든 코스의 GPX 파일을 다운로드하는 API를 구현했습니다. 유효시간이 1시간인 Presigned GET URL 발급을 통해 이루어집니다. --- .../course/controller/CourseController.java | 30 ++++++++++++----- .../domain/course/dto/GpxPathDto.java | 13 ++++++++ .../domain/course/entity/Course.java | 24 +++++++------- .../domain/course/service/CourseService.java | 32 +++++++++++++++---- .../domain/member/entity/Member.java | 5 +++ .../global/response/ResponseCode.java | 1 + 6 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 69f11e8..73d8efe 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -2,10 +2,7 @@ import static com.server.running_handai.global.response.ResponseCode.*; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; +import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.service.CourseService; import com.server.running_handai.global.oauth.CustomOAuth2User; import com.server.running_handai.global.response.CommonResponse; @@ -28,7 +25,6 @@ @Slf4j @RestController -@RequestMapping("/api/courses") @RequiredArgsConstructor @Tag(name = "Course", description = "코스 관련 API") public class CourseController { @@ -40,7 +36,7 @@ public class CourseController { @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "400", description = "실패 (요청 파라미터 오류)") }) - @GetMapping + @GetMapping("/api/courses") public ResponseEntity>> getFilteredCourses( @ParameterObject @ModelAttribute CourseFilterRequestDto filterOption, @AuthenticationPrincipal CustomOAuth2User customOAuth2User @@ -60,7 +56,7 @@ public ResponseEntity>> getFiltere @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)") }) - @GetMapping("/{courseId}") + @GetMapping("/api/courses/{courseId}") public ResponseEntity> getCourseDetails( @Parameter(description = "조회하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, @@ -77,7 +73,7 @@ public ResponseEntity> getCourseDetails( @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스)") }) - @GetMapping("/{courseId}/summary") + @GetMapping("/api/courses/{courseId}/summary") public ResponseEntity> getCourseSummary( @Parameter(description = "조회하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, @@ -89,4 +85,22 @@ public ResponseEntity> getCourseSummary( return ResponseEntity.ok(CommonResponse.success(SUCCESS, courseSummary)); } + @Operation(summary = "GPX 파일 다운로드", description = "GPX 파일을 다운로드할 수 있는 Presigned GET URL을 발급합니다. 해당 URL의 유효시간은 1시간입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS"), + @ApiResponse(responseCode = "403", description = "실패 (해당 사용자가 만든 코스가 아님) - NOT_COURSE_CREATOR"), + @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스) - COURSE_NOT_FOUND") + }) + @GetMapping("/api/members/me/courses/{courseId}") + public ResponseEntity> downloadGpx( + @Parameter(description = "다운로드하려는 코스 ID", required = true) + @PathVariable("courseId") Long courseId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + log.info("[코스 GPX 다운로드] courseId: {}, memberId: {}", courseId, memberId); + GpxPathDto gpxPath = courseService.downloadGpx(courseId, memberId); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, gpxPath)); + } } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java b/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java new file mode 100644 index 0000000..59747b5 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/GpxPathDto.java @@ -0,0 +1,13 @@ +package com.server.running_handai.domain.course.dto; + +public record GpxPathDto( + Long courseId, + String gpxPath +) { + public static GpxPathDto from(Long courseId, String gpxPath) { + return new GpxPathDto( + courseId, + gpxPath + ); + } +} diff --git a/src/main/java/com/server/running_handai/domain/course/entity/Course.java b/src/main/java/com/server/running_handai/domain/course/entity/Course.java index 9cd9224..dfeb269 100644 --- a/src/main/java/com/server/running_handai/domain/course/entity/Course.java +++ b/src/main/java/com/server/running_handai/domain/course/entity/Course.java @@ -1,21 +1,11 @@ package com.server.running_handai.domain.course.entity; +import com.server.running_handai.domain.member.entity.Member; import com.server.running_handai.domain.spot.entity.CourseSpot; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.global.entity.BaseTimeEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; + import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -23,6 +13,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.locationtech.jts.geom.Point; @Entity @@ -94,6 +86,12 @@ public class Course extends BaseTimeEntity { @OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true) private List courseSpots = new ArrayList<>(); + // Member와 다대일 관계 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private Member creator; + @Builder public Course(String externalId, String name, double distance, int duration, CourseLevel level, Area area, String gpxPath, diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index c923fa5..907db80 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -5,14 +5,9 @@ import static com.server.running_handai.global.response.ResponseCode.INVALID_THEME_PARAMETER; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; +import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; import com.server.running_handai.domain.bookmark.dto.BookmarkInfoDto; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.TrackPointDto; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.TrackPoint; import com.server.running_handai.domain.course.repository.CourseRepository; @@ -22,7 +17,9 @@ import com.server.running_handai.domain.review.service.ReviewService; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.repository.SpotRepository; +import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; + import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -54,6 +51,7 @@ public class CourseService { private final SpotRepository spotRepository; private final ReviewRepository reviewRepository; private final ReviewService reviewService; + private final FileService fileService; @Value("${course.simplification.distance-tolerance}") private double distanceTolerance; @@ -220,4 +218,26 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { return CourseSummaryDto.from(course, reviewCount, starAverage, reviewInfoDtos, spotInfoDtos); } + + /** + * 내가 생성한 코스의 GPX 파일 다운로드를 위한 Presigned GET URL을 발급합니다. + * 해당 URL의 유효시간은 1시간입니다. + * + * @param courseId 다운로드하려는 코스 ID + * @param memberId 다운로드 요청한 회원 ID + * @return GPX 파일 다운로드용 Presigned GET URL이 포함된 DTO + */ + public GpxPathDto downloadGpx(Long courseId, Long memberId) { + Course course = courseRepository.findById(courseId).orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); + + // 해당 Course를 만든 Member가 아닌 경우 + if (!course.getCreator().getId().equals(memberId)) { + throw new BusinessException(ResponseCode.NOT_COURSE_CREATOR); + } + + // Presigned GET URL 발급 (1시간) + String gpxPath = fileService.getPresignedGetUrl(course.getGpxPath(), 60); + + return GpxPathDto.from(courseId, gpxPath); + } } diff --git a/src/main/java/com/server/running_handai/domain/member/entity/Member.java b/src/main/java/com/server/running_handai/domain/member/entity/Member.java index 7bf20ce..a4ba52b 100644 --- a/src/main/java/com/server/running_handai/domain/member/entity/Member.java +++ b/src/main/java/com/server/running_handai/domain/member/entity/Member.java @@ -1,5 +1,6 @@ package com.server.running_handai.domain.member.entity; +import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.review.entity.Review; import com.server.running_handai.global.entity.BaseTimeEntity; import jakarta.persistence.*; @@ -51,6 +52,10 @@ public class Member extends BaseTimeEntity { @OneToMany(mappedBy = "writer", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private List reviews = new ArrayList<>(); + // Course와 일대다 관계 + @OneToMany(mappedBy = "creator", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + private List courses = new ArrayList<>(); + @Builder public Member(String providerId, String email, String nickname, Provider provider, Role role) { this.providerId = providerId; diff --git a/src/main/java/com/server/running_handai/global/response/ResponseCode.java b/src/main/java/com/server/running_handai/global/response/ResponseCode.java index f659431..4eed7f4 100644 --- a/src/main/java/com/server/running_handai/global/response/ResponseCode.java +++ b/src/main/java/com/server/running_handai/global/response/ResponseCode.java @@ -41,6 +41,7 @@ public enum ResponseCode { // FORBIDDEN (403) ACCESS_DENIED(FORBIDDEN, "접근 권한이 없습니다."), + NOT_COURSE_CREATOR(FORBIDDEN, "해당 코스를 만든 사용자가 아닙니다."), // NOT_FOUND (404) AREA_NOT_FOUND(NOT_FOUND, "지원하지 않는 지역입니다."), From a06eafdfd37d40de636ebd2c366724e173ecab74 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 28 Aug 2025 07:42:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[SCRUM-258]=20FEAT:=20=EB=82=B4=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자신이 만든 코스를 정렬 조건에 따라 전체 조회하는 API를 구현했습니다. --- .../course/controller/CourseController.java | 28 +++++++++++++++---- .../domain/course/dto/MyCourseDetailDto.java | 15 ++++++++++ .../course/repository/CourseRepository.java | 23 +++++++++++++++ .../domain/course/service/CourseService.java | 20 +++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 73d8efe..2ea3a78 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -8,20 +8,19 @@ import com.server.running_handai.global.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; + +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Slf4j @RestController @@ -103,4 +102,23 @@ public ResponseEntity> downloadGpx( GpxPathDto gpxPath = courseService.downloadGpx(courseId, memberId); return ResponseEntity.ok(CommonResponse.success(SUCCESS, gpxPath)); } + + @Operation(summary = "내 코스 전체 조회", description = "사용자가 생성한 코스 목록을 정렬 조건에 따라 조회합니다. 정렬 조건은 최신순, 오래된순, 짧은 거리순, 긴 거리순으로 총 4개입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "401", description = "토큰 인증 필요 - UNAUTHORIZED_ACCESS") + }) + @GetMapping("/api/members/me/courses") + public ResponseEntity> getMyCourses( + @Parameter( + description = "정렬 조건", + schema = @Schema(allowableValues = {"latest", "oldest", "short", "long"}) + ) + @RequestParam(defaultValue = "latest") String sortBy, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User + ) { + Long memberId = customOAuth2User.getMember().getId(); + MyCourseDetailDto myCourseDetail = courseService.getMyCourses(memberId, sortBy); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, myCourseDetail)); + } } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java b/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java new file mode 100644 index 0000000..26ca171 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/MyCourseDetailDto.java @@ -0,0 +1,15 @@ +package com.server.running_handai.domain.course.dto; + +import java.util.List; + +public record MyCourseDetailDto( + int courseCount, + List courses +) { + public static MyCourseDetailDto from(List courseInfoDtos) { + return new MyCourseDetailDto( + courseInfoDtos.size(), + courseInfoDtos + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java index 277d712..07316a5 100644 --- a/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java +++ b/src/main/java/com/server/running_handai/domain/course/repository/CourseRepository.java @@ -4,6 +4,8 @@ import com.server.running_handai.domain.course.entity.Course; import java.util.List; import java.util.Optional; + +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -91,4 +93,25 @@ public interface CourseRepository extends JpaRepository { "JOIN FETCH c.trackPoints " + "WHERE c.id = :courseId") Optional findCourseWithDetailsById(@Param("courseId") Long courseId); + + /** + * Member가 생성한 Course 목록을 정렬 조건에 따라 조회 + */ + @Query( + value = "SELECT " + + " c.course_id AS id, " + + " c.name, " + + " ci.img_url AS thumbnailUrl, " + + " c.distance, " + + " c.duration, " + + " c.max_ele AS maxElevation, " + + " 0.0 AS distanceFromUser " + + "FROM " + + " course c " + + "LEFT JOIN " + + " course_image ci ON c.course_id = ci.course_id " + + "WHERE c.member_id = :memberId ", + nativeQuery = true + ) + List findMyCoursesBySort(@Param("memberId") Long memberId, Sort sort); } diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index 907db80..a24322f 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -33,6 +33,7 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -240,4 +241,23 @@ public GpxPathDto downloadGpx(Long courseId, Long memberId) { return GpxPathDto.from(courseId, gpxPath); } + + /** + * 사용자가 생성한 코스 목록을 정렬 조건에 따라 조회합니다. + * + * @param memberId 조회 요청한 회원 ID + * @param sortBy 정렬 조건 (latest, oldest, short, long) + * @return 정렬된 코스 목록이 포함된 DTO + */ + public MyCourseDetailDto getMyCourses(Long memberId, String sortBy) { + Sort sort = switch (sortBy) { + case "oldest" -> Sort.by("created_at").ascending(); + case "short" -> Sort.by("distance").ascending(); + case "long" -> Sort.by("distance").descending(); + default -> Sort.by("created_at").descending(); + }; + + List courseInfoDtos = courseRepository.findMyCoursesBySort(memberId, sort); + return MyCourseDetailDto.from(courseInfoDtos); + } } From 0d6790d6dcde80441c3fdb74f010acbdaee703f5 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 28 Aug 2025 17:34:09 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[SCRUM-258]=20TEST:=20=EB=82=B4=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 코스 전체 조회 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 2개) --- .../course/controller/CourseController.java | 2 +- .../course/service/CourseServiceTest.java | 85 ++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 2ea3a78..2540ed2 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -91,7 +91,7 @@ public ResponseEntity> getCourseSummary( @ApiResponse(responseCode = "403", description = "실패 (해당 사용자가 만든 코스가 아님) - NOT_COURSE_CREATOR"), @ApiResponse(responseCode = "404", description = "실패 (존재하지 않는 코스) - COURSE_NOT_FOUND") }) - @GetMapping("/api/members/me/courses/{courseId}") + @GetMapping("/api/members/me/courses/{courseId}/gpx") public ResponseEntity> downloadGpx( @Parameter(description = "다운로드하려는 코스 ID", required = true) @PathVariable("courseId") Long courseId, diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index 3b92c7c..026b150 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -11,16 +11,11 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; -import com.server.running_handai.domain.course.dto.CourseDetailDto; -import com.server.running_handai.domain.course.dto.CourseFilterRequestDto; -import com.server.running_handai.domain.course.dto.CourseInfoDto; -import com.server.running_handai.domain.course.dto.CourseInfoWithDetailsDto; -import com.server.running_handai.domain.course.dto.CourseSummaryDto; +import com.server.running_handai.domain.course.dto.*; import com.server.running_handai.domain.course.entity.Area; import com.server.running_handai.domain.course.entity.Course; import com.server.running_handai.domain.course.entity.CourseFilter; @@ -43,9 +38,7 @@ import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -55,6 +48,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; @@ -62,6 +56,7 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; @@ -530,4 +525,74 @@ void getCourseSummary_fail_courseNotFound() { assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); } } + + @Nested + @DisplayName("내 코스 전체 조회 테스트") + class GetMyCoursesTest { + // 헬퍼 메서드 + private CourseInfoDto createCourseInfoDto(Long courseId, String courseName, double distance) { + CourseInfoDto courseInfoDto = Mockito.mock(CourseInfoDto.class); + given(courseInfoDto.getId()).willReturn(courseId); + given(courseInfoDto.getName()).willReturn(courseName); + given(courseInfoDto.getDistance()).willReturn(distance); + return courseInfoDto; + } + + /** + * [내 코스 전체 조회] 성공 + * 1. Course가 존재하는 경우 + */ + @ParameterizedTest + @ValueSource(strings = {"latest", "oldest", "short", "long"}) + @DisplayName("내 코스 전체 조회 - Course가 존재") + void getMyCourses_success_courseExists(String sortBy) { + // given + Sort sort = switch (sortBy) { + case "oldest" -> Sort.by("created_at").ascending(); + case "short" -> Sort.by("distance").ascending(); + case "long" -> Sort.by("distance").descending(); + default -> Sort.by("created_at").descending(); + }; + + List courseInfoDtos = List.of( + createCourseInfoDto(1L, "코스1", 13.5), + createCourseInfoDto(2L, "코스2", 12.1), + createCourseInfoDto(3L, "코스3", 10.8) + ); + + given(courseRepository.findMyCoursesBySort(MEMBER_ID, sort)).willReturn(courseInfoDtos); + + // when + MyCourseDetailDto result = courseService.getMyCourses(MEMBER_ID, sortBy); + + // then + assertThat(result.courseCount()).isEqualTo(3); + assertThat(result.courses()).hasSize(3); + + verify(courseRepository).findMyCoursesBySort(MEMBER_ID, sort); + } + + /** + * [내 코스 전체 조회] 성공 + * 2. Course가 존재하지 않는 경우 + */ + @Test + @DisplayName("내 코스 전체 조회 - Course가 존재하지 않음") + void getMyCourses_success_noCourse() { + // given + // Course가 존재하지 않으면 빈 리스트로 응답해야 함 (정렬 조건은 기본값으로 설정) + String sortBy = "latest"; + Sort sort = Sort.by("created_at").descending(); + given(courseRepository.findMyCoursesBySort(MEMBER_ID, sort)).willReturn(Collections.emptyList()); + + // when + MyCourseDetailDto result = courseService.getMyCourses(MEMBER_ID, sortBy); + + // then + assertThat(result.courseCount()).isEqualTo(0); + assertThat(result.courses()).isEmpty(); + + verify(courseRepository).findMyCoursesBySort(MEMBER_ID, sort); + } + } } \ No newline at end of file From cf66a60632d1dd653dabd2024a804826ae049e0a Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Thu, 28 Aug 2025 18:30:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[SCRUM-258]=20TEST:=20GPX=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20API=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPX 다운로드 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 1개, 실패 2개) --- .../course/service/CourseServiceTest.java | 107 +++++++++++++++--- 1 file changed, 91 insertions(+), 16 deletions(-) diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index 026b150..414db17 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -3,6 +3,7 @@ import static com.server.running_handai.domain.course.entity.CourseFilter.*; import static com.server.running_handai.domain.course.service.CourseService.MYSQL_POINT_FORMAT; import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; +import static com.server.running_handai.global.response.ResponseCode.NOT_COURSE_CREATOR; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -16,13 +17,7 @@ import com.server.running_handai.domain.bookmark.repository.BookmarkRepository; import com.server.running_handai.domain.bookmark.dto.BookmarkCountDto; import com.server.running_handai.domain.course.dto.*; -import com.server.running_handai.domain.course.entity.Area; -import com.server.running_handai.domain.course.entity.Course; -import com.server.running_handai.domain.course.entity.CourseFilter; -import com.server.running_handai.domain.course.entity.CourseLevel; -import com.server.running_handai.domain.course.entity.RoadCondition; -import com.server.running_handai.domain.course.entity.Theme; -import com.server.running_handai.domain.course.entity.TrackPoint; +import com.server.running_handai.domain.course.entity.*; import com.server.running_handai.domain.course.repository.CourseRepository; import com.server.running_handai.domain.course.repository.TrackPointRepository; import com.server.running_handai.domain.member.entity.Member; @@ -88,6 +83,9 @@ class CourseServiceTest { @Mock private GeometryFactory geometryFactory; + @Mock + private FileService fileService; + private static final Long COURSE_ID = 1L; private static final Long MEMBER_ID = 1L; private static final Double USER_LAT = 37.5665; @@ -526,15 +524,92 @@ void getCourseSummary_fail_courseNotFound() { } } + @Nested + @DisplayName("GPX 다운로드 테스트") + class CourseGpxDownloadTest { + // 헬퍼 메서드 + private Member createMockMember(Long memberId) { + Member member = Member.builder().build(); + ReflectionTestUtils.setField(member, "id", memberId); + return member; + } + + private Course createMockCourse(Long courseId, Member member) { + Course course = Course.builder().gpxPath("https://s3-bucket.com/course-1.gpx").build(); + ReflectionTestUtils.setField(course, "id", courseId); + ReflectionTestUtils.setField(course, "creator", member); + return course; + } + + /** + * [GPX 다운로드] 성공 + */ + @Test + @DisplayName("GPX 파일 다운로드 성공") + void gpxDownload_success() { + // given + Member member = createMockMember(MEMBER_ID); + Course course = createMockCourse(COURSE_ID, member); + String presignedUrl = "https://presigned-url.com/course-1.gpx"; + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + given(fileService.getPresignedGetUrl(course.getGpxPath(), 60)).willReturn(presignedUrl); + + // when + GpxPathDto result = courseService.downloadGpx(course.getId(), member.getId()); + + // then + assertThat(result.courseId()).isEqualTo(course.getId()); + assertThat(result.gpxPath()).isEqualTo(presignedUrl); + + verify(courseRepository).findById(COURSE_ID); + verify(fileService).getPresignedGetUrl(course.getGpxPath(), 60); + } + + /** + * [GPX 다운로드] 실패 + * 1. 요청한 Course가 없는 경우 + */ + @Test + @DisplayName("GPX 파일 다운로드 실패 - Course가 없음") + void gpxDownload_fail_courseNotFound() { + // given + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.empty()); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> courseService.downloadGpx(COURSE_ID, MEMBER_ID)); + assertThat(exception.getResponseCode()).isEqualTo(COURSE_NOT_FOUND); + } + + /** + * [GPX 다운로드] 실패 + * 2. 요청한 사용자가 만든 Course가 아닐 경우 + */ + @Test + @DisplayName("GPX 파일 다운로드 실패 - 요청한 사용자가 만든 Course가 아님") + void gpxDownload_fail_notCourseCreator() { + // given + Long otherMemberId = 999L; + Member otherMember = createMockMember(otherMemberId); + Course course = createMockCourse(COURSE_ID, otherMember); + + given(courseRepository.findById(COURSE_ID)).willReturn(Optional.of(course)); + + // when, then + BusinessException exception = assertThrows(BusinessException.class, () -> courseService.downloadGpx(COURSE_ID, MEMBER_ID)); + assertThat(exception.getResponseCode()).isEqualTo(NOT_COURSE_CREATOR); + + verify(courseRepository).findById(COURSE_ID); + verify(fileService, never()).getPresignedGetUrl(course.getGpxPath(), 60); + } + } + @Nested @DisplayName("내 코스 전체 조회 테스트") class GetMyCoursesTest { // 헬퍼 메서드 - private CourseInfoDto createCourseInfoDto(Long courseId, String courseName, double distance) { + private CourseInfoDto createCourseInfoDto() { CourseInfoDto courseInfoDto = Mockito.mock(CourseInfoDto.class); - given(courseInfoDto.getId()).willReturn(courseId); - given(courseInfoDto.getName()).willReturn(courseName); - given(courseInfoDto.getDistance()).willReturn(distance); return courseInfoDto; } @@ -544,7 +619,7 @@ private CourseInfoDto createCourseInfoDto(Long courseId, String courseName, doub */ @ParameterizedTest @ValueSource(strings = {"latest", "oldest", "short", "long"}) - @DisplayName("내 코스 전체 조회 - Course가 존재") + @DisplayName("내 코스 전체 조회 성공 - Course가 존재") void getMyCourses_success_courseExists(String sortBy) { // given Sort sort = switch (sortBy) { @@ -555,9 +630,9 @@ void getMyCourses_success_courseExists(String sortBy) { }; List courseInfoDtos = List.of( - createCourseInfoDto(1L, "코스1", 13.5), - createCourseInfoDto(2L, "코스2", 12.1), - createCourseInfoDto(3L, "코스3", 10.8) + createCourseInfoDto(), + createCourseInfoDto(), + createCourseInfoDto() ); given(courseRepository.findMyCoursesBySort(MEMBER_ID, sort)).willReturn(courseInfoDtos); @@ -577,7 +652,7 @@ void getMyCourses_success_courseExists(String sortBy) { * 2. Course가 존재하지 않는 경우 */ @Test - @DisplayName("내 코스 전체 조회 - Course가 존재하지 않음") + @DisplayName("내 코스 전체 조회 성공 - Course가 존재하지 않음") void getMyCourses_success_noCourse() { // given // Course가 존재하지 않으면 빈 리스트로 응답해야 함 (정렬 조건은 기본값으로 설정)