diff --git a/src/main/java/com/cotato/cokerthon/domain/route/service/ReturnRouteService.java b/src/main/java/com/cotato/cokerthon/domain/route/service/ReturnRouteService.java index ff5e1bb..c902e70 100644 --- a/src/main/java/com/cotato/cokerthon/domain/route/service/ReturnRouteService.java +++ b/src/main/java/com/cotato/cokerthon/domain/route/service/ReturnRouteService.java @@ -26,6 +26,8 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ThreadLocalRandom; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +35,7 @@ @Transactional(readOnly = true) public class ReturnRouteService { + private static final Logger log = LoggerFactory.getLogger(ReturnRouteService.class); private static final int DAILY_ADJUST_MINUTES = 30; private final ReturnRouteRepository returnRouteRepository; @@ -63,7 +66,7 @@ public ReturnRouteService(ReturnRouteRepository returnRouteRepository, public ReturnRouteResponse createReturnRoute(Long memberId, Long resultId) { Member member = getMember(memberId); SleepJetlagResult result = sleepJetlagResultRepository.findById(resultId) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.SLEEP_RESULT_NOT_FOUND)); validateOwner(member, result); returnRouteRepository.findAllByMemberAndStatus(member, ReturnRouteStatus.IN_PROGRESS) @@ -103,7 +106,7 @@ public ReturnRouteResponse createReturnRoute(Long memberId, Long resultId) { public ReturnRouteResponse getCurrentRoute(Long memberId) { ReturnRoute route = getCurrentRouteEntity(memberId); List days = returnRouteDayRepository.findAllByReturnRouteOrderByDayNumberAsc(route); - ReturnRouteDay currentDay = getCurrentDayOrNull(route); + ReturnRouteDay currentDay = getCurrentDay(route); return ReturnRouteResponse.of(route, getDepartureCity(route), currentDay, days); } @@ -181,7 +184,10 @@ private City pickRandomCity(int signedGapMinutes) { candidates = cityRepository.findAllByDirectionAndGapMinutes(direction, gapMinutes); if (candidates.isEmpty()) { - throw new BusinessException(ErrorCode.NOT_FOUND); + String message = "귀국 루트 경유 도시 후보를 찾을 수 없습니다. direction=%s, gapMinutes=%d" + .formatted(direction, gapMinutes); + log.warn(message); + throw new BusinessException(ErrorCode.RETURN_ROUTE_CITY_NOT_FOUND, message); } return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); @@ -199,24 +205,19 @@ private CityDirection toDirection(int signedGapMinutes) { private City getSeoulCity() { return cityRepository.findFirstByDirectionOrderByDisplayOrderAsc(CityDirection.BASE) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.SEOUL_CITY_NOT_FOUND)); } private ReturnRoute getCurrentRouteEntity(Long memberId) { Member member = getMember(memberId); return returnRouteRepository.findFirstByMemberAndStatusOrderByCreatedAtDesc(member, ReturnRouteStatus.IN_PROGRESS) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.RETURN_ROUTE_NOT_FOUND)); } private ReturnRouteDay getCurrentDay(ReturnRoute route) { return returnRouteDayRepository.findByReturnRouteAndDayNumber(route, route.getCurrentDayNumber()) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); - } - - private ReturnRouteDay getCurrentDayOrNull(ReturnRoute route) { - return returnRouteDayRepository.findByReturnRouteAndDayNumber(route, route.getCurrentDayNumber()) - .orElse(null); + .orElseThrow(() -> new BusinessException(ErrorCode.RETURN_ROUTE_DAY_NOT_FOUND)); } private CitySummaryResponse getDepartureCity(ReturnRoute route) { @@ -226,7 +227,7 @@ private CitySummaryResponse getDepartureCity(ReturnRoute route) { ReturnRouteDay previousDay = returnRouteDayRepository.findByReturnRouteAndDayNumber(route, route.getCurrentDayNumber() - 1) - .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND)); + .orElseThrow(() -> new BusinessException(ErrorCode.RETURN_ROUTE_DAY_NOT_FOUND)); return CitySummaryResponse.from(previousDay.getCheckpointCity()); } @@ -237,7 +238,7 @@ private Member getMember(Long memberId) { private void validateOwner(Member member, SleepJetlagResult result) { if (!result.getMember().getId().equals(member.getId())) { - throw new BusinessException(ErrorCode.NOT_FOUND); + throw new BusinessException(ErrorCode.SLEEP_RESULT_FORBIDDEN); } } diff --git a/src/main/java/com/cotato/cokerthon/global/exception/BusinessException.java b/src/main/java/com/cotato/cokerthon/global/exception/BusinessException.java index 11a06dd..1c4c775 100644 --- a/src/main/java/com/cotato/cokerthon/global/exception/BusinessException.java +++ b/src/main/java/com/cotato/cokerthon/global/exception/BusinessException.java @@ -9,6 +9,11 @@ public BusinessException(ErrorCode errorCode) { this.errorCode = errorCode; } + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + public ErrorCode getErrorCode() { return errorCode; } diff --git a/src/main/java/com/cotato/cokerthon/global/exception/ErrorCode.java b/src/main/java/com/cotato/cokerthon/global/exception/ErrorCode.java index d97c879..c063774 100644 --- a/src/main/java/com/cotato/cokerthon/global/exception/ErrorCode.java +++ b/src/main/java/com/cotato/cokerthon/global/exception/ErrorCode.java @@ -8,6 +8,12 @@ public enum ErrorCode { INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_401_001", "아이디 또는 비밀번호가 올바르지 않습니다."), DUPLICATED_LOGIN_ID(HttpStatus.CONFLICT, "MEMBER_409_001", "이미 사용 중인 아이디입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청한 리소스를 찾을 수 없습니다."), + SLEEP_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "SLEEP_RESULT_NOT_FOUND", "수면시차 계산 결과를 찾을 수 없습니다."), + SLEEP_RESULT_FORBIDDEN(HttpStatus.FORBIDDEN, "SLEEP_RESULT_FORBIDDEN", "현재 로그인한 사용자의 수면시차 계산 결과가 아닙니다."), + RETURN_ROUTE_CITY_NOT_FOUND(HttpStatus.NOT_FOUND, "RETURN_ROUTE_CITY_NOT_FOUND", "귀국 루트 경유 도시 후보를 찾을 수 없습니다."), + SEOUL_CITY_NOT_FOUND(HttpStatus.NOT_FOUND, "SEOUL_CITY_NOT_FOUND", "서울(BASE) 도시 데이터를 찾을 수 없습니다."), + RETURN_ROUTE_NOT_FOUND(HttpStatus.NOT_FOUND, "RETURN_ROUTE_NOT_FOUND", "진행 중인 귀국 루트를 찾을 수 없습니다."), + RETURN_ROUTE_DAY_NOT_FOUND(HttpStatus.NOT_FOUND, "RETURN_ROUTE_DAY_NOT_FOUND", "현재 진행 일차의 귀국 루트 데이터를 찾을 수 없습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 오류가 발생했습니다."), CITY_NOT_MATCHED(HttpStatus.INTERNAL_SERVER_ERROR, "SLEEP_500_001", "시차에 매칭되는 도시를 찾을 수 없습니다."); diff --git a/src/main/java/com/cotato/cokerthon/global/exception/GlobalExceptionHandler.java b/src/main/java/com/cotato/cokerthon/global/exception/GlobalExceptionHandler.java index 1837b66..e8feff4 100644 --- a/src/main/java/com/cotato/cokerthon/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cotato/cokerthon/global/exception/GlobalExceptionHandler.java @@ -14,7 +14,7 @@ public ResponseEntity handleBusinessException(BusinessException e ErrorCode errorCode = exception.getErrorCode(); return ResponseEntity .status(errorCode.getStatus()) - .body(ErrorResponse.of(errorCode.getCode(), errorCode.getMessage())); + .body(ErrorResponse.of(errorCode.getCode(), exception.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/src/test/java/com/cotato/cokerthon/domain/route/service/ReturnRouteServiceTest.java b/src/test/java/com/cotato/cokerthon/domain/route/service/ReturnRouteServiceTest.java new file mode 100644 index 0000000..bff8092 --- /dev/null +++ b/src/test/java/com/cotato/cokerthon/domain/route/service/ReturnRouteServiceTest.java @@ -0,0 +1,216 @@ +package com.cotato.cokerthon.domain.route.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.cotato.cokerthon.domain.city.entity.City; +import com.cotato.cokerthon.domain.city.entity.CityDirection; +import com.cotato.cokerthon.domain.city.repository.CityRepository; +import com.cotato.cokerthon.domain.member.entity.Member; +import com.cotato.cokerthon.domain.member.repository.MemberRepository; +import com.cotato.cokerthon.domain.route.entity.ReturnRoute; +import com.cotato.cokerthon.domain.route.entity.ReturnRouteStatus; +import com.cotato.cokerthon.domain.route.repository.ReturnRouteDayRepository; +import com.cotato.cokerthon.domain.route.repository.ReturnRouteRepository; +import com.cotato.cokerthon.domain.route.repository.ReturnRouteStopRepository; +import com.cotato.cokerthon.domain.sleep.entity.JetlagDirection; +import com.cotato.cokerthon.domain.sleep.entity.SleepJetlagResult; +import com.cotato.cokerthon.domain.sleep.entity.SleepRecord; +import com.cotato.cokerthon.domain.sleep.repository.SleepJetlagResultRepository; +import com.cotato.cokerthon.global.exception.BusinessException; +import com.cotato.cokerthon.global.exception.ErrorCode; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ReturnRouteServiceTest { + + private static final Long MEMBER_ID = 1L; + private static final Long RESULT_ID = 10L; + + @Mock + private ReturnRouteRepository returnRouteRepository; + + @Mock + private ReturnRouteDayRepository returnRouteDayRepository; + + @Mock + private ReturnRouteStopRepository returnRouteStopRepository; + + @Mock + private SleepJetlagResultRepository sleepJetlagResultRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private CityRepository cityRepository; + + @Mock + private SleepTimeCalculator sleepTimeCalculator; + + private ReturnRouteService returnRouteService; + + @BeforeEach + void setUp() { + returnRouteService = new ReturnRouteService( + returnRouteRepository, + returnRouteDayRepository, + returnRouteStopRepository, + sleepJetlagResultRepository, + memberRepository, + cityRepository, + sleepTimeCalculator + ); + } + + @Test + void createReturnRouteThrowsSleepResultNotFoundWhenResultDoesNotExist() { + Member member = member(MEMBER_ID); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(sleepJetlagResultRepository.findById(RESULT_ID)).thenReturn(Optional.empty()); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.createReturnRoute(MEMBER_ID, RESULT_ID)) + .satisfies(exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SLEEP_RESULT_NOT_FOUND)); + } + + @Test + void createReturnRouteThrowsSleepResultForbiddenWhenResultBelongsToOtherMember() { + Member member = member(MEMBER_ID); + SleepJetlagResult result = sleepJetlagResult(member(2L)); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(sleepJetlagResultRepository.findById(RESULT_ID)).thenReturn(Optional.of(result)); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.createReturnRoute(MEMBER_ID, RESULT_ID)) + .satisfies(exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SLEEP_RESULT_FORBIDDEN)); + } + + @Test + void createReturnRouteThrowsReturnRouteCityNotFoundWithDirectionAndGapMinutesWhenNoCandidateExists() { + Member member = member(MEMBER_ID); + SleepJetlagResult result = sleepJetlagResult(member); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(sleepJetlagResultRepository.findById(RESULT_ID)).thenReturn(Optional.of(result)); + when(returnRouteRepository.findAllByMemberAndStatus(member, ReturnRouteStatus.IN_PROGRESS)) + .thenReturn(List.of()); + when(sleepTimeCalculator.reached(any(), any(), any(), any())).thenReturn(false); + when(sleepTimeCalculator.moveTowardTarget(LocalTime.of(3, 0), LocalTime.of(23, 0))) + .thenReturn(LocalTime.of(2, 30)); + when(sleepTimeCalculator.moveTowardTarget(LocalTime.of(11, 0), LocalTime.of(7, 0))) + .thenReturn(LocalTime.of(10, 30)); + when(sleepTimeCalculator.calculateMidTime(LocalTime.of(2, 30), LocalTime.of(10, 30))) + .thenReturn(LocalTime.of(6, 0)); + when(sleepTimeCalculator.signedMidDifferenceMinutes(LocalTime.of(3, 0), LocalTime.of(6, 0))) + .thenReturn(180); + when(cityRepository.findAllByDirectionAndGapMinutes(CityDirection.WEST, 180)).thenReturn(List.of()); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.createReturnRoute(MEMBER_ID, RESULT_ID)) + .satisfies(exception -> { + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.RETURN_ROUTE_CITY_NOT_FOUND); + assertThat(exception.getMessage()).contains("direction=WEST", "gapMinutes=180"); + }); + } + + @Test + void createReturnRouteThrowsSeoulCityNotFoundWhenBaseCityDoesNotExist() { + Member member = member(MEMBER_ID); + SleepJetlagResult result = sleepJetlagResult(member); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(sleepJetlagResultRepository.findById(RESULT_ID)).thenReturn(Optional.of(result)); + when(returnRouteRepository.findAllByMemberAndStatus(member, ReturnRouteStatus.IN_PROGRESS)) + .thenReturn(List.of()); + when(sleepTimeCalculator.reached(any(), any(), any(), any())).thenReturn(true); + when(cityRepository.findFirstByDirectionOrderByDisplayOrderAsc(CityDirection.BASE)) + .thenReturn(Optional.empty()); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.createReturnRoute(MEMBER_ID, RESULT_ID)) + .satisfies(exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.SEOUL_CITY_NOT_FOUND)); + } + + @Test + void getCurrentRouteThrowsReturnRouteNotFoundWhenNoRouteIsInProgress() { + Member member = member(MEMBER_ID); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(returnRouteRepository.findFirstByMemberAndStatusOrderByCreatedAtDesc(member, + ReturnRouteStatus.IN_PROGRESS)).thenReturn(Optional.empty()); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.getCurrentRoute(MEMBER_ID)) + .satisfies(exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.RETURN_ROUTE_NOT_FOUND)); + } + + @Test + void getCurrentBoardingPassThrowsReturnRouteDayNotFoundWhenCurrentDayDoesNotExist() { + Member member = member(MEMBER_ID); + ReturnRoute route = ReturnRoute.create(member, sleepJetlagResult(member), 30, 3); + when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)); + when(returnRouteRepository.findFirstByMemberAndStatusOrderByCreatedAtDesc(member, + ReturnRouteStatus.IN_PROGRESS)).thenReturn(Optional.of(route)); + when(returnRouteDayRepository.findByReturnRouteAndDayNumber(route, 1)).thenReturn(Optional.empty()); + + assertThatExceptionOfType(BusinessException.class) + .isThrownBy(() -> returnRouteService.getCurrentBoardingPass(MEMBER_ID)) + .satisfies(exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.RETURN_ROUTE_DAY_NOT_FOUND)); + } + + private Member member(Long id) { + Member member = Member.create("member" + id, "password", "nickname" + id); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private SleepJetlagResult sleepJetlagResult(Member member) { + SleepRecord sleepRecord = SleepRecord.create( + member, + LocalTime.of(3, 0), + LocalTime.of(11, 0), + 480, + LocalTime.of(23, 0), + LocalTime.of(7, 0), + 480 + ); + return SleepJetlagResult.create( + sleepRecord, + member, + LocalTime.of(7, 0), + LocalTime.of(3, 0), + 240, + JetlagDirection.WEST, + city(CityDirection.WEST), + LocalDate.of(2026, 7, 11) + ); + } + + private City city(CityDirection direction) { + return City.create( + "대한민국", + "서울", + "SEOUL", + "ICN", + "+09:00", + "Asia/Seoul", + direction, + 0, + 0, + 30, + false, + 1, + 37.5665, + 126.9780 + ); + } +}