From d359c45b45739a08ac44a6a94c7187aeff361950 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 29 May 2026 21:50:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V26__create_course_likes.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/main/resources/db/migration/V26__create_course_likes.sql diff --git a/src/main/resources/db/migration/V26__create_course_likes.sql b/src/main/resources/db/migration/V26__create_course_likes.sql new file mode 100644 index 0000000..015b557 --- /dev/null +++ b/src/main/resources/db/migration/V26__create_course_likes.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS course_likes +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + user_id bigint not null + constraint fk_course_likes_user + references users, + course_id bigint not null + constraint fk_course_likes_course + references courses, + constraint uk_course_likes_user_id_course_id + unique (user_id, course_id) +); + +CREATE INDEX IF NOT EXISTS idx_course_likes_user_id_created_at + on course_likes (user_id asc, created_at desc); From e494fb4a61a1c029912991caf3ff4e5cba3c7273 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 29 May 2026 21:51:07 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/ErrorStatus.java | 2 + .../api/common/status/SuccessStatus.java | 2 + .../mountain/controller/CourseController.java | 26 +++- .../controller/docs/CourseControllerDocs.java | 42 ++++++ .../dto/response/CourseDetailResponse.java | 6 +- .../domain/mountain/entity/CourseLike.java | 58 +++++++++ .../repository/CourseLikeRepository.java | 20 +++ .../mountain/repository/CourseRepository.java | 1 + .../projection/CourseDetailProjection.java | 1 + .../mountain/service/CourseLikeService.java | 51 ++++++++ .../mountain/service/CourseService.java | 13 +- .../api/domain/user/service/UserService.java | 3 + .../service/CourseLikeServiceTest.java | 121 ++++++++++++++++++ .../mountain/service/CourseServiceTest.java | 88 +++++++++++++ .../domain/user/service/UserServiceTest.java | 4 + 15 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/mountain/entity/CourseLike.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java create mode 100644 src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java create mode 100644 src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java create mode 100644 src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 7bbeaa8..9c0eaaf 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -86,6 +86,8 @@ public enum ErrorStatus implements BaseStatus { MOUNTAIN_BBOX_PARTIAL(HttpStatus.BAD_REQUEST, "MTN_400_1", "BBox 좌표는 4개(swLat, swLng, neLat, neLng) 모두 보내거나 모두 비워주세요."), MOUNTAIN_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_2", "좋아요한 산이 아닙니다."), COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), + COURSE_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_4", "좋아요한 코스가 아닙니다."), + COURSE_LIKE_ALREADY_EXISTS(HttpStatus.CONFLICT, "MTN_409_2", "이미 좋아요한 코스입니다."), /** * Image diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 6040440..81fef36 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -43,6 +43,8 @@ public enum SuccessStatus implements BaseStatus { MOUNTAIN_MAP_SUCCESS(HttpStatus.OK, "MTN_200_7", "지도 영역 내 산 조회에 성공했습니다."), MOUNTAIN_RECOMMENDATION_SUCCESS(HttpStatus.OK, "MTN_200_8", "레벨 맞춤 산 추천 조회에 성공했습니다."), COURSE_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_9", "코스 상세 정보 조회에 성공했습니다."), + COURSE_LIKE_SUCCESS(HttpStatus.OK, "MTN_200_10", "코스 좋아요 등록에 성공했습니다."), + COURSE_UNLIKE_SUCCESS(HttpStatus.OK, "MTN_200_11", "코스 좋아요 취소에 성공했습니다."), /** * Hiking Record diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java b/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java index 04d1904..c23be49 100644 --- a/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java +++ b/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java @@ -4,12 +4,15 @@ import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.mountain.controller.docs.CourseControllerDocs; import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse; +import com.semosan.api.domain.mountain.service.CourseLikeService; import com.semosan.api.domain.mountain.service.CourseService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,6 +22,7 @@ public class CourseController implements CourseControllerDocs { private final CourseService courseService; + private final CourseLikeService courseLikeService; @GetMapping("/{courseId}") @Override @@ -26,7 +30,27 @@ public ResponseEntity> getCourseDetail( @AuthenticationPrincipal Long userId, @PathVariable Long courseId ) { - CourseDetailResponse response = courseService.getCourseDetail(courseId); + CourseDetailResponse response = courseService.getCourseDetail(userId, courseId); return ApiResponse.success(SuccessStatus.COURSE_DETAIL_SUCCESS, response); } + + @PostMapping("/{courseId}/like") + @Override + public ResponseEntity> likeCourse( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId + ) { + courseLikeService.likeCourse(userId, courseId); + return ApiResponse.success(SuccessStatus.COURSE_LIKE_SUCCESS); + } + + @DeleteMapping("/{courseId}/like") + @Override + public ResponseEntity> unlikeCourse( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId + ) { + courseLikeService.unlikeCourse(userId, courseId); + return ApiResponse.success(SuccessStatus.COURSE_UNLIKE_SUCCESS); + } } diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java b/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java index e503b0b..9ccde60 100644 --- a/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java @@ -36,4 +36,46 @@ ResponseEntity> getCourseDetail( @Parameter(description = "코스 ID", required = true) @PathVariable Long courseId ); + + @Operation( + summary = "코스 좋아요 등록", + description = "로그인한 사용자가 코스에 좋아요를 등록합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "코스 좋아요 등록 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "코스를 찾을 수 없음 (MTN_404_3)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> likeCourse( + @AuthenticationPrincipal Long userId, + @Parameter(description = "코스 ID", required = true) + @PathVariable Long courseId + ); + + @Operation( + summary = "코스 좋아요 취소", + description = "로그인한 사용자가 코스 좋아요를 취소합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "코스 좋아요 취소 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "좋아요한 코스가 아니거나 코스를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> unlikeCourse( + @AuthenticationPrincipal Long userId, + @Parameter(description = "코스 ID", required = true) + @PathVariable Long courseId + ); } diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java index 169fdfa..b522c39 100644 --- a/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java @@ -12,6 +12,7 @@ */ public record CourseDetailResponse( Long id, + Long mountainId, String name, Difficulty difficulty, Double distance, @@ -21,12 +22,14 @@ public record CourseDetailResponse( Double ascent, Double descent, Double maxAltitude, + boolean likedByMe, @JsonRawValue String polyline, @JsonRawValue String altitudes ) { - public static CourseDetailResponse from(CourseDetailProjection p) { + public static CourseDetailResponse from(CourseDetailProjection p, boolean likedByMe) { return new CourseDetailResponse( p.getId(), + p.getMountainId(), p.getName(), p.getDifficulty() == null ? null : Difficulty.valueOf(p.getDifficulty()), p.getDistance(), @@ -36,6 +39,7 @@ public static CourseDetailResponse from(CourseDetailProjection p) { p.getAscent(), p.getDescent(), p.getMaxAltitude(), + likedByMe, p.getPolyline(), p.getAltitudes() ); diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/CourseLike.java b/src/main/java/com/semosan/api/domain/mountain/entity/CourseLike.java new file mode 100644 index 0000000..a0a8461 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/entity/CourseLike.java @@ -0,0 +1,58 @@ +package com.semosan.api.domain.mountain.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table( + name = "course_likes", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_course_likes_user_id_course_id", + columnNames = {"user_id", "course_id"} + ) + }, + indexes = { + @Index(name = "idx_course_likes_user_id_created_at", columnList = "user_id, created_at DESC") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CourseLike extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id", nullable = false) + private Course course; + + public static CourseLike create(User user, Course course) { + return CourseLike.builder() + .user(user) + .course(course) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java new file mode 100644 index 0000000..f88c131 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java @@ -0,0 +1,20 @@ +package com.semosan.api.domain.mountain.repository; + +import com.semosan.api.domain.mountain.entity.CourseLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface CourseLikeRepository extends JpaRepository { + + boolean existsByUser_IdAndCourse_Id(Long userId, Long courseId); + + Optional findByUser_IdAndCourse_Id(Long userId, Long courseId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM CourseLike cl WHERE cl.user.id = :userId") + void deleteByUser_Id(@Param("userId") Long userId); +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java index 2d5bf7b..d624848 100644 --- a/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseRepository.java @@ -29,6 +29,7 @@ public interface CourseRepository extends JpaRepository { value = """ SELECT c.id AS id, + c.mountain_id AS mountainId, c.name AS name, c.difficulty AS difficulty, c.distance AS distance, diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/projection/CourseDetailProjection.java b/src/main/java/com/semosan/api/domain/mountain/repository/projection/CourseDetailProjection.java index 1b4b4fa..1a1edec 100644 --- a/src/main/java/com/semosan/api/domain/mountain/repository/projection/CourseDetailProjection.java +++ b/src/main/java/com/semosan/api/domain/mountain/repository/projection/CourseDetailProjection.java @@ -7,6 +7,7 @@ */ public interface CourseDetailProjection { Long getId(); + Long getMountainId(); String getName(); String getDifficulty(); Double getDistance(); diff --git a/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java new file mode 100644 index 0000000..45d1356 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java @@ -0,0 +1,51 @@ +package com.semosan.api.domain.mountain.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.CourseLike; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; +import com.semosan.api.domain.mountain.repository.CourseRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserReader; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CourseLikeService { + + private final CourseLikeRepository courseLikeRepository; + private final CourseRepository courseRepository; + private final UserReader userReader; + + @Transactional + public void likeCourse(Long userId, Long courseId) { + User user = userReader.findCompletedOnboardingUserById(userId); + Course course = findCourseById(courseId); + if (courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId)) { + throw new GeneralException(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS); + } + + try { + courseLikeRepository.save(CourseLike.create(user, course)); + } catch (DataIntegrityViolationException e) { + throw new GeneralException(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS); + } + } + + @Transactional + public void unlikeCourse(Long userId, Long courseId) { + userReader.findCompletedOnboardingUserById(userId); + CourseLike courseLike = courseLikeRepository.findByUser_IdAndCourse_Id(userId, courseId) + .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_LIKE_NOT_FOUND)); + courseLikeRepository.delete(courseLike); + } + + private Course findCourseById(Long courseId) { + return courseRepository.findById(courseId) + .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java b/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java index fe846ef..87dc57e 100644 --- a/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java +++ b/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java @@ -3,7 +3,10 @@ import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; import com.semosan.api.domain.mountain.repository.CourseRepository; +import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection; +import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,10 +17,14 @@ public class CourseService { private final CourseRepository courseRepository; + private final CourseLikeRepository courseLikeRepository; + private final UserReader userReader; - public CourseDetailResponse getCourseDetail(Long courseId) { - return courseRepository.findCourseDetailById(courseId) - .map(CourseDetailResponse::from) + public CourseDetailResponse getCourseDetail(Long userId, Long courseId) { + userReader.findCompletedOnboardingUserById(userId); + CourseDetailProjection course = courseRepository.findCourseDetailById(courseId) .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); + boolean likedByMe = courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId); + return CourseDetailResponse.from(course, likedByMe); } } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index e3a60c9..a5387c7 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -4,6 +4,7 @@ import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.hiking.repository.HikingMemberRepository; import com.semosan.api.domain.hiking.repository.HikingRecordRepository; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; import com.semosan.api.domain.mountain.repository.MountainLikeRepository; import com.semosan.api.domain.notification.repository.NotificationRepository; import com.semosan.api.domain.review.repository.ReviewRepository; @@ -33,6 +34,7 @@ public class UserService { private final UserNotificationSettingRepository userNotificationSettingRepository; private final UserOnboardingRepository userOnboardingRepository; private final MountainLikeRepository mountainLikeRepository; + private final CourseLikeRepository courseLikeRepository; private final ReviewRepository reviewRepository; private final HikingMemberRepository hikingMemberRepository; private final HikingRecordRepository hikingRecordRepository; @@ -143,6 +145,7 @@ public void withdrawUser(User user) { private void deleteUserChildRecords(Long userId) { mountainLikeRepository.deleteByUser_Id(userId); + courseLikeRepository.deleteByUser_Id(userId); reviewRepository.deleteByUser_Id(userId); List recordIdsToDelete = hikingRecordRepository.findRecordIdsOnlyParticipatedByUser(userId); hikingMemberRepository.deleteByUser_Id(userId); diff --git a/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java new file mode 100644 index 0000000..9541bc5 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java @@ -0,0 +1,121 @@ +package com.semosan.api.domain.mountain.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.CourseLike; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; +import com.semosan.api.domain.mountain.repository.CourseRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.service.UserReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Constructor; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CourseLikeServiceTest { + + @Mock + private CourseLikeRepository courseLikeRepository; + + @Mock + private CourseRepository courseRepository; + + @Mock + private UserReader userReader; + + @InjectMocks + private CourseLikeService courseLikeService; + + @Test + void likeCourseSavesCourseLike() throws Exception { + User user = user(1L); + Course course = course(10L); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + + courseLikeService.likeCourse(1L, 10L); + + verify(courseLikeRepository).save(any(CourseLike.class)); + } + + @Test + void likeCourseThrowsWhenAlreadyLiked() throws Exception { + User user = user(1L); + Course course = course(10L); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseLikeRepository.existsByUser_IdAndCourse_Id(1L, 10L)).thenReturn(true); + + assertThatThrownBy(() -> courseLikeService.likeCourse(1L, 10L)) + .isInstanceOf(GeneralException.class) + .hasMessage(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS.getMessage()); + } + + @Test + void likeCourseThrowsWhenConcurrentDuplicateDetected() throws Exception { + User user = user(1L); + Course course = course(10L); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseLikeRepository.save(any(CourseLike.class))).thenThrow(new DataIntegrityViolationException("duplicate")); + + assertThatThrownBy(() -> courseLikeService.likeCourse(1L, 10L)) + .isInstanceOf(GeneralException.class) + .hasMessage(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS.getMessage()); + } + + @Test + void unlikeCourseDeletesCourseLike() throws Exception { + User user = user(1L); + Course course = course(10L); + CourseLike courseLike = CourseLike.create(user, course); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.of(courseLike)); + + courseLikeService.unlikeCourse(1L, 10L); + + verify(courseLikeRepository).delete(courseLike); + } + + @Test + void unlikeCourseThrowsWhenNotLiked() { + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); + when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseLikeService.unlikeCourse(1L, 10L)) + .isInstanceOf(GeneralException.class) + .hasMessage(ErrorStatus.COURSE_LIKE_NOT_FOUND.getMessage()); + } + + private User user(Long id) { + User user = User.createTestUser("user-" + id, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Course course(Long id) throws Exception { + Constructor constructor = Course.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Course course = constructor.newInstance(); + ReflectionTestUtils.setField(course, "id", id); + return course; + } +} diff --git a/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java b/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java new file mode 100644 index 0000000..e4e6021 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java @@ -0,0 +1,88 @@ +package com.semosan.api.domain.mountain.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; +import com.semosan.api.domain.mountain.repository.CourseRepository; +import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.service.UserReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CourseServiceTest { + + @Mock + private CourseRepository courseRepository; + + @Mock + private CourseLikeRepository courseLikeRepository; + + @Mock + private UserReader userReader; + + @Mock + private CourseDetailProjection projection; + + @InjectMocks + private CourseService courseService; + + @Test + void getCourseDetailIncludesLikedByMe() { + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); + when(courseRepository.findCourseDetailById(10L)).thenReturn(Optional.of(projection)); + when(courseLikeRepository.existsByUser_IdAndCourse_Id(1L, 10L)).thenReturn(true); + stubProjection(); + + CourseDetailResponse response = courseService.getCourseDetail(1L, 10L); + + assertThat(response.id()).isEqualTo(10L); + assertThat(response.mountainId()).isEqualTo(100L); + assertThat(response.likedByMe()).isTrue(); + } + + @Test + void getCourseDetailThrowsWhenCourseNotFound() { + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); + when(courseRepository.findCourseDetailById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> courseService.getCourseDetail(1L, 10L)) + .isInstanceOf(GeneralException.class) + .hasMessage(ErrorStatus.COURSE_NOT_FOUND.getMessage()); + } + + private void stubProjection() { + when(projection.getId()).thenReturn(10L); + when(projection.getMountainId()).thenReturn(100L); + when(projection.getName()).thenReturn("가리산 코스 1"); + when(projection.getDifficulty()).thenReturn("EASY"); + when(projection.getDistance()).thenReturn(3300.0); + when(projection.getDuration()).thenReturn(55); + when(projection.getStartName()).thenReturn("가리산자연휴양림"); + when(projection.getEndName()).thenReturn("갈림길"); + when(projection.getAscent()).thenReturn(59.0); + when(projection.getDescent()).thenReturn(593.0); + when(projection.getMaxAltitude()).thenReturn(930.0); + when(projection.getPolyline()).thenReturn(null); + when(projection.getAltitudes()).thenReturn(null); + } + + private User user(Long id) { + User user = User.createTestUser("user-" + id, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/semosan/api/domain/user/service/UserServiceTest.java b/src/test/java/com/semosan/api/domain/user/service/UserServiceTest.java index df72fc2..a3bbeff 100644 --- a/src/test/java/com/semosan/api/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/semosan/api/domain/user/service/UserServiceTest.java @@ -2,6 +2,7 @@ import com.semosan.api.domain.hiking.repository.HikingMemberRepository; import com.semosan.api.domain.hiking.repository.HikingRecordRepository; +import com.semosan.api.domain.mountain.repository.CourseLikeRepository; import com.semosan.api.domain.mountain.repository.MountainLikeRepository; import com.semosan.api.domain.notification.repository.NotificationRepository; import com.semosan.api.domain.review.repository.ReviewRepository; @@ -44,6 +45,9 @@ class UserServiceTest { @Mock private MountainLikeRepository mountainLikeRepository; + @Mock + private CourseLikeRepository courseLikeRepository; + @Mock private ReviewRepository reviewRepository; From 4d228865922c6561de8314b6d321b40ccc1870c5 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 29 May 2026 21:53:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=BD=94=EC=8A=A4=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=86=A0=EA=B8=80=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/ErrorStatus.java | 2 - .../api/common/status/SuccessStatus.java | 3 +- .../mountain/controller/CourseController.java | 20 ++----- .../controller/docs/CourseControllerDocs.java | 30 ++-------- .../response/CourseLikeToggleResponse.java | 7 +++ .../repository/CourseLikeRepository.java | 2 + .../mountain/service/CourseLikeService.java | 37 +++++++----- .../service/CourseLikeServiceTest.java | 60 +++++++------------ 8 files changed, 67 insertions(+), 94 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 9c0eaaf..7bbeaa8 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -86,8 +86,6 @@ public enum ErrorStatus implements BaseStatus { MOUNTAIN_BBOX_PARTIAL(HttpStatus.BAD_REQUEST, "MTN_400_1", "BBox 좌표는 4개(swLat, swLng, neLat, neLng) 모두 보내거나 모두 비워주세요."), MOUNTAIN_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_2", "좋아요한 산이 아닙니다."), COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), - COURSE_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_4", "좋아요한 코스가 아닙니다."), - COURSE_LIKE_ALREADY_EXISTS(HttpStatus.CONFLICT, "MTN_409_2", "이미 좋아요한 코스입니다."), /** * Image diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 81fef36..a25334e 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -43,8 +43,7 @@ public enum SuccessStatus implements BaseStatus { MOUNTAIN_MAP_SUCCESS(HttpStatus.OK, "MTN_200_7", "지도 영역 내 산 조회에 성공했습니다."), MOUNTAIN_RECOMMENDATION_SUCCESS(HttpStatus.OK, "MTN_200_8", "레벨 맞춤 산 추천 조회에 성공했습니다."), COURSE_DETAIL_SUCCESS(HttpStatus.OK, "MTN_200_9", "코스 상세 정보 조회에 성공했습니다."), - COURSE_LIKE_SUCCESS(HttpStatus.OK, "MTN_200_10", "코스 좋아요 등록에 성공했습니다."), - COURSE_UNLIKE_SUCCESS(HttpStatus.OK, "MTN_200_11", "코스 좋아요 취소에 성공했습니다."), + COURSE_LIKE_TOGGLE_SUCCESS(HttpStatus.OK, "MTN_200_10", "코스 좋아요 처리에 성공했습니다."), /** * Hiking Record diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java b/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java index c23be49..108ca7b 100644 --- a/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java +++ b/src/main/java/com/semosan/api/domain/mountain/controller/CourseController.java @@ -4,12 +4,12 @@ import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.mountain.controller.docs.CourseControllerDocs; import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse; +import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse; import com.semosan.api.domain.mountain.service.CourseLikeService; import com.semosan.api.domain.mountain.service.CourseService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -36,21 +36,13 @@ public ResponseEntity> getCourseDetail( @PostMapping("/{courseId}/like") @Override - public ResponseEntity> likeCourse( + public ResponseEntity> toggleCourseLike( @AuthenticationPrincipal Long userId, @PathVariable Long courseId ) { - courseLikeService.likeCourse(userId, courseId); - return ApiResponse.success(SuccessStatus.COURSE_LIKE_SUCCESS); - } - - @DeleteMapping("/{courseId}/like") - @Override - public ResponseEntity> unlikeCourse( - @AuthenticationPrincipal Long userId, - @PathVariable Long courseId - ) { - courseLikeService.unlikeCourse(userId, courseId); - return ApiResponse.success(SuccessStatus.COURSE_UNLIKE_SUCCESS); + return ApiResponse.success( + SuccessStatus.COURSE_LIKE_TOGGLE_SUCCESS, + courseLikeService.toggleCourseLike(userId, courseId) + ); } } diff --git a/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java b/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java index 9ccde60..32c0393 100644 --- a/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java @@ -2,6 +2,7 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse; +import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -38,13 +39,13 @@ ResponseEntity> getCourseDetail( ); @Operation( - summary = "코스 좋아요 등록", - description = "로그인한 사용자가 코스에 좋아요를 등록합니다." + summary = "코스 좋아요 토글", + description = "로그인한 사용자가 코스 좋아요를 누르거나 취소합니다." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "코스 좋아요 등록 성공" + description = "코스 좋아요 토글 성공" ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", @@ -52,28 +53,7 @@ ResponseEntity> getCourseDetail( content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) - ResponseEntity> likeCourse( - @AuthenticationPrincipal Long userId, - @Parameter(description = "코스 ID", required = true) - @PathVariable Long courseId - ); - - @Operation( - summary = "코스 좋아요 취소", - description = "로그인한 사용자가 코스 좋아요를 취소합니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "코스 좋아요 취소 성공" - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "좋아요한 코스가 아니거나 코스를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ApiResponse.class)) - ) - }) - ResponseEntity> unlikeCourse( + ResponseEntity> toggleCourseLike( @AuthenticationPrincipal Long userId, @Parameter(description = "코스 ID", required = true) @PathVariable Long courseId diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java new file mode 100644 index 0000000..3e09ef3 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.mountain.dto.response; + +public record CourseLikeToggleResponse( + boolean liked, + long count +) { +} diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java index f88c131..a6786d9 100644 --- a/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java @@ -14,6 +14,8 @@ public interface CourseLikeRepository extends JpaRepository { Optional findByUser_IdAndCourse_Id(Long userId, Long courseId); + long countByCourse_Id(Long courseId); + @Modifying(clearAutomatically = true) @Query("DELETE FROM CourseLike cl WHERE cl.user.id = :userId") void deleteByUser_Id(@Param("userId") Long userId); diff --git a/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java index 45d1356..d478a2c 100644 --- a/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java +++ b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java @@ -2,6 +2,7 @@ import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse; import com.semosan.api.domain.mountain.entity.Course; import com.semosan.api.domain.mountain.entity.CourseLike; import com.semosan.api.domain.mountain.repository.CourseLikeRepository; @@ -9,10 +10,12 @@ import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class CourseLikeService { @@ -21,29 +24,35 @@ public class CourseLikeService { private final CourseRepository courseRepository; private final UserReader userReader; - @Transactional - public void likeCourse(Long userId, Long courseId) { + @Transactional(noRollbackFor = DataIntegrityViolationException.class) + public CourseLikeToggleResponse toggleCourseLike(Long userId, Long courseId) { + boolean liked = toggle(userId, courseId); + long count = courseLikeRepository.countByCourse_Id(courseId); + return new CourseLikeToggleResponse(liked, count); + } + + private boolean toggle(Long userId, Long courseId) { User user = userReader.findCompletedOnboardingUserById(userId); Course course = findCourseById(courseId); - if (courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId)) { - throw new GeneralException(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS); - } + return courseLikeRepository.findByUser_IdAndCourse_Id(userId, courseId) + .map(existing -> { + courseLikeRepository.delete(existing); + return false; + }) + .orElseGet(() -> createCourseLike(user, course, userId, courseId)); + } + + private boolean createCourseLike(User user, Course course, Long userId, Long courseId) { try { courseLikeRepository.save(CourseLike.create(user, course)); + return true; } catch (DataIntegrityViolationException e) { - throw new GeneralException(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS); + log.warn("CourseLike 동시 요청 감지: courseId={}, userId={}", courseId, userId); + return true; } } - @Transactional - public void unlikeCourse(Long userId, Long courseId) { - userReader.findCompletedOnboardingUserById(userId); - CourseLike courseLike = courseLikeRepository.findByUser_IdAndCourse_Id(userId, courseId) - .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_LIKE_NOT_FOUND)); - courseLikeRepository.delete(courseLike); - } - private Course findCourseById(Long courseId) { return courseRepository.findById(courseId) .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); diff --git a/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java index 9541bc5..5190d1d 100644 --- a/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java +++ b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java @@ -1,7 +1,6 @@ package com.semosan.api.domain.mountain.service; -import com.semosan.api.common.exception.GeneralException; -import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.dto.response.CourseLikeToggleResponse; import com.semosan.api.domain.mountain.entity.Course; import com.semosan.api.domain.mountain.entity.CourseLike; import com.semosan.api.domain.mountain.repository.CourseLikeRepository; @@ -20,7 +19,7 @@ import java.lang.reflect.Constructor; import java.util.Optional; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -41,68 +40,55 @@ class CourseLikeServiceTest { private CourseLikeService courseLikeService; @Test - void likeCourseSavesCourseLike() throws Exception { + void toggleCourseLikeCreatesLikeWhenNotLiked() throws Exception { User user = user(1L); Course course = course(10L); when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); + when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(1L); - courseLikeService.likeCourse(1L, 10L); + CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); + assertThat(response.liked()).isTrue(); + assertThat(response.count()).isEqualTo(1L); verify(courseLikeRepository).save(any(CourseLike.class)); } @Test - void likeCourseThrowsWhenAlreadyLiked() throws Exception { + void toggleCourseLikeDeletesLikeWhenAlreadyLiked() throws Exception { User user = user(1L); Course course = course(10L); + CourseLike courseLike = CourseLike.create(user, course); when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); - when(courseLikeRepository.existsByUser_IdAndCourse_Id(1L, 10L)).thenReturn(true); + when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.of(courseLike)); + when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(0L); - assertThatThrownBy(() -> courseLikeService.likeCourse(1L, 10L)) - .isInstanceOf(GeneralException.class) - .hasMessage(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS.getMessage()); + CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); + + assertThat(response.liked()).isFalse(); + assertThat(response.count()).isZero(); + verify(courseLikeRepository).delete(courseLike); } @Test - void likeCourseThrowsWhenConcurrentDuplicateDetected() throws Exception { + void toggleCourseLikeReturnsLikedWhenConcurrentDuplicateDetected() throws Exception { User user = user(1L); Course course = course(10L); when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); + when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); when(courseLikeRepository.save(any(CourseLike.class))).thenThrow(new DataIntegrityViolationException("duplicate")); + when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(1L); - assertThatThrownBy(() -> courseLikeService.likeCourse(1L, 10L)) - .isInstanceOf(GeneralException.class) - .hasMessage(ErrorStatus.COURSE_LIKE_ALREADY_EXISTS.getMessage()); - } - - @Test - void unlikeCourseDeletesCourseLike() throws Exception { - User user = user(1L); - Course course = course(10L); - CourseLike courseLike = CourseLike.create(user, course); - - when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); - when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.of(courseLike)); - - courseLikeService.unlikeCourse(1L, 10L); - - verify(courseLikeRepository).delete(courseLike); - } - - @Test - void unlikeCourseThrowsWhenNotLiked() { - when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); - when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); + CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); - assertThatThrownBy(() -> courseLikeService.unlikeCourse(1L, 10L)) - .isInstanceOf(GeneralException.class) - .hasMessage(ErrorStatus.COURSE_LIKE_NOT_FOUND.getMessage()); + assertThat(response.liked()).isTrue(); + assertThat(response.count()).isEqualTo(1L); } private User user(Long id) { From 27f7410ee956c0af4bd99f7167f428fdf66d22a6 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 29 May 2026 22:04:35 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=BD=94=EC=8A=A4=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=86=A0=EA=B8=80=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CourseLikeToggleResponse.java | 3 +-- .../mountain/repository/CourseLikeRepository.java | 2 -- .../domain/mountain/service/CourseLikeService.java | 3 +-- .../api/domain/mountain/service/CourseService.java | 5 +---- .../mountain/service/CourseLikeServiceTest.java | 6 ------ .../domain/mountain/service/CourseServiceTest.java | 14 -------------- 6 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java index 3e09ef3..8c9f20d 100644 --- a/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java +++ b/src/main/java/com/semosan/api/domain/mountain/dto/response/CourseLikeToggleResponse.java @@ -1,7 +1,6 @@ package com.semosan.api.domain.mountain.dto.response; public record CourseLikeToggleResponse( - boolean liked, - long count + boolean liked ) { } diff --git a/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java index a6786d9..f88c131 100644 --- a/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java +++ b/src/main/java/com/semosan/api/domain/mountain/repository/CourseLikeRepository.java @@ -14,8 +14,6 @@ public interface CourseLikeRepository extends JpaRepository { Optional findByUser_IdAndCourse_Id(Long userId, Long courseId); - long countByCourse_Id(Long courseId); - @Modifying(clearAutomatically = true) @Query("DELETE FROM CourseLike cl WHERE cl.user.id = :userId") void deleteByUser_Id(@Param("userId") Long userId); diff --git a/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java index d478a2c..f33ae4b 100644 --- a/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java +++ b/src/main/java/com/semosan/api/domain/mountain/service/CourseLikeService.java @@ -27,8 +27,7 @@ public class CourseLikeService { @Transactional(noRollbackFor = DataIntegrityViolationException.class) public CourseLikeToggleResponse toggleCourseLike(Long userId, Long courseId) { boolean liked = toggle(userId, courseId); - long count = courseLikeRepository.countByCourse_Id(courseId); - return new CourseLikeToggleResponse(liked, count); + return new CourseLikeToggleResponse(liked); } private boolean toggle(Long userId, Long courseId) { diff --git a/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java b/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java index 87dc57e..bd4e897 100644 --- a/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java +++ b/src/main/java/com/semosan/api/domain/mountain/service/CourseService.java @@ -6,7 +6,6 @@ import com.semosan.api.domain.mountain.repository.CourseLikeRepository; import com.semosan.api.domain.mountain.repository.CourseRepository; import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection; -import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,13 +17,11 @@ public class CourseService { private final CourseRepository courseRepository; private final CourseLikeRepository courseLikeRepository; - private final UserReader userReader; public CourseDetailResponse getCourseDetail(Long userId, Long courseId) { - userReader.findCompletedOnboardingUserById(userId); CourseDetailProjection course = courseRepository.findCourseDetailById(courseId) .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); - boolean likedByMe = courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId); + boolean likedByMe = userId != null && courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId); return CourseDetailResponse.from(course, likedByMe); } } diff --git a/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java index 5190d1d..7c0b4f6 100644 --- a/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java +++ b/src/test/java/com/semosan/api/domain/mountain/service/CourseLikeServiceTest.java @@ -47,12 +47,10 @@ void toggleCourseLikeCreatesLikeWhenNotLiked() throws Exception { when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); - when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(1L); CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); assertThat(response.liked()).isTrue(); - assertThat(response.count()).isEqualTo(1L); verify(courseLikeRepository).save(any(CourseLike.class)); } @@ -65,12 +63,10 @@ void toggleCourseLikeDeletesLikeWhenAlreadyLiked() throws Exception { when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.of(courseLike)); - when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(0L); CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); assertThat(response.liked()).isFalse(); - assertThat(response.count()).isZero(); verify(courseLikeRepository).delete(courseLike); } @@ -83,12 +79,10 @@ void toggleCourseLikeReturnsLikedWhenConcurrentDuplicateDetected() throws Except when(courseRepository.findById(10L)).thenReturn(Optional.of(course)); when(courseLikeRepository.findByUser_IdAndCourse_Id(1L, 10L)).thenReturn(Optional.empty()); when(courseLikeRepository.save(any(CourseLike.class))).thenThrow(new DataIntegrityViolationException("duplicate")); - when(courseLikeRepository.countByCourse_Id(10L)).thenReturn(1L); CourseLikeToggleResponse response = courseLikeService.toggleCourseLike(1L, 10L); assertThat(response.liked()).isTrue(); - assertThat(response.count()).isEqualTo(1L); } private User user(Long id) { diff --git a/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java b/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java index e4e6021..d12b619 100644 --- a/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java +++ b/src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java @@ -6,15 +6,11 @@ import com.semosan.api.domain.mountain.repository.CourseLikeRepository; import com.semosan.api.domain.mountain.repository.CourseRepository; import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection; -import com.semosan.api.domain.user.entity.User; -import com.semosan.api.domain.user.enums.user.DeviceType; -import com.semosan.api.domain.user.service.UserReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; @@ -31,9 +27,6 @@ class CourseServiceTest { @Mock private CourseLikeRepository courseLikeRepository; - @Mock - private UserReader userReader; - @Mock private CourseDetailProjection projection; @@ -42,7 +35,6 @@ class CourseServiceTest { @Test void getCourseDetailIncludesLikedByMe() { - when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); when(courseRepository.findCourseDetailById(10L)).thenReturn(Optional.of(projection)); when(courseLikeRepository.existsByUser_IdAndCourse_Id(1L, 10L)).thenReturn(true); stubProjection(); @@ -56,7 +48,6 @@ void getCourseDetailIncludesLikedByMe() { @Test void getCourseDetailThrowsWhenCourseNotFound() { - when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user(1L)); when(courseRepository.findCourseDetailById(10L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> courseService.getCourseDetail(1L, 10L)) @@ -80,9 +71,4 @@ private void stubProjection() { when(projection.getAltitudes()).thenReturn(null); } - private User user(Long id) { - User user = User.createTestUser("user-" + id, DeviceType.IOS); - ReflectionTestUtils.setField(user, "id", id); - return user; - } }