Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@
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;

@Service
@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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -103,7 +106,7 @@ public ReturnRouteResponse createReturnRoute(Long memberId, Long resultId) {
public ReturnRouteResponse getCurrentRoute(Long memberId) {
ReturnRoute route = getCurrentRouteEntity(memberId);
List<ReturnRouteDay> days = returnRouteDayRepository.findAllByReturnRouteOrderByDayNumberAsc(route);
ReturnRouteDay currentDay = getCurrentDayOrNull(route);
ReturnRouteDay currentDay = getCurrentDay(route);

return ReturnRouteResponse.of(route, getDepartureCity(route), currentDay, days);
}
Expand Down Expand Up @@ -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()));
Expand All @@ -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) {
Expand All @@ -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());
}

Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "시차에 매칭되는 도시를 찾을 수 없습니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public ResponseEntity<ErrorResponse> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading