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 0d819511..96c965ec 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -103,8 +103,10 @@ public enum ErrorStatus implements BaseStatus { /** * Hiking (등산 기록) */ + HIKING_RECORD_COURSE_REQUIRED(HttpStatus.BAD_REQUEST, "HIKE_400_1", "코스 기반 등산 기록에만 난이도 피드백을 남길 수 있습니다."), HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."), HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."), + COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS(HttpStatus.CONFLICT, "HIKE_409_1", "이미 난이도 피드백을 남긴 등산 기록입니다."), /** * Tracking (트래킹 세션) 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 2f1f7e46..5b4648d1 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -51,6 +51,7 @@ public enum SuccessStatus implements BaseStatus { GET_HIKING_RECORD_SUMMARY_SUCCESS(HttpStatus.OK, "HIKING_200_2", "나의 등산 기록 요약 조회에 성공했습니다."), GET_HIKING_RECORD_LIST_SUCCESS(HttpStatus.OK, "HIKING_200_3", "나의 등산 기록 목록 조회에 성공했습니다."), GET_HIKING_RECORD_LIST_BY_MOUNTAIN_SUCCESS(HttpStatus.OK, "HIKING_200_4", "특정 산의 나의 등산 기록 목록 조회에 성공했습니다."), + CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS(HttpStatus.CREATED, "HIKING_201_1", "코스 난이도 피드백 저장에 성공했습니다."), /** * Tracking — 진입 화면 (#45) & 세션 (#18) diff --git a/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java b/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java index 895c0592..1ea50ad8 100644 --- a/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java +++ b/src/main/java/com/semosan/api/domain/hiking/controller/HikingRecordController.java @@ -4,10 +4,13 @@ import com.semosan.api.common.response.PageResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.hiking.controller.docs.HikingRecordControllerDocs; +import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest; +import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; import com.semosan.api.domain.hiking.service.HikingRecordService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -15,6 +18,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -70,4 +75,20 @@ public ResponseEntity> getUserHi GetUserHikingRecordSummaryResponse response = hikingRecordService.getUserHikingRecordSummary(userId); return ApiResponse.success(SuccessStatus.GET_HIKING_RECORD_SUMMARY_SUCCESS, response); } + + // 코스 난이도 피드백을 저장합니다. + @PostMapping("/{hikingRecordId}/difficulty-feedback") + @Override + public ResponseEntity> createCourseDifficultyFeedback( + @AuthenticationPrincipal Long userId, + @PathVariable Long hikingRecordId, + @Valid @RequestBody CreateCourseDifficultyFeedbackRequest request + ) { + CourseDifficultyFeedbackResponse response = hikingRecordService.createCourseDifficultyFeedback( + userId, + hikingRecordId, + request + ); + return ApiResponse.success(SuccessStatus.CREATE_COURSE_DIFFICULTY_FEEDBACK_SUCCESS, response); + } } diff --git a/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java b/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java index 8bd29780..8df7425b 100644 --- a/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/hiking/controller/docs/HikingRecordControllerDocs.java @@ -2,9 +2,12 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.response.PageResponse; +import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest; +import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -16,6 +19,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Hiking Record", description = "등산 기록 관련 API") public interface HikingRecordControllerDocs { @@ -89,4 +93,42 @@ ResponseEntity> getUserHikingRec @Parameter(hidden = true) @AuthenticationPrincipal Long userId ); + + @Operation( + summary = "코스 난이도 피드백 저장", + description = "코스 기반 등산 기록 1개에 대해 사용자가 체감한 난이도 비교 피드백을 저장합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "코스 난이도 피드백 저장 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "자유 기록 등 코스 비교가 불가능한 등산 기록", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "본인이 참여한 등산 기록이 아님", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "등산 기록을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "이미 난이도 피드백을 저장한 등산 기록", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> createCourseDifficultyFeedback( + @Parameter(hidden = true) + @AuthenticationPrincipal Long userId, + @Parameter(description = "등산 기록 ID", required = true) + @PathVariable Long hikingRecordId, + @Valid @RequestBody CreateCourseDifficultyFeedbackRequest request + ); } diff --git a/src/main/java/com/semosan/api/domain/hiking/dto/request/CreateCourseDifficultyFeedbackRequest.java b/src/main/java/com/semosan/api/domain/hiking/dto/request/CreateCourseDifficultyFeedbackRequest.java new file mode 100644 index 00000000..def546ed --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/dto/request/CreateCourseDifficultyFeedbackRequest.java @@ -0,0 +1,9 @@ +package com.semosan.api.domain.hiking.dto.request; + +import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType; +import jakarta.validation.constraints.NotNull; + +public record CreateCourseDifficultyFeedbackRequest( + @NotNull DifficultyFeedbackType comparison +) { +} diff --git a/src/main/java/com/semosan/api/domain/hiking/dto/response/CourseDifficultyFeedbackResponse.java b/src/main/java/com/semosan/api/domain/hiking/dto/response/CourseDifficultyFeedbackResponse.java new file mode 100644 index 00000000..56d1f35d --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/dto/response/CourseDifficultyFeedbackResponse.java @@ -0,0 +1,30 @@ +package com.semosan.api.domain.hiking.dto.response; + +import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback; +import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType; +import com.semosan.api.domain.mountain.enums.Difficulty; + +public record CourseDifficultyFeedbackResponse( + Long feedbackId, + Long hikingRecordId, + Long mountainId, + String mountainName, + Long courseId, + String courseName, + Difficulty guideDifficulty, + DifficultyFeedbackType comparison +) { + + public static CourseDifficultyFeedbackResponse from(CourseDifficultyFeedback feedback) { + return new CourseDifficultyFeedbackResponse( + feedback.getId(), + feedback.getHikingRecord().getId(), + feedback.getCourse().getMountain().getId(), + feedback.getCourse().getMountain().getName(), + feedback.getCourse().getId(), + feedback.getCourse().getName(), + feedback.getGuideDifficulty(), + feedback.getComparison() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/entity/CourseDifficultyFeedback.java b/src/main/java/com/semosan/api/domain/hiking/entity/CourseDifficultyFeedback.java new file mode 100644 index 00000000..d3cce13c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/entity/CourseDifficultyFeedback.java @@ -0,0 +1,74 @@ +package com.semosan.api.domain.hiking.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Table( + name = "course_difficulty_feedbacks", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_course_difficulty_feedback_hiking_record", + columnNames = "hiking_record_id" + ) + }, + indexes = { + @Index(name = "idx_course_difficulty_feedback_course_id", columnList = "course_id"), + @Index(name = "idx_course_difficulty_feedback_user_id", columnList = "user_id") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CourseDifficultyFeedback extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hiking_record_id", nullable = false) + private HikingRecord hikingRecord; + + @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; + + @Enumerated(EnumType.STRING) + @Column(name = "guide_difficulty", nullable = false, length = 20) + private Difficulty guideDifficulty; + + @Enumerated(EnumType.STRING) + @Column(name = "comparison", nullable = false, length = 20) + private DifficultyFeedbackType comparison; + + public static CourseDifficultyFeedback create( + HikingRecord hikingRecord, + User user, + DifficultyFeedbackType comparison + ) { + Course course = hikingRecord.getCourse(); + if (course == null) { + throw new GeneralException(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED); + } + return CourseDifficultyFeedback.builder() + .hikingRecord(hikingRecord) + .user(user) + .course(course) + .guideDifficulty(course.getDifficulty()) + .comparison(comparison) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/enums/DifficultyFeedbackType.java b/src/main/java/com/semosan/api/domain/hiking/enums/DifficultyFeedbackType.java new file mode 100644 index 00000000..e7990b89 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/enums/DifficultyFeedbackType.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.hiking.enums; + +public enum DifficultyFeedbackType { + SIMILAR, + EASIER, + HARDER +} diff --git a/src/main/java/com/semosan/api/domain/hiking/repository/CourseDifficultyFeedbackRepository.java b/src/main/java/com/semosan/api/domain/hiking/repository/CourseDifficultyFeedbackRepository.java new file mode 100644 index 00000000..cbc9803c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/hiking/repository/CourseDifficultyFeedbackRepository.java @@ -0,0 +1,9 @@ +package com.semosan.api.domain.hiking.repository; + +import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CourseDifficultyFeedbackRepository extends JpaRepository { + + boolean existsByHikingRecord_Id(Long hikingRecordId); +} diff --git a/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java b/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java index e07973fe..dadbc253 100644 --- a/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java +++ b/src/main/java/com/semosan/api/domain/hiking/repository/HikingRecordRepository.java @@ -74,7 +74,7 @@ Page findUserHikingMountainRecordsByUserId( FROM hiking_records hr JOIN hiking_members hm ON hm.hiking_record_id = hr.id JOIN mountains m ON m.id = hr.mountain_id - JOIN courses c ON c.id = hr.course_id + LEFT JOIN courses c ON c.id = hr.course_id WHERE hm.user_id = :userId ORDER BY hr.created_at DESC, hr.id DESC """, @@ -121,7 +121,7 @@ Page findUserHikingRecordsByUserId( FROM hiking_records hr JOIN hiking_members hm ON hm.hiking_record_id = hr.id JOIN mountains m ON m.id = hr.mountain_id - JOIN courses c ON c.id = hr.course_id + LEFT JOIN courses c ON c.id = hr.course_id WHERE hm.user_id = :userId AND hr.mountain_id = :mountainId ORDER BY hr.created_at DESC, hr.id DESC """, diff --git a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java index b65b6279..c8a997ea 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java @@ -2,14 +2,23 @@ import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest; +import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingMountainRecordResponse; import com.semosan.api.domain.hiking.dto.response.GetUserHikingRecordSummaryResponse; +import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback; +import com.semosan.api.domain.hiking.entity.HikingRecord; +import com.semosan.api.domain.hiking.repository.CourseDifficultyFeedbackRepository; +import com.semosan.api.domain.hiking.repository.HikingMemberRepository; import com.semosan.api.domain.hiking.repository.HikingRecordRepository; import com.semosan.api.domain.hiking.repository.projection.UserHikingRecordSummaryProjection; import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -19,9 +28,14 @@ @RequiredArgsConstructor public class HikingRecordService { + private static final String DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT = + "uk_course_difficulty_feedback_hiking_record"; + private final HikingRecordRepository hikingRecordRepository; private final UserReader userReader; private final MountainRepository mountainRepository; + private final HikingMemberRepository hikingMemberRepository; + private final CourseDifficultyFeedbackRepository courseDifficultyFeedbackRepository; // 유저가 다녀온 산 목록을 산 단위로 묶어 조회합니다. @Transactional(readOnly = true) @@ -65,4 +79,56 @@ public GetUserHikingRecordSummaryResponse getUserHikingRecordSummary(Long userId } return GetUserHikingRecordSummaryResponse.from(projection); } + + // 코스 기반 등산 기록에 대한 난이도 체감 피드백을 저장합니다. + @Transactional + public CourseDifficultyFeedbackResponse createCourseDifficultyFeedback( + Long userId, + Long hikingRecordId, + CreateCourseDifficultyFeedbackRequest request + ) { + User user = userReader.findCompletedOnboardingUserById(userId); + HikingRecord hikingRecord = hikingRecordRepository.findById(hikingRecordId) + .orElseThrow(() -> new GeneralException(ErrorStatus.HIKING_RECORD_NOT_FOUND)); + + if (!hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)) { + throw new GeneralException(ErrorStatus.HIKING_RECORD_FORBIDDEN); + } + if (hikingRecord.getCourse() == null) { + throw new GeneralException(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED); + } + if (courseDifficultyFeedbackRepository.existsByHikingRecord_Id(hikingRecordId)) { + throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); + } + + CourseDifficultyFeedback feedback = CourseDifficultyFeedback.create( + hikingRecord, + user, + request.comparison() + ); + try { + return CourseDifficultyFeedbackResponse.from(courseDifficultyFeedbackRepository.saveAndFlush(feedback)); + } catch (DataIntegrityViolationException e) { + if (isDifficultyFeedbackHikingRecordUniqueViolation(e)) { + throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); + } + throw e; + } + } + + private boolean isDifficultyFeedbackHikingRecordUniqueViolation(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) { + return true; + } + String message = current.getMessage(); + if (message != null && message.contains(DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) { + return true; + } + current = current.getCause(); + } + return false; + } } diff --git a/src/main/resources/db/migration/V19__create_course_difficulty_feedbacks.sql b/src/main/resources/db/migration/V19__create_course_difficulty_feedbacks.sql new file mode 100644 index 00000000..fdb1592d --- /dev/null +++ b/src/main/resources/db/migration/V19__create_course_difficulty_feedbacks.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS course_difficulty_feedbacks +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + hiking_record_id bigint not null + constraint uk_course_difficulty_feedback_hiking_record + unique + constraint fk_course_difficulty_feedback_hiking_record + references hiking_records, + user_id bigint not null + constraint fk_course_difficulty_feedback_user + references users, + course_id bigint not null + constraint fk_course_difficulty_feedback_course + references courses, + guide_difficulty varchar(20) not null + constraint course_difficulty_feedbacks_guide_difficulty_check + check ((guide_difficulty)::text = ANY + ((ARRAY ['EASY'::character varying, 'NORMAL'::character varying, 'HARD'::character varying])::text[])), + comparison varchar(20) not null + constraint course_difficulty_feedbacks_comparison_check + check ((comparison)::text = ANY + ((ARRAY ['SIMILAR'::character varying, 'EASIER'::character varying, 'HARDER'::character varying])::text[])) +); + +CREATE INDEX IF NOT EXISTS idx_course_difficulty_feedback_course_id + ON course_difficulty_feedbacks (course_id); + +CREATE INDEX IF NOT EXISTS idx_course_difficulty_feedback_user_id + ON course_difficulty_feedbacks (user_id); diff --git a/src/test/java/com/semosan/api/domain/hiking/service/HikingRecordServiceTest.java b/src/test/java/com/semosan/api/domain/hiking/service/HikingRecordServiceTest.java new file mode 100644 index 00000000..7e14ebe0 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/hiking/service/HikingRecordServiceTest.java @@ -0,0 +1,262 @@ +package com.semosan.api.domain.hiking.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.hiking.dto.request.CreateCourseDifficultyFeedbackRequest; +import com.semosan.api.domain.hiking.dto.response.CourseDifficultyFeedbackResponse; +import com.semosan.api.domain.hiking.entity.CourseDifficultyFeedback; +import com.semosan.api.domain.hiking.entity.HikingRecord; +import com.semosan.api.domain.hiking.enums.DifficultyFeedbackType; +import com.semosan.api.domain.hiking.repository.CourseDifficultyFeedbackRepository; +import com.semosan.api.domain.hiking.repository.HikingMemberRepository; +import com.semosan.api.domain.hiking.repository.HikingRecordRepository; +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.mountain.entity.Mountain; +import com.semosan.api.domain.mountain.enums.Difficulty; +import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.user.dto.command.CompleteOnboardingCommand; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.enums.user.Gender; +import com.semosan.api.domain.user.service.UserReader; +import org.hibernate.exception.ConstraintViolationException; +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.sql.SQLException; +import java.time.LocalDate; +import java.util.Optional; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HikingRecordServiceTest { + + @Mock + private HikingRecordRepository hikingRecordRepository; + + @Mock + private UserReader userReader; + + @Mock + private MountainRepository mountainRepository; + + @Mock + private HikingMemberRepository hikingMemberRepository; + + @Mock + private CourseDifficultyFeedbackRepository courseDifficultyFeedbackRepository; + + @InjectMocks + private HikingRecordService hikingRecordService; + + @Test + void createCourseDifficultyFeedbackSavesFeedbackForOwnedCourseRecord() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, course(mountain(20L, "관악산"), 30L, "과천향교 출발 코스")); + CreateCourseDifficultyFeedbackRequest request = + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.HARDER); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(true); + when(courseDifficultyFeedbackRepository.existsByHikingRecord_Id(10L)).thenReturn(false); + when(courseDifficultyFeedbackRepository.saveAndFlush(any(CourseDifficultyFeedback.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + CourseDifficultyFeedbackResponse response = hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + request + ); + + assertThat(response.hikingRecordId()).isEqualTo(10L); + assertThat(response.mountainId()).isEqualTo(20L); + assertThat(response.courseId()).isEqualTo(30L); + assertThat(response.guideDifficulty()).isEqualTo(Difficulty.NORMAL); + assertThat(response.comparison()).isEqualTo(DifficultyFeedbackType.HARDER); + verify(courseDifficultyFeedbackRepository).saveAndFlush(any(CourseDifficultyFeedback.class)); + } + + @Test + void createCourseDifficultyFeedbackThrowsWhenRecordIsNotOwnedByUser() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, course(mountain(20L, "관악산"), 30L, "과천향교 출발 코스")); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(false); + + assertThatThrownBy(() -> hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.SIMILAR) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.HIKING_RECORD_FORBIDDEN); + verify(courseDifficultyFeedbackRepository, never()).saveAndFlush(any()); + } + + @Test + void createCourseDifficultyFeedbackThrowsWhenRecordHasNoCourse() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, null); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(true); + + assertThatThrownBy(() -> hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.EASIER) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED); + verify(courseDifficultyFeedbackRepository, never()).saveAndFlush(any()); + } + + @Test + void createCourseDifficultyFeedbackThrowsWhenFeedbackAlreadyExists() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, course(mountain(20L, "관악산"), 30L, "과천향교 출발 코스")); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(true); + when(courseDifficultyFeedbackRepository.existsByHikingRecord_Id(10L)).thenReturn(true); + + assertThatThrownBy(() -> hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.SIMILAR) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); + verify(courseDifficultyFeedbackRepository, never()).saveAndFlush(any()); + } + + @Test + void createCourseDifficultyFeedbackThrowsConflictWhenConcurrentDuplicateSaveOccurs() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, course(mountain(20L, "관악산"), 30L, "과천향교 출발 코스")); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(true); + when(courseDifficultyFeedbackRepository.existsByHikingRecord_Id(10L)).thenReturn(false); + when(courseDifficultyFeedbackRepository.saveAndFlush(any(CourseDifficultyFeedback.class))) + .thenThrow(duplicateFeedbackDataIntegrityViolation()); + + assertThatThrownBy(() -> hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.SIMILAR) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); + } + + @Test + void createCourseDifficultyFeedbackRethrowsUnrelatedDataIntegrityViolation() throws Exception { + User user = user(1L); + HikingRecord hikingRecord = hikingRecord(10L, course(mountain(20L, "관악산"), 30L, "과천향교 출발 코스")); + DataIntegrityViolationException exception = new DataIntegrityViolationException("other constraint"); + + when(userReader.findCompletedOnboardingUserById(1L)).thenReturn(user); + when(hikingRecordRepository.findById(10L)).thenReturn(Optional.of(hikingRecord)); + when(hikingMemberRepository.existsByHikingRecordAndUser(hikingRecord, user)).thenReturn(true); + when(courseDifficultyFeedbackRepository.existsByHikingRecord_Id(10L)).thenReturn(false); + when(courseDifficultyFeedbackRepository.saveAndFlush(any(CourseDifficultyFeedback.class))) + .thenThrow(exception); + + assertThatThrownBy(() -> hikingRecordService.createCourseDifficultyFeedback( + 1L, + 10L, + new CreateCourseDifficultyFeedbackRequest(DifficultyFeedbackType.SIMILAR) + )) + .isSameAs(exception); + } + + @Test + void courseDifficultyFeedbackCreateThrowsWhenRecordHasNoCourse() throws Exception { + HikingRecord hikingRecord = hikingRecord(10L, null); + + assertThatThrownBy(() -> CourseDifficultyFeedback.create( + hikingRecord, + user(1L), + DifficultyFeedbackType.SIMILAR + )) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.HIKING_RECORD_COURSE_REQUIRED); + } + + private DataIntegrityViolationException duplicateFeedbackDataIntegrityViolation() { + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate hiking_record_id", + new SQLException("unique violation"), + "uk_course_difficulty_feedback_hiking_record" + ); + return new DataIntegrityViolationException("duplicate hiking_record_id", cause); + } + + private User user(Long id) { + User user = User.createTestUser("hiking-record-service-test-user", DeviceType.IOS); + user.completeOnboarding(new CompleteOnboardingCommand( + "테스트", + null, + LocalDate.of(1990, 1, 1), + Gender.MALE, + 175.0, + 70.0 + )); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Mountain mountain(Long id, String name) throws Exception { + Constructor constructor = Mountain.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Mountain mountain = constructor.newInstance(); + ReflectionTestUtils.setField(mountain, "id", id); + ReflectionTestUtils.setField(mountain, "name", name); + return mountain; + } + + private Course course(Mountain mountain, Long id, String name) throws Exception { + Constructor constructor = Course.class.getDeclaredConstructor(); + constructor.setAccessible(true); + Course course = constructor.newInstance(); + ReflectionTestUtils.setField(course, "id", id); + ReflectionTestUtils.setField(course, "mountain", mountain); + ReflectionTestUtils.setField(course, "name", name); + ReflectionTestUtils.setField(course, "difficulty", Difficulty.NORMAL); + return course; + } + + private HikingRecord hikingRecord(Long id, Course course) throws Exception { + Constructor constructor = HikingRecord.class.getDeclaredConstructor(); + constructor.setAccessible(true); + HikingRecord hikingRecord = constructor.newInstance(); + ReflectionTestUtils.setField(hikingRecord, "id", id); + ReflectionTestUtils.setField(hikingRecord, "course", course); + ReflectionTestUtils.setField(hikingRecord, "mountain", course == null ? mountain(20L, "자유기록산") : course.getMountain()); + return hikingRecord; + } +}