diff --git a/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java b/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java index d2a85b85..03e3c197 100644 --- a/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java +++ b/src/main/java/com/sudo/railo/train/application/TrainSearchApplicationService.java @@ -2,15 +2,19 @@ import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.sudo.railo.train.application.dto.TrainScheduleBasicInfo; import com.sudo.railo.train.application.dto.request.TrainCarListRequest; import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.OperationCalendarItem; import com.sudo.railo.train.application.dto.response.TrainCarInfo; import com.sudo.railo.train.application.dto.response.TrainCarListResponse; import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +28,24 @@ public class TrainSearchApplicationService { private final TrainSearchService trainSearchService; private final TrainSeatQueryService trainCarService; + /** + * 운행 캘린더 조회 + */ + public List getOperationCalendar() { + return trainSearchService.getOperationCalendar(); + } + + /** + * 통합 열차 조회 (열차 스케줄 검색) + */ + public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pageable pageable) { + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable); + + log.info("열차 검색 완료: {} 건 조회, hasNext: {}", response.numberOfElements(), response.hasNext()); + + return response; + } + /** * 열차 객차 목록 조회 (잔여 좌석이 있는 객차만) */ @@ -47,6 +69,7 @@ public TrainCarListResponse getAvailableTrainCars(TrainCarListRequest request) { scheduleInfo.trainClassificationCode(), scheduleInfo.trainNumber()); return TrainCarListResponse.of( + request.trainScheduleId(), recommendedCarNumber, availableCars.size(), scheduleInfo.trainClassificationCode(), @@ -73,12 +96,17 @@ public TrainCarSeatDetailResponse getTrainCarSeatDetail(TrainCarSeatDetailReques * TODO: 조금 더 고도화된 객차 추천 알고리즘 필요 */ private String selectRecommendedCar(List availableCars, int passengerCount) { - // 승객 수보다 잔여 좌석이 많은 객차 중에서 중간 위치 선택 - return availableCars.stream() + // 승객 수보다 잔여 좌석이 많은 객차 필터링 + List suitableCars = availableCars.stream() .filter(car -> car.remainingSeats() >= passengerCount) - .skip(availableCars.size() / 2) // 중간 객차 선택 - .findFirst() - .map(TrainCarInfo::carNumber) - .orElse(availableCars.get(0).carNumber()); // 없으면 첫 번째 객차 + .toList(); + + // 적합한 객차가 있으면 중간 위치 선택, 없으면 첫 번째 객차 + if (!suitableCars.isEmpty()) { + int middleIndex = suitableCars.size() / 2; + return suitableCars.get(middleIndex).carNumber(); + } + + return availableCars.get(0).carNumber(); } } diff --git a/src/main/java/com/sudo/railo/train/application/TrainSearchService.java b/src/main/java/com/sudo/railo/train/application/TrainSearchService.java index 1e3b3644..79fab6a3 100644 --- a/src/main/java/com/sudo/railo/train/application/TrainSearchService.java +++ b/src/main/java/com/sudo/railo/train/application/TrainSearchService.java @@ -60,7 +60,7 @@ public class TrainSearchService { /** * 운행 캘린더 조회 - * @return + * @return List */ public List getOperationCalendar() { LocalDate startDate = LocalDate.now(); @@ -78,7 +78,7 @@ public List getOperationCalendar() { }) .toList(); - log.info("운행 캘린더 조회 완료: {} ~ {} ({} 일), 운행일수: {}", + log.info("운행 캘린더 조회 : {} ~ {} ({} 일), 운행일수: {}", startDate, endDate, calendar.size(), datesWithSchedule.size()); return calendar; @@ -140,7 +140,7 @@ public TrainSearchSlicePageResponse searchTrains(TrainSearchRequest request, Pag List trainSearchResults = processTrainSearchResults(trainInfoSlice.getContent(), fare, request); - log.info("Slice 기반 열차 조회 완료: {}건 조회, hasNext: {}", trainSearchResults.size(), trainInfoSlice.hasNext()); + log.info("Slice 기반 열차 조회: {}건 조회, hasNext: {}", trainSearchResults.size(), trainInfoSlice.hasNext()); return createTrainSearchPageResponse(trainSearchResults, trainInfoSlice); } @@ -281,8 +281,9 @@ private TrainSearchSlicePageResponse createTrainSearchPageResponse(List= passengerCount + 20) { - return SeatAvailabilityStatus.AVAILABLE; + double availabilityRatio = (double)availableSeats / totalSeats; + + if (availabilityRatio >= 0.25) { + return SeatAvailabilityStatus.AVAILABLE; // 25% 이상이면 여유 } else { return SeatAvailabilityStatus.LIMITED; } diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java b/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java index eb243f36..c29d8020 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/TrainCarListResponse.java @@ -10,6 +10,9 @@ @Schema(description = "열차 객차 목록 조회 응답") public record TrainCarListResponse( + @Schema(description = "열차 스케줄 ID", example = "26") + Long trainScheduleId, + @Schema(description = "AI가 추천하는 최적 객차 번호 (승객수, 위치 고려)", example = "14") String recommendedCarNumber, @@ -25,10 +28,10 @@ public record TrainCarListResponse( @Schema(description = "좌석 선택 가능한 객차 정보 목록") List carInfos ) { - public static TrainCarListResponse of(String recommendedCarNumber, int totalCarCount, + public static TrainCarListResponse of(Long trainScheduleId, String recommendedCarNumber, int totalCarCount, String trainClassificationCode, String trainNumber, List carInfos) { - return new TrainCarListResponse(recommendedCarNumber, totalCarCount, + return new TrainCarListResponse(trainScheduleId, recommendedCarNumber, totalCarCount, trainClassificationCode, trainNumber, carInfos); } } diff --git a/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java b/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java index 53291da1..8e7b9a53 100644 --- a/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java +++ b/src/main/java/com/sudo/railo/train/application/dto/response/TrainSearchResponse.java @@ -98,7 +98,7 @@ private static void validateTrainSearchData(Long trainScheduleId, String trainNu /** * 입석 정보 존재 여부 */ - public boolean hasStanding() { + public boolean hasStandingInfo() { return standing != null; } diff --git a/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java b/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java index 7f98cdd8..bdbe03c9 100644 --- a/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java +++ b/src/main/java/com/sudo/railo/train/application/validator/TrainSearchValidator.java @@ -33,6 +33,9 @@ private void validateOperationDate(TrainSearchRequest request) { } } + /** + * 예약 날짜가 오늘(date)이면, 시(hour) 단위로만 비교 + */ private void validateDepartureTime(TrainSearchRequest request) { if (request.operationDate().equals(LocalDate.now())) { int requestHour = Integer.parseInt(request.departureHour()); diff --git a/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java b/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java index ec233fff..9d347222 100644 --- a/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java +++ b/src/main/java/com/sudo/railo/train/exception/TrainErrorCode.java @@ -15,7 +15,7 @@ public enum TrainErrorCode implements ErrorCode { TRAIN_SCHEDULE_NOT_FOUND("해당 날짜에 운행하는 열차가 없습니다.", HttpStatus.NOT_FOUND, "T4001"), TRAIN_OPERATION_CANCELLED("해당 열차는 운행이 취소되었습니다.", HttpStatus.BAD_REQUEST, "T4002"), TRAIN_OPERATION_DELAYED("해당 열차는 지연 운행 중입니다.", HttpStatus.BAD_REQUEST, "T4003"), - TRAIN_SCHEDULE_DETAIL_NOT_FOUND("요청하신 열차 스케줄이 존재하지 않습니다.", HttpStatus.NOT_FOUND, "T4004"), + TRAIN_SCHEDULE_DETAIL_NOT_FOUND("해당 열차 스케줄을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "T4004"), NO_AVAILABLE_CARS("잔여 좌석이 있는 객차가 없습니다.", HttpStatus.NOT_FOUND, "TR_4005"), TRAIN_CAR_NOT_FOUND("해당 객차를 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "TR_4006"), diff --git a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java index e3e32bc5..379792aa 100644 --- a/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java +++ b/src/main/java/com/sudo/railo/train/infrastructure/TrainCarRepository.java @@ -11,4 +11,6 @@ public interface TrainCarRepository extends JpaRepository { List findByTrainIn(Collection trains); + + List findAllByTrainId(Long trainId); } diff --git a/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java b/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java index a415b9ad..2bdd3a00 100644 --- a/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java +++ b/src/main/java/com/sudo/railo/train/presentation/TrainSearchController.java @@ -46,7 +46,7 @@ public class TrainSearchController { @Operation(summary = "운행 캘린더 조회", description = "금일로부터 한 달간의 운행 캘린더를 조회합니다.") public SuccessResponse> getOperationCalendar() { log.info("운행 캘린더 조회"); - List calendar = trainSearchService.getOperationCalendar(); + List calendar = trainSearchApplicationService.getOperationCalendar(); log.info("운행 캘린더 조회: {} 건", calendar.size()); return SuccessResponse.of(TrainSearchSuccess.OPERATION_CALENDAR_SUCCESS, calendar); @@ -70,7 +70,7 @@ public SuccessResponse searchTrainSchedules( request.operationDate(), request.passengerCount(), request.departureHour(), pageable.getPageNumber(), pageable.getPageSize()); - TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable); + TrainSearchSlicePageResponse response = trainSearchApplicationService.searchTrains(request, pageable); return SuccessResponse.of(TrainSearchSuccess.TRAIN_SEARCH_SUCCESS, response); } diff --git a/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java b/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java index f167392a..e96c362a 100644 --- a/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java +++ b/src/test/java/com/sudo/railo/support/helper/ReservationTestHelper.java @@ -6,6 +6,7 @@ import java.util.List; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.sudo.railo.booking.domain.Reservation; import com.sudo.railo.booking.domain.SeatReservation; @@ -20,7 +21,6 @@ import com.sudo.railo.train.domain.Train; import com.sudo.railo.train.domain.type.CarType; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @Component @@ -35,7 +35,8 @@ public class ReservationTestHelper { /** * 기본 예약 생성 메서드 */ - public Reservation createReservation(Member member, TrainScheduleWithStopStations scheduleWithStops) { + public Reservation createReservation(Member member, + TrainScheduleWithStopStations scheduleWithStops) { Reservation reservation = Reservation.builder() .trainSchedule(scheduleWithStops.trainSchedule()) .member(member) @@ -55,6 +56,78 @@ public Reservation createReservation(Member member, TrainScheduleWithStopStation return reservation; } + /** + * 특정 좌석 ID들에 대한 좌석 예약 생성 (출발, 도착 정차역 직접 지정) + * + * @param member 예약할 회원 + * @param scheduleWithStops 열차 스케줄 및 정차역 정보 + * @param departureStop 출발 정차역 + * @param arrivalStop 도착 정차역 + * @param seatIds 예약할 좌석 ID 목록 + * @param passengerType 승객 유형 (성인, 어린이 등) + * @return 생성된 Reservation 객체 + */ + public Reservation createReservationWithSeatIds(Member member, + TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + List seatIds, + PassengerType passengerType) { + + Reservation reservation = Reservation.builder() + .trainSchedule(scheduleWithStops.trainSchedule()) + .member(member) + .reservationCode("SEAT-" + System.currentTimeMillis()) + .tripType(TripType.OW) + .totalPassengers(seatIds.size()) + .passengerSummary("[{\"passengerType\":\"" + passengerType.name() + "\",\"count\":" + seatIds.size() + "}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare(50000 * seatIds.size()) + .departureStop(departureStop) + .arrivalStop(arrivalStop) + .build(); + + reservationRepository.save(reservation); + createSeatReservations(reservation, seatIds, passengerType); + return reservation; + } + + /** + * 지정한 인원 수만큼 입석 예약 생성 (출발, 도착 정차역 직접 지정, seat 없이) + * + * @param member 예약할 회원 + * @param scheduleWithStops 열차 스케줄 및 정차역 정보 + * @param departureStop 출발 정차역 + * @param arrivalStop 도착 정차역 + * @param standingCount 입석 인원 수 + * @return 생성된 Reservation 객체 + */ + public Reservation createStandingReservation(Member member, + TrainScheduleWithStopStations scheduleWithStops, + ScheduleStop departureStop, + ScheduleStop arrivalStop, + int standingCount) { + + Reservation reservation = Reservation.builder() + .trainSchedule(scheduleWithStops.trainSchedule()) + .member(member) + .reservationCode("STANDING-" + System.currentTimeMillis()) + .tripType(TripType.OW) + .totalPassengers(standingCount) + .passengerSummary("[{\"passengerType\":\"ADULT\",\"count\":" + standingCount + "}]") + .reservationStatus(ReservationStatus.RESERVED) + .expiresAt(LocalDateTime.now().plusMinutes(10)) + .fare((int)(50000 * 0.85) * standingCount) + .departureStop(departureStop) + .arrivalStop(arrivalStop) + .build(); + + reservationRepository.save(reservation); + createStandingSeatReservations(reservation, standingCount); + return reservation; + } + /** * 좌석 예약 생성 메서드 */ @@ -74,6 +147,41 @@ private void createSeatReservation(Reservation reservation) { seatReservationRepository.saveAll(seatReservations); } + /** + * 주어진 좌석 ID들로 SeatReservation 생성 + */ + private void createSeatReservations(Reservation reservation, List seatIds, PassengerType passengerType) { + List seats = trainTestHelper.getSeatsByIds(seatIds); + + List seatReservations = seats.stream() + .map(seat -> SeatReservation.builder() + .trainSchedule(reservation.getTrainSchedule()) + .seat(seat) + .reservation(reservation) + .passengerType(passengerType) + .build()) + .toList(); + + seatReservationRepository.saveAll(seatReservations); + } + + /** + * Seat=null로 승객수만큼 입석 SeatReservations 생성 + */ + private void createStandingSeatReservations(Reservation reservation, int count) { + List seatReservations = + java.util.stream.IntStream.range(0, count) + .mapToObj(i -> SeatReservation.builder() + .trainSchedule(reservation.getTrainSchedule()) + .seat(null) // 입석 처리 + .reservation(reservation) + .passengerType(PassengerType.ADULT) + .build()) + .toList(); + + seatReservationRepository.saveAll(seatReservations); + } + private ScheduleStop getDepartureStop(List scheduleStops) { if (scheduleStops.isEmpty()) { throw new IllegalArgumentException("출발역을 찾을 수 없습니다."); diff --git a/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java b/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java index 1bd4b709..7422bef7 100644 --- a/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java +++ b/src/test/java/com/sudo/railo/support/helper/TrainTestHelper.java @@ -1,5 +1,6 @@ package com.sudo.railo.support.helper; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -24,8 +25,10 @@ import com.sudo.railo.train.infrastructure.TrainRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Component +@Slf4j @RequiredArgsConstructor public class TrainTestHelper { @@ -43,6 +46,26 @@ public Train createKTX() { return createCustomKTX(1, 1); } + /** + * 테스트용 소형 열차 생성 + * 객차 총 2개 (standard 1개 + firstClass 1개) + * 좌석 총 4개 (standard 24개 + firstClass 12개) + */ + @Transactional + public Train createSmallTestTrain() { + return createRealisticTrain(1, 1, 6, 4); + } + + /** + * 테스트용 중형 열차 생성 + * 객차 총 5개 (standard 3개 + firstClass 2개) + * 좌석 총 192개 (standard 144개 + firstClass 48개) + */ + @Transactional + public Train createMediumTestTrain() { + return createRealisticTrain(3, 2, 12, 8); + } + /** * 커스텀 기차 생성 메서드 * 객차 총 2개 (standard 1개 + firstClass 1개) @@ -61,6 +84,44 @@ public Train createCustomKTX(int standardRows, int firstRows) { return saveTrainWithCarsAndSeats(savedTrain, carSpecs); } + /** + * 고급 열차 생성 메서드 - 객차 개수와 각 객차별 행 수를 모두 설정 가능 + * + * @param standardCarCount 일반실 객차 수 + * @param firstClassCarCount 특실 객차 수 + * @param standardRowsPerCar 일반실 객차당 행 수 (각 행은 4석 - 2+2 배치) + * @param firstClassRowsPerCar 특실 객차당 행 수 (각 행은 3석 - 2+1 배치) + * @return 생성된 열차 + * + * 예시: + * - createAdvancedTrain(2, 1, 10, 6) → 일반실 2개(각 40석), 특실 1개(18석) = 총 98석 + * - createAdvancedTrain(5, 3, 15, 10) → 일반실 5개(각 60석), 특실 3개(각 30석) = 총 390석 + */ + @Transactional + public Train createRealisticTrain(int standardCarCount, int firstClassCarCount, + int standardRowsPerCar, int firstClassRowsPerCar) { + + // 입력값 검증 + validateTrainConfiguration(standardCarCount, firstClassCarCount, standardRowsPerCar, firstClassRowsPerCar); + + Train train = createKTXTrain(standardCarCount + firstClassCarCount); + Train savedTrain = trainRepository.save(train); + + List carSpecs = new ArrayList<>(); + + // 일반실 객차들 추가 + for (int i = 0; i < standardCarCount; i++) { + carSpecs.add(new CarSpec(CarType.STANDARD, standardRowsPerCar)); + } + + // 특실 객차들 추가 + for (int i = 0; i < firstClassCarCount; i++) { + carSpecs.add(new CarSpec(CarType.FIRST_CLASS, firstClassRowsPerCar)); + } + + return saveTrainWithCarsAndSeats(savedTrain, carSpecs, createRealisticSeatLayouts()); + } + /** * 좌석 조회 메서드 * count만큼 carType에 해당하는 좌석 조회 @@ -72,6 +133,13 @@ public List getSeats(Train train, CarType carType, int count) { .toList(); } + /** + * 주어진 seatId 목록에 해당하는 Seat 목록을 조회 + */ + public List getSeatsByIds(List seatIds) { + return testSeatRepository.findAllById(seatIds); + } + /** * 좌석 조회 메서드 * count만큼 carType에 해당하는 좌석 ID 조회 @@ -97,6 +165,10 @@ private Train createKTXTrain() { return Train.create(1, TrainType.KTX, "KTX", 2); } + private Train createKTXTrain(int totalCars) { + return Train.create(1, TrainType.KTX, "KTX", totalCars); + } + private Train saveTrainWithCarsAndSeats(Train savedTrain, List carSpecs) { TrainTemplate trainTemplate = new TrainTemplate(carSpecs); Map seatLayouts = createSeatLayouts(); @@ -114,6 +186,23 @@ private Train saveTrainWithCarsAndSeats(Train savedTrain, List carSpecs return savedTrain; } + private Train saveTrainWithCarsAndSeats(Train savedTrain, List carSpecs, + Map seatLayouts) { + TrainTemplate trainTemplate = new TrainTemplate(carSpecs); + + List trainCars = savedTrain.generateTrainCars(seatLayouts, trainTemplate); + List savedTrainCars = trainCarRepository.saveAll(trainCars); + + savedTrainCars.forEach(trainCar -> { + CarSpec carSpec = getCarSpecByCarNumber(carSpecs, trainCar.getCarNumber()); + SeatLayout seatLayout = seatLayouts.get(trainCar.getCarType()); + List seats = trainCar.generateSeats(carSpec, seatLayout); + testSeatRepository.saveAll(seats); + }); + + return savedTrain; + } + private CarSpec getCarSpecByCarNumber(List carSpecs, int carNumber) { return carSpecs.get(carNumber - 1); } @@ -135,4 +224,54 @@ private Map createSeatLayouts() { return layouts; } + + /** + * 현실적인 좌석 배치 (실제 KTX와 동일) + * - 일반실: 4석/행 (2+2 배치) + * - 특실: 3석/행 (2+1 배치) + */ + private Map createRealisticSeatLayouts() { + Map layouts = new HashMap<>(); + + // 일반실: 2+2 배치 (AB 통로 CD) - 4석/행 + List standardColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE), + new SeatColumn("C", SeatType.AISLE), + new SeatColumn("D", SeatType.WINDOW) + ); + layouts.put(CarType.STANDARD, new SeatLayout("2+2", standardColumns)); + + // 특실: 2+1 배치 (AB 통로 C) - 3석/행 + List firstClassColumns = List.of( + new SeatColumn("A", SeatType.WINDOW), + new SeatColumn("B", SeatType.AISLE), + new SeatColumn("C", SeatType.WINDOW) + ); + layouts.put(CarType.FIRST_CLASS, new SeatLayout("2+1", firstClassColumns)); + + return layouts; + } + + private void validateTrainConfiguration(int standardCarCount, int firstClassCarCount, + int standardRowsPerCar, int firstClassRowsPerCar) { + if (standardCarCount < 0 || firstClassCarCount < 0) { + throw new IllegalArgumentException("객차 수는 0 이상이어야 합니다."); + } + if (standardCarCount == 0 && firstClassCarCount == 0) { + throw new IllegalArgumentException("최소 하나의 객차는 있어야 합니다."); + } + if (standardRowsPerCar < 1 || firstClassRowsPerCar < 1) { + throw new IllegalArgumentException("객차당 행 수는 1 이상이어야 합니다."); + } + if (standardCarCount + firstClassCarCount > 20) { + throw new IllegalArgumentException("총 객차 수는 20개를 초과할 수 없습니다."); + } + + // 총 좌석 수 제한 (너무 큰 테스트 데이터 방지) + int totalSeats = (standardCarCount * standardRowsPerCar * 4) + (firstClassCarCount * firstClassRowsPerCar * 3); + if (totalSeats > 1000) { + log.warn("총 좌석 수가 {}석으로 너무 큽니다. 테스트 성능에 영향을 줄 수 있습니다.", totalSeats); + } + } } diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java new file mode 100644 index 00000000..97fcaaff --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchApplicationServiceTest.java @@ -0,0 +1,591 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainCarListRequest; +import com.sudo.railo.train.application.dto.request.TrainCarSeatDetailRequest; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.OperationCalendarItem; +import com.sudo.railo.train.application.dto.response.TrainCarInfo; +import com.sudo.railo.train.application.dto.response.TrainCarListResponse; +import com.sudo.railo.train.application.dto.response.TrainCarSeatDetailResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.TrainCar; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.infrastructure.TrainCarRepository; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ServiceTest +class TrainSearchApplicationServiceTest { + + @Autowired + private TrainSearchApplicationService trainSearchApplicationService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private TrainCarRepository trainCarRepository; + + @DisplayName("금일로부터 한달간의 운행 스케줄 캘린더를 조회한다.") + @Test + void getOperationCalendar() { + // given + Train train1 = trainTestHelper.createKTX(); + Train train2 = trainTestHelper.createCustomKTX(2, 1); + + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + LocalDate dayAfterTomorrow = today.plusDays(2); + LocalDate nextWeek = today.plusWeeks(1); + + createTrainSchedule(train1, today, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); + createTrainSchedule(train2, tomorrow, "KTX 003", LocalTime.of(14, 0), LocalTime.of(17, 0)); + createTrainSchedule(train1, nextWeek, "KTX 005", LocalTime.of(10, 0), LocalTime.of(13, 0)); + + // when + List operationCalendar = trainSearchApplicationService.getOperationCalendar(); + + // then + // 1. 캘린더가 한 달치 날짜를 포함하는지 확인 (약 30일) + assertThat(operationCalendar).hasSizeGreaterThanOrEqualTo(28).hasSizeLessThanOrEqualTo(32); + + // 2. 운행하는 날짜들이 isBookingAvailable = "Y"로 표시되는지 확인 + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(today) && item.isBookingAvailable().equals("Y")); + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(tomorrow) && item.isBookingAvailable().equals("Y")); + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(nextWeek) && item.isBookingAvailable().equals("Y")); + + // 3. 운행하지 않는 날짜가 isBookingAvailable = "N"으로 표시되는지 확인 + assertThat(operationCalendar).anyMatch(item -> + item.operationDate().equals(dayAfterTomorrow) && item.isBookingAvailable().equals("N")); + + // 4. 전체 캘린더에서 운행일과 비운행일이 모두 존재하는지 확인 + long operatingDays = operationCalendar.stream() + .mapToLong(item -> item.isBookingAvailable().equals("Y") ? 1 : 0) + .sum(); + long nonOperatingDays = operationCalendar.stream() + .mapToLong(item -> item.isBookingAvailable().equals("N") ? 1 : 0) + .sum(); + + assertThat(operatingDays).isEqualTo(3); // today, tomorrow, nextWeek + assertThat(nonOperatingDays).isGreaterThanOrEqualTo(0); + assertThat(operatingDays + nonOperatingDays).isEqualTo(operationCalendar.size()); // 전체 합계 일치 + + log.info("운행 캘린더 검증 완료: 전체 {} 일, 운행일 {} 일, 비운행일 {} 일", + operationCalendar.size(), operatingDays, nonOperatingDays); + } + + @DisplayName("검색 조건에 따른 열차를 조회한다.") + @TestFactory + List searchTrains() { + // given + Train train1 = trainTestHelper.createKTX(); + Train train2 = trainTestHelper.createCustomKTX(2, 1); + Train train3 = trainTestHelper.createCustomKTX(3, 1); + + LocalDate futureDate = LocalDate.now().plusDays(1); + + // 오전, 오후, 저녁 시간대 열차 생성 + createTrainSchedule(train1, futureDate, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); // 오전 + createTrainSchedule(train2, futureDate, "KTX 003", LocalTime.of(14, 0), LocalTime.of(17, 0)); // 오후 + createTrainSchedule(train3, futureDate, "KTX 005", LocalTime.of(19, 0), LocalTime.of(22, 0)); // 저녁 + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 100000); + + // 검색 시나리오 정의 + record SearchScenario( + String description, + String departureHour, + int expectedCount, + java.util.function.Predicate> validator + ) { + } + + List scenarios = List.of( + new SearchScenario( + "전체 열차 조회 (06시 이후)", + "06", + 3, + trains -> trains.size() == 3 && + trains.stream().allMatch(train -> train.departureTime().isAfter(LocalTime.of(6, 0))) + ), + new SearchScenario( + "오후 이후 열차 조회 (13시 이후)", + "13", + 2, + trains -> trains.size() == 2 && + trains.stream().allMatch(train -> train.departureTime().isAfter(LocalTime.of(13, 0))) + ), + new SearchScenario( + "저녁 이후 열차 조회 (18시 이후)", + "18", + 1, + trains -> trains.size() == 1 && + trains.get(0).departureTime().isAfter(LocalTime.of(18, 0)) + ), + new SearchScenario( + "심야 시간 조회 (23시 이후)", + "23", + 0, + trains -> trains.isEmpty() + ) + ); + + // DynamicTest 생성 + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // given + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), futureDate, 2, scenario.departureHour + ); + Pageable pageable = PageRequest.of(0, 20); + + // when + TrainSearchSlicePageResponse response = trainSearchApplicationService.searchTrains(request, + pageable); + + // then + assertThat(response.content()).hasSize(scenario.expectedCount); + assertThat(scenario.validator.test(response.content())).isTrue(); + + // 페이징 정보 기본 검증 + assertThat(response.currentPage()).isEqualTo(0); + assertThat(response.first()).isTrue(); + assertThat(response.numberOfElements()).isEqualTo(scenario.expectedCount); + + log.info("검색 시나리오 완료 - {}: {}시 이후 → {}건 조회", + scenario.description, scenario.departureHour, response.content().size()); + } + )) + .toList(); + } + + @DisplayName("잔여 좌석이 있는 객차 목록을 조회할 수 있다.") + @Test + void getAvailableTrainCars() { + // given + Train train = trainTestHelper.createCustomKTX(3, 2); + TrainScheduleWithStopStations scheduleWithStop = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + TrainCarListRequest request = new TrainCarListRequest( + scheduleWithStop.trainSchedule().getId(), + seoul.getId(), + busan.getId(), + 2 + ); + + // when + TrainCarListResponse response = trainSearchApplicationService.getAvailableTrainCars(request); + + // then + assertThat(response.trainClassificationCode()).isEqualTo("KTX"); + assertThat(response.trainNumber()).isNotBlank(); + assertThat(response.totalCarCount()).isGreaterThan(0); + assertThat(response.totalCarCount()).isEqualTo(response.carInfos().size()); + + List carNumbers = response.carInfos().stream() + .map(car -> car.carNumber()) + .toList(); + assertThat(carNumbers).contains(response.recommendedCarNumber()); + + log.info("객차 목록 조회 완료: 열차 = {}-{}, 추천객차 = {}", + response.trainClassificationCode(), response.trainNumber(), response.recommendedCarNumber()); + } + + // TODO : 객차 타입별 객차수 다채롭게 두어 테스트 필요 + @DisplayName("승객 수에 따라 추천에 적합한 객차(잔여 좌석수 > 승객 수)가 있다면 중간 객차를, 없으면 첫 번째 객차를 추천한다.") + @TestFactory + Collection getAvailableTrainCars_recommendationLogicScenarios() { + // given + Train train = trainTestHelper.createCustomKTX(6, 2); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + record RecommendationScenario( + String description, + int passengerCount, + String expectedBehavior, + Predicate validator + ) { + } + + List scenarios = List.of( + new RecommendationScenario( + "일반적인 승객 수 - 수용 가능한 객차 추천", + 2, + "승객 수를 수용할 수 있는 객차 중에서 선택", + response -> { + // 추천 객차가 승객 수를 수용할 수 있는지 + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + return recommendedCar != null && recommendedCar.remainingSeats() >= 2; + } + ), + /*new RecommendationScenario( + "많은 승객 수 - 최대 수용 가능한 객차 우선", + 9, + "가장 많은 좌석을 가진 객차 선택 (일반실 우선)", + response -> { + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + if (recommendedCar == null) + return false; + + // 추천 객차가 요청 승객 수를 수용할 수 있거나, 수용 불가능한 경우 가장 큰 객차여야 함 + boolean canAccommodate = recommendedCar.remainingSeats() >= 9; + boolean isLargestCar = response.carInfos().stream() + .allMatch(car -> car.remainingSeats() <= recommendedCar.remainingSeats()); + + return canAccommodate || isLargestCar; + } + ),*/ + new RecommendationScenario( + "수용 불가능한 승객 수 - fallback 로직", + 20, + "모든 객차가 수용 불가능할 때 첫 번째 객차 또는 가장 큰 객차 선택", + response -> { + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElse(null); + + if (recommendedCar == null) + return false; + + // 모든 객차가 20명을 수용할 수 없으므로 fallback 로직 적용 + boolean isFirstCar = response.carInfos().get(0).carNumber().equals(response.recommendedCarNumber()); + boolean isLargestCar = response.carInfos().stream() + .allMatch(car -> car.remainingSeats() <= recommendedCar.remainingSeats()); + + return isFirstCar || isLargestCar; + } + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description + " (승객 " + scenario.passengerCount + "명)", + () -> { + // given + TrainCarListRequest request = new TrainCarListRequest( + scheduleWithStops.trainSchedule().getId(), + seoul.getId(), + busan.getId(), + scenario.passengerCount + ); + + // when + TrainCarListResponse response = trainSearchApplicationService.getAvailableTrainCars(request); + + // then + assertThat(response.carInfos()).isNotEmpty(); + assertThat(response.recommendedCarNumber()).isNotBlank(); + assertThat(response.totalCarCount()).isEqualTo(2); // 일반실 1개 + 특실 1개 + + // 추천 객차가 실제 객차 목록에 포함되어 있는지 + List availableCarNumbers = response.carInfos().stream() + .map(TrainCarInfo::carNumber) + .toList(); + assertThat(availableCarNumbers).contains(response.recommendedCarNumber()); + + // 각 객차 정보 상세 검증 + response.carInfos().forEach(carInfo -> { + assertThat(carInfo.carNumber()).isNotBlank(); + assertThat(carInfo.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(carInfo.totalSeats()).isGreaterThan(0); + assertThat(carInfo.remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(carInfo.remainingSeats()).isLessThanOrEqualTo(carInfo.totalSeats()); + }); + + // 객차 타입별 분포 검증 + // TODO : 객차 타입별 객차수 다채롭게 두어 테스트 필요 + long standardCars = response.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.STANDARD ? 1 : 0) + .sum(); + long firstClassCars = response.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.FIRST_CLASS ? 1 : 0) + .sum(); + + assertThat(standardCars + firstClassCars).isEqualTo(response.totalCarCount()); + assertThat(standardCars + firstClassCars).isEqualTo(response.carInfos().size()); + + // 시나리오별 비즈니스 로직 검증 + assertThat(scenario.validator.test(response)) + .as("시나리오 '%s'의 추천 로직이 올바르게 작동해야 합니다. 추천객차: %s, 기대동작: %s", + scenario.description, response.recommendedCarNumber(), scenario.expectedBehavior) + .isTrue(); + + TrainCarInfo recommendedCar = response.carInfos().stream() + .filter(car -> car.carNumber().equals(response.recommendedCarNumber())) + .findFirst() + .orElseThrow(); + + log.info("추천 로직 검증 완료 - {}: 승객{}명 → 객차{} (잔여{}석), 총 {}개 객차 중", + scenario.description, scenario.passengerCount, + response.recommendedCarNumber(), recommendedCar.remainingSeats(), + response.carInfos().size()); + } + )) + .toList(); + } + + @DisplayName("좌석 상세 조회") + @Test + void getTrainCarSeatDetail_delegatesToSeatQueryService() { + // given + Train train = trainTestHelper.createKTX(); + TrainScheduleWithStopStations scheduleWithStops = trainScheduleTestHelper.createSchedule(train); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + List trainCars = trainCarRepository.findAllByTrainId(train.getId()); + TrainCar firstCar = trainCars.get(0); + + TrainCarSeatDetailRequest request = new TrainCarSeatDetailRequest( + firstCar.getId(), scheduleWithStops.trainSchedule().getId(), + seoul.getId(), busan.getId() + ); + + // when + TrainCarSeatDetailResponse response = trainSearchApplicationService.getTrainCarSeatDetail(request); + + // then + assertThat(response.carNumber()).isEqualTo(Integer.valueOf(firstCar.getCarNumber()).toString()); + assertThat(response.carType()).isEqualTo(firstCar.getCarType()); + assertThat(response.totalSeatCount()).isEqualTo(firstCar.getTotalSeats()); + assertThat(response.remainingSeatCount()).isGreaterThanOrEqualTo(0) + .isLessThanOrEqualTo(firstCar.getTotalSeats()); + + log.info("좌석 상세 조회 완료: 객차={}, 좌석타입={}", + response.carNumber(), response.carType()); + } + + @DisplayName("전체 플로우 테스트 - 검색 -> 객차선택 -> 좌석조회 연동") + @Test + void fullSearchFlow_integrationTest() { + // given + Train train = trainTestHelper.createCustomKTX(2, 1); + LocalDate futureDate = LocalDate.now().plusDays(1); + createTrainSchedule(train, futureDate, "KTX 001", LocalTime.of(8, 0), LocalTime.of(11, 0)); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 100000); + + // 1: 열차 검색 + TrainSearchRequest searchRequest = new TrainSearchRequest( + seoul.getId(), busan.getId(), futureDate, 2, "00" + ); + Pageable pageable = PageRequest.of(0, 20); + + TrainSearchSlicePageResponse searchResponse = trainSearchApplicationService.searchTrains(searchRequest, + pageable); + + // 2: 검색된 열차의 객차 조회 + TrainCarListRequest carRequest = new TrainCarListRequest( + searchResponse.content().get(0).trainScheduleId(), + seoul.getId(), busan.getId(), 2 + ); + + TrainCarListResponse carResponse = trainSearchApplicationService.getAvailableTrainCars(carRequest); + + // 3: 선택된 객차의 좌석 상세 조회 + String selectedCarNumber = carResponse.recommendedCarNumber(); + List trainCars = trainCarRepository.findAllByTrainId(train.getId()); + TrainCar selectedCar = trainCars.stream() + .filter(car -> String.format("%04d", car.getCarNumber()).equals(selectedCarNumber)) + .findFirst() + .orElseThrow(); + + TrainCarSeatDetailRequest seatRequest = new TrainCarSeatDetailRequest( + selectedCar.getId(), searchResponse.content().get(0).trainScheduleId(), + seoul.getId(), busan.getId() + ); + + TrainCarSeatDetailResponse seatResponse = trainSearchApplicationService.getTrainCarSeatDetail(seatRequest); + + // then + // === Step 1: 열차 검색 결과 검증 === + assertThat(searchResponse.content()).hasSize(1); + TrainSearchResponse searchResult = searchResponse.content().get(0); + + // 기본 열차 정보 검증 + assertThat(searchResult.trainScheduleId()).isNotNull(); + assertThat(searchResult.trainNumber()).isNotBlank(); + assertThat(searchResult.trainName()).isEqualTo("KTX"); + assertThat(searchResult.departureStationName()).isEqualTo("서울"); + assertThat(searchResult.arrivalStationName()).isEqualTo("부산"); + assertThat(searchResult.departureTime()).isEqualTo(LocalTime.of(8, 0)); + assertThat(searchResult.arrivalTime()).isEqualTo(LocalTime.of(11, 0)); + assertThat(searchResult.travelTime()).isEqualTo(Duration.ofHours(3)); + + // 좌석 정보 검증 + assertThat(searchResult.standardSeat()).isNotNull(); + assertThat(searchResult.firstClassSeat()).isNotNull(); + assertThat(searchResult.standardSeat().fare()).isEqualTo(50000); // 일반실 요금 + assertThat(searchResult.firstClassSeat().fare()).isEqualTo(100000); // 특실 요금 + assertThat(searchResult.standardSeat().remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(searchResult.firstClassSeat().remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(searchResult.standardSeat().canReserve()).isNotNull(); + assertThat(searchResult.firstClassSeat().canReserve()).isNotNull(); + + // === Step 2: 객차 조회 결과 검증 === + assertThat(carResponse.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + assertThat(carResponse.trainClassificationCode()).isEqualTo("KTX"); + assertThat(carResponse.trainNumber()).isNotBlank(); + assertThat(carResponse.recommendedCarNumber()).isNotBlank(); + assertThat(carResponse.totalCarCount()).isEqualTo(2); // 일반실 1개 + 특실 1개 + assertThat(carResponse.carInfos()).hasSize(2); + + // 추천 객차가 실제 객차 목록에 존재하는지 검증 + List availableCarNumbers = carResponse.carInfos().stream() + .map(TrainCarInfo::carNumber) + .toList(); + assertThat(availableCarNumbers).contains(carResponse.recommendedCarNumber()); + + // 각 객차 정보 검증 + carResponse.carInfos().forEach(carInfo -> { + assertThat(carInfo.carNumber()).isNotBlank(); + assertThat(carInfo.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(carInfo.totalSeats()).isGreaterThan(0); + assertThat(carInfo.remainingSeats()).isGreaterThanOrEqualTo(0); + assertThat(carInfo.remainingSeats()).isLessThanOrEqualTo(carInfo.totalSeats()); + }); + + // === Step 3: 좌석 상세 조회 결과 검증 === + assertThat(String.format("%04d", Integer.parseInt(seatResponse.carNumber()))).isEqualTo(selectedCarNumber); + assertThat(seatResponse.carType()).isIn(CarType.STANDARD, CarType.FIRST_CLASS); + assertThat(seatResponse.totalSeatCount()).isGreaterThan(0); + assertThat(seatResponse.remainingSeatCount()).isGreaterThanOrEqualTo(0); + assertThat(seatResponse.remainingSeatCount()).isLessThanOrEqualTo(seatResponse.totalSeatCount()); + assertThat(seatResponse.layoutType()).isIn(2, 3); // 2+2 또는 2+1 배치 + assertThat(seatResponse.seatList()).isNotEmpty(); + + // 좌석 상세 정보 검증 + seatResponse.seatList().forEach(seat -> { + assertThat(seat.seatId()).isNotNull(); + assertThat(seat.seatNumber()).isNotBlank(); + assertThat(seat.seatDirection()).isNotNull(); + assertThat(seat.seatType()).isNotNull(); + // available은 true/false 모두 가능 + }); + + // 좌석 수 일관성 검증 + int totalSeatsInList = seatResponse.seatList().size(); + int availableSeatsInList = (int)seatResponse.seatList().stream() + .mapToLong(seat -> seat.isAvailable() ? 1 : 0) + .sum(); + + assertThat(totalSeatsInList).isEqualTo(seatResponse.totalSeatCount()); + assertThat(availableSeatsInList).isEqualTo(seatResponse.remainingSeatCount()); + + // === 플로우 간 데이터 일관성 검증 === + + // 1. 검색 결과의 trainScheduleId가 모든 단계에서 일관되게 사용되는지 + assertThat(carRequest.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + assertThat(seatRequest.trainScheduleId()).isEqualTo(searchResult.trainScheduleId()); + + // 2. 선택된 객차가 실제 검색된 열차의 객차인지 + assertThat(selectedCar.getTrain().getId()).isEqualTo(train.getId()); + + // 3. 객차 조회와 좌석 조회 간 데이터 일관성 + TrainCarInfo selectedCarInfo = carResponse.carInfos().stream() + .filter(car -> car.carNumber().equals(selectedCarNumber) || + String.format("%04d", Integer.parseInt(car.carNumber())).equals(selectedCarNumber)) + .findFirst() + .orElseThrow(); + + assertThat(seatResponse.carType()).isEqualTo(selectedCarInfo.carType()); + assertThat(seatResponse.totalSeatCount()).isEqualTo(selectedCarInfo.totalSeats()); + assertThat(seatResponse.remainingSeatCount()).isEqualTo(selectedCarInfo.remainingSeats()); + + // 4. 요청한 승객 수가 추천 객차에서 예약 가능한지 검증 + assertThat(selectedCarInfo.remainingSeats()).isGreaterThanOrEqualTo(2); // 요청한 승객 수 + + // === 비즈니스 로직 검증 === + + // 1. 추천 객차가 승객 수를 수용할 수 있는지 + assertThat(carResponse.carInfos().stream() + .anyMatch(car -> car.carNumber().equals(carResponse.recommendedCarNumber()) && + car.remainingSeats() >= 2)) + .as("추천 객차는 요청한 승객 수(%d명)를 수용할 수 있어야 합니다", 2) + .isTrue(); + + // 2. 요금 정보가 올바르게 설정되었는지 + long standardSeatCars = carResponse.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.STANDARD ? 1 : 0) + .sum(); + long firstClassCars = carResponse.carInfos().stream() + .mapToLong(car -> car.carType() == CarType.FIRST_CLASS ? 1 : 0) + .sum(); + + assertThat(standardSeatCars).isEqualTo(1); // 일반실 1개 + assertThat(firstClassCars).isEqualTo(1); // 특실 1개 + + log.info("전체 플로우 테스트 완료: 검색{}건 → 객차{}개 → 좌석{}개, 추천객차={}, 잔여좌석={}", + searchResponse.content().size(), + carResponse.carInfos().size(), + seatResponse.seatList().size(), + carResponse.recommendedCarNumber(), + seatResponse.remainingSeatCount()); + } + + private void createTrainSchedule(Train train, LocalDate operationDate, String scheduleName, + LocalTime departureTime, LocalTime arrivalTime) { + trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop("서울", null, departureTime) + .addStop("부산", arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java new file mode 100644 index 00000000..54efaf4b --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceOverlapReservationTest.java @@ -0,0 +1,176 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +@DisplayName("다구간 경로에서 예약 겹침(overlap) 로직 검증") +public class TrainSearchServiceOverlapReservationTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + record OverlapScenario( + String description, + String existingReservationRoute, // ex. "서울-대전" + String searchRoute, // ex. "대전-부산" + List reservedSeatsPerSegment, // ex. 15 + int expectedRemainingSeats // ex. 120 + ) { + @Override + public String toString() { + return description; + } + } + + static Stream overlapScenarios() { + return Stream.of( + new OverlapScenario( + "기존 예약 : 서울→대전(15) / 검색 : 대전→부산 (비겹침)", + "서울-대전", "대전-부산", + List.of(15), 120 + ), + new OverlapScenario( + "기존 예약 : 서울→부산(20) / 검색 : 대전→대구 (완전 겹침)", + "서울-부산", "대전-대구", + List.of(20), 100 + ), + new OverlapScenario( + "기존 예약 : 대전→부산(25) / 검색 : 서울→대구 (부분 겹침)", + "대전-부산", "서울-대구", + List.of(25), 95 + ), + new OverlapScenario( + "기존 예약 : 서울→대전(10) + 대구→부산(20) / 검색 : 대전→대구 (교집합 없음=비겹침)", + "서울-대전+대구-부산", "대전-대구", + List.of(15, 20), 120 + ) + ); + } + + @DisplayName("열차 조회 시 다구간 경로에서 기존의 ‘겹침 구간’ 예약만 잔여 좌석에서 차감된다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("overlapScenarios") + void shouldCountOnlyOverlappingReservations(OverlapScenario s) { + // given + Train train = trainTestHelper.createRealisticTrain(3, 2, 10, 6); // 일반실 : 120석, 특실 : 36석 + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 요금 설정 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "대전", 25000, 40000); + trainScheduleTestHelper.createOrUpdateStationFare("대전", "대구", 20000, 32000); + trainScheduleTestHelper.createOrUpdateStationFare("대구", "부산", 15000, 24000); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "대구", 40000, 64000); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + trainScheduleTestHelper.createOrUpdateStationFare("대전", "부산", 30000, 48000); + + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("KTX 복합구간") + .operationDate(searchDate) + .train(train) + .addStop("서울", null, LocalTime.of(8, 0)) + .addStop("대전", LocalTime.of(9, 0), LocalTime.of(9, 5)) + .addStop("대구", LocalTime.of(10, 30), LocalTime.of(10, 35)) + .addStop("부산", LocalTime.of(12, 0), null) + .build(); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station daejeon = trainScheduleTestHelper.getOrCreateStation("대전"); + Station daegu = trainScheduleTestHelper.getOrCreateStation("대구"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + ScheduleStop seoulStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "서울"); + ScheduleStop daejeonStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "대전"); + ScheduleStop daeguStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "대구"); + ScheduleStop busanStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "부산"); + + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + // 예약 생성 + String[] segments = s.existingReservationRoute().split("\\+"); + for (int i = 0; i < segments.length; i++) { + String segment = segments[i]; + String[] stops = segment.split("-"); + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, stops[0]); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, stops[1]); + + int seatsToReserve = s.reservedSeatsPerSegment().get(i); + List seatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, seatsToReserve); + + reservationTestHelper.createReservationWithSeatIds( + member, schedule, departureStop, arrivalStop, seatIds, PassengerType.ADULT + ); + } + + // when + String[] searchNodes = s.searchRoute().split("-"); + Station searchDepartureStation = switch (searchNodes[0]) { + case "서울" -> seoul; + case "대전" -> daejeon; + case "대구" -> daegu; + default -> busan; + }; + Station searchArrivalStation = switch (searchNodes[1]) { + case "대전" -> daejeon; + case "대구" -> daegu; + case "부산" -> busan; + default -> seoul; + }; + + TrainSearchRequest request = new TrainSearchRequest( + searchDepartureStation.getId(), + searchArrivalStation.getId(), + searchDate, + 10, + "00" + ); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(1); + TrainSearchResponse result = response.content().get(0); + assertThat(result.standardSeat().remainingSeats()).isEqualTo(s.expectedRemainingSeats()); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java new file mode 100644 index 00000000..e81d0d54 --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceSeatStatusTest.java @@ -0,0 +1,398 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sudo.railo.booking.domain.type.PassengerType; +import com.sudo.railo.member.domain.Member; +import com.sudo.railo.member.infrastructure.MemberRepository; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.fixture.MemberFixture; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.ScheduleStop; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.domain.type.CarType; +import com.sudo.railo.train.domain.type.SeatAvailabilityStatus; +import com.sudo.railo.train.infrastructure.SeatRepository; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +public class TrainSearchServiceSeatStatusTest { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private SeatRepository seatRepository; + + record SeatStatusScenario( + String description, + int standardCars, int firstClassCars, int standardRows, int firstClassRows, + int reservedStandardSeats, int reservedFirstClassSeats, int reservedStandingSeats, + int passengerCount, + SeatAvailabilityStatus expectedStandardStatus, + SeatAvailabilityStatus expectedFirstClassStatus, + boolean expectedStandardCanReserve, + boolean expectedFirstClassCanReserve, + boolean expectedHasStanding + ) { + @Override + public String toString() { + return description; + } + } + + static Stream seatStatusScenarios() { + return Stream.of( + new SeatStatusScenario( + "1. 여유 상황 - 일반실/특실 모두 충분", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 + 5, 2, 0, // 일반실 5석, 특실 2석 예약 → 75석, 22석 잔여 + 4, // 4명 요청 + // 일반실: 75/80 = 93.7% > 25% → AVAILABLE + // 특실: 22/24 = 91.6% > 25% → AVAILABLE + SeatAvailabilityStatus.AVAILABLE, SeatAvailabilityStatus.AVAILABLE, + true, true, false + ), + new SeatStatusScenario( + "2. 제한적 상황 - 일반실 여유 부족하지만 예약 가능", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 + 65, 5, 0, // 일반실 65석, 특실 5석 예약 → 15석, 19석 잔여 + 4, // 4명 요청 + // 일반실: 15/80 = 18.75% < 25% → LIMITED + // 특실: 19/24 = 79.1% > 25% → AVAILABLE + SeatAvailabilityStatus.LIMITED, SeatAvailabilityStatus.AVAILABLE, + true, true, false + ), + new SeatStatusScenario( + "3. 일반실 부족하지만 입석 가능 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석(104*0.15) + 78, 0, 0, // 일반실 78석 예약 → 2석 잔여, 입석 0석 예약 → 15석 잔여 + 5, // 5명 요청 (일반실 2석 < 5명이므로 예약 불가하지만 입석 15석으로 수용 가능) + // 일반실: 2 < 5명 요청 → STANDING_ONLY (입석 가능하므로) + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.STANDING_ONLY, SeatAvailabilityStatus.AVAILABLE, + false, true, true + ), + new SeatStatusScenario( + "4. 일반실 매진하지만 입석 가능 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 0, 0, // 일반실 모두 예약, 입석 0석 예약 → 15석 잔여 + 3, // 3명 요청 (입석 15석으로 수용 가능) + // 일반실: 0석 잔여, 입석 가능 → STANDING_ONLY + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.STANDING_ONLY, SeatAvailabilityStatus.AVAILABLE, + false, true, true + ), + new SeatStatusScenario( + "5. 일반실 매진 + 입석 부족 상황", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 0, 13, // 일반실 매진, 입석 13석 예약 → 입석 2석 잔여 + 5, // 5명 요청 (입석 2석으로 수용 불가) + // 일반실: 0석 잔여, 입석으로도 수용 불가 → SOLD_OUT + // 특실: 24/24 = 100% > 25% → AVAILABLE + SeatAvailabilityStatus.SOLD_OUT, SeatAvailabilityStatus.AVAILABLE, + false, true, false + ), + new SeatStatusScenario( + "6. 완전 매진 상황 - 모든 좌석 + 입석 매진", + 2, 1, 10, 8, // 일반실 80석, 특실 24석 → 총 104석 → 입석 15석 + 80, 20, 15, // 일반실 매진, 특실 20석 예약 → 4석 잔여, 입석 매진 + 6, // 6명 요청 (특실 4석으로 수용 불가, 입석 매진) + // 일반실: 0석 잔여, 입석 매진 → SOLD_OUT + // 특실: 4 < 6명 요청 → INSUFFICIENT (입석 불가하므로) + SeatAvailabilityStatus.SOLD_OUT, SeatAvailabilityStatus.INSUFFICIENT, + false, false, false + ) + ); + } + + @DisplayName("다양한 기존 예약 상황에 따라 적절한 좌석 상태와 입석 정보를 표시한다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("seatStatusScenarios") + void shouldDisplayCorrectSeatAndStandingInfoForAllScenarios(SeatStatusScenario scenario) { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + Train train = trainTestHelper.createRealisticTrain( + scenario.standardCars, scenario.firstClassCars, + scenario.standardRows, scenario.firstClassRows); + + TrainScheduleWithStopStations schedule = trainScheduleTestHelper.createCustomSchedule() + .scheduleName("KTX TEST") + .operationDate(searchDate) + .train(train) + .addStop("서울", null, LocalTime.of(10, 0)) + .addStop("부산", LocalTime.of(13, 0), null) + .build(); + + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "서울"); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(schedule, "부산"); + + if (scenario.reservedStandardSeats > 0) { + List seatIds = trainTestHelper.getSeatIds(train, CarType.STANDARD, scenario.reservedStandardSeats); + reservationTestHelper.createReservationWithSeatIds(member, schedule, departureStop, arrivalStop, seatIds, + PassengerType.ADULT); + } + if (scenario.reservedFirstClassSeats > 0) { + List seatIds = trainTestHelper.getSeatIds(train, CarType.FIRST_CLASS, + scenario.reservedFirstClassSeats); + reservationTestHelper.createReservationWithSeatIds(member, schedule, departureStop, arrivalStop, seatIds, + PassengerType.ADULT); + } + if (scenario.reservedStandingSeats > 0) { + reservationTestHelper.createStandingReservation(member, schedule, departureStop, arrivalStop, + scenario.reservedStandingSeats); + } + + // when + TrainSearchRequest request = new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate, + scenario.passengerCount, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(1); + TrainSearchResponse trainResult = response.content().get(0); + + assertThat(trainResult.standardSeat().status()).isEqualTo(scenario.expectedStandardStatus); + assertThat(trainResult.standardSeat().canReserve()).isEqualTo(scenario.expectedStandardCanReserve); + assertThat(trainResult.firstClassSeat().status()).isEqualTo(scenario.expectedFirstClassStatus); + assertThat(trainResult.firstClassSeat().canReserve()).isEqualTo(scenario.expectedFirstClassCanReserve); + assertThat(trainResult.hasStandingInfo()).isEqualTo(scenario.expectedHasStanding); + + if (scenario.expectedHasStanding) { + assertThat(trainResult.standing()).isNotNull(); + assertThat(trainResult.standing().remainingStanding()).isGreaterThan(0); + assertThat(trainResult.standing().fare()).isEqualTo((int)(50000 * 0.85)); + assertThat(trainResult.standardSeat().status()).isEqualTo(SeatAvailabilityStatus.STANDING_ONLY); + assertThat(trainResult.standardSeat().displayText()).contains("입석"); + } else { + assertThat(trainResult.standing()).isNull(); + } + + int expectedStandardTotal = scenario.standardCars * scenario.standardRows * 4; + int expectedFirstClassTotal = scenario.firstClassCars * scenario.firstClassRows * 3; + int expectedStandardRemaining = expectedStandardTotal - scenario.reservedStandardSeats; + int expectedFirstClassRemaining = expectedFirstClassTotal - scenario.reservedFirstClassSeats; + + assertThat(trainResult.standardSeat().totalSeats()).isEqualTo(expectedStandardTotal); + assertThat(trainResult.standardSeat().remainingSeats()).isEqualTo(expectedStandardRemaining); + assertThat(trainResult.firstClassSeat().totalSeats()).isEqualTo(expectedFirstClassTotal); + assertThat(trainResult.firstClassSeat().remainingSeats()).isEqualTo(expectedFirstClassRemaining); + } + + /** + * 입석 테스트용 공통 데이터 + */ + private void setupStandingTestData() { + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Member member = memberRepository.save(MemberFixture.createStandardMember()); + + // 여유 열차와 매진 열차 생성 + Train availableTrain = trainTestHelper.createRealisticTrain(1, 1, 8, 6); // 일반실 32석, 특실 18석 + Train soldOutTrain = trainTestHelper.createRealisticTrain(1, 1, 8, 6); // 일반실 32석, 특실 18석 + + TrainScheduleWithStopStations availableSchedule = createTrainSchedule(availableTrain, searchDate, + "KTX 201", LocalTime.of(10, 0), LocalTime.of(13, 0), "서울", "부산"); + TrainScheduleWithStopStations soldOutSchedule = createTrainSchedule(soldOutTrain, searchDate, + "KTX 203", LocalTime.of(10, 10), LocalTime.of(13, 10), "서울", "부산"); + + ScheduleStop departureStop = trainScheduleTestHelper.getScheduleStopByStationName(soldOutSchedule, "서울"); + ScheduleStop arrivalStop = trainScheduleTestHelper.getScheduleStopByStationName(soldOutSchedule, "부산"); + + // 매진 열차의 일반실 모두 예약 (32석 모두) + List allStandardSeats = trainTestHelper.getSeatIds(soldOutTrain, CarType.STANDARD, 32); + reservationTestHelper.createReservationWithSeatIds(member, soldOutSchedule, departureStop, arrivalStop, + allStandardSeats, PassengerType.ADULT); + } + + @DisplayName("입석 정보 자동 제공: 일반실 여유 열차는 입석 정보를 제공하지 않고, 매진/부족 열차는 입석 정보를 제공한다.") + @Test + void shouldAutoProvideStandingInfoWhenStandardSoldOutOrInsufficient() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + assertThat(response.content()).hasSize(2); + + TrainSearchResponse availableTrain = findTrainByTime(response.content(), LocalTime.of(10, 0)); + TrainSearchResponse soldOutTrain = findTrainByTime(response.content(), LocalTime.of(10, 10)); + + assertThat(availableTrain.hasStandingInfo()).isFalse(); + assertThat(soldOutTrain.hasStandingInfo()).isTrue(); + + log.info("여유 열차 - {}: 일반실 {}석 잔여, 입석 정보 없음", + availableTrain.trainNumber(), availableTrain.standardSeat().remainingSeats()); + log.info("매진 열차 - {}: 일반실 매진, 입석 정보 제공", + soldOutTrain.trainNumber()); + } + + @DisplayName("입석 요금 할인: 입석 요금은 일반실 대비 15% 할인을 적용한다. (85% 요금)") + @Test + void shouldApply15PercentDiscountOnStandingFare() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 3, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + List standingTrains = response.content().stream() + .filter(TrainSearchResponse::hasStandingInfo) + .toList(); + + assertThat(standingTrains).hasSize(1); + + TrainSearchResponse standingTrain = standingTrains.get(0); + int standingFare = standingTrain.standing().fare(); + int standardFare = standingTrain.standardSeat().fare(); + int expectedFare = (int)(standardFare * 0.85); + double actualDiscount = (1.0 - (double)standingFare / standardFare) * 100; + + assertThat(standingFare).isEqualTo(expectedFare); + assertThat(actualDiscount).isEqualTo(15.0, within(0.1)); // ±0.1 범위 오차 허용 + + log.info("입석 요금 할인 검증 - 일반실: {}원, 입석: {}원 ({}% 할인)", + standardFare, standingFare, String.format("%.1f", actualDiscount)); + } + + @DisplayName("입석 수용력: 열차는 총 좌석의 15%를 입석 인원으로 수용할 수 있다. (32+18=50석 → 7석)") + @Test + void shouldCalculateStandingCapacityAsFifteenPercent() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 4, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + List standingTrains = response.content().stream() + .filter(TrainSearchResponse::hasStandingInfo) + .toList(); + + assertThat(standingTrains).hasSize(1); + + TrainSearchResponse standingTrain = standingTrains.get(0); + int totalSeats = standingTrain.standardSeat().totalSeats() + standingTrain.firstClassSeat().totalSeats(); + int expectedCapacity = (int)(totalSeats * 0.15); // 50 * 0.15 = 7.5 → 7석 + + assertThat(totalSeats).isEqualTo(50); // 32 + 18 + assertThat(expectedCapacity).isEqualTo(7); + assertThat(standingTrain.standing().maxStanding()).isEqualTo(expectedCapacity); + assertThat(standingTrain.standing().remainingStanding()).isLessThanOrEqualTo(expectedCapacity); + + log.info("입석 수용력 검증 - 총 좌석: {}석, 입석 용량: {}석, 잔여 입석: {}석", + totalSeats, expectedCapacity, standingTrain.standing().remainingStanding()); + } + + @DisplayName("입석 상태 표시: 일반실 매진, 입석 예약 가능 시 좌석 상태는 STANDING_ONLY로 표시되고, 입석 정보가 노출된다.") + @Test + void shouldDisplayStandingOnlyStatusAndStandingInfoWhenStandardSoldOutAndCanReserveStanding() { + // given + setupStandingTestData(); + LocalDate searchDate = LocalDate.now().plusDays(1); + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // when + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, "00"); + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, PageRequest.of(0, 20)); + + // then + TrainSearchResponse soldOutTrain = findTrainByTime(response.content(), LocalTime.of(10, 10)); + + assertThat(soldOutTrain.hasStandingInfo()).isTrue(); + assertThat(soldOutTrain.standardSeat().status()).isEqualTo(SeatAvailabilityStatus.STANDING_ONLY); + assertThat(soldOutTrain.standardSeat().canReserve()).isFalse(); + assertThat(soldOutTrain.standardSeat().displayText()).contains("일반실(입석)"); + assertThat(soldOutTrain.standing().remainingStanding()).isGreaterThan(0); + + log.info("입석 상태 표시 검증 - 일반실 상태: {}, 표시 텍스트: {}", + soldOutTrain.standardSeat().status(), soldOutTrain.standardSeat().displayText()); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleWithStopStations createTrainSchedule(Train train, + LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } + + private TrainSearchResponse findTrainByTime(List trains, LocalTime time) { + return trains.stream() + .filter(train -> train.departureTime().equals(time)) + .findFirst() + .orElseThrow(() -> new AssertionError("시간 " + time + "에 해당하는 열차를 찾을 수 없습니다")); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java new file mode 100644 index 00000000..a843d69d --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchServiceTest.java @@ -0,0 +1,319 @@ +package com.sudo.railo.train.application; + +import static com.sudo.railo.support.helper.TrainScheduleTestHelper.*; +import static com.sudo.railo.train.exception.TrainErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.ReservationTestHelper; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchResponse; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.exception.TrainErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +class TrainSearchServiceTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private ReservationTestHelper reservationTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @DisplayName("열차 조회 시 지정한 출발 시간 이후에만 필터링하고, 결과를 시간순으로 정렬해서 반환한다") + @TestFactory + Collection shouldFilterByDepartureHourAndSortChronologically() { + // given + Train train1 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); // 일반실 32석, 특실 12석 + Train train2 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); + Train train3 = trainTestHelper.createRealisticTrain(1, 1, 8, 4); + + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 요금 정보 생성 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + // 다양한 시간대의 열차 생성 (시간순 정렬 테스트를 위해) + createTrainSchedule(train1, searchDate, "KTX 001", LocalTime.of(6, 0), LocalTime.of(9, 15), "서울", "부산"); + createTrainSchedule(train2, searchDate, "KTX 003", LocalTime.of(12, 30), LocalTime.of(15, 45), "서울", "부산"); + createTrainSchedule(train3, searchDate, "KTX 005", LocalTime.of(18, 0), LocalTime.of(21, 15), "서울", "부산"); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + record TimeFilterScenario( + String description, + String departureHour, + int expectedTrainCount, + List expectedDepartureTimes + ) { + } + + List scenarios = List.of( + new TimeFilterScenario( + "전체 열차 조회 (0시 이후)", + "00", + 3, + List.of(LocalTime.of(6, 0), LocalTime.of(12, 30), LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "오전 중반 이후 열차 조회 (10시 이후)", + "10", + 2, + List.of(LocalTime.of(12, 30), LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "오후 이후 열차 조회 (14시 이후)", + "14", + 1, + List.of(LocalTime.of(18, 0)) + ), + new TimeFilterScenario( + "심야 시간 조회 (22시 이후)", + "22", + 0, + List.of() + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // given + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 2, scenario.departureHour // 2명으로 고정 (수용력과 무관) + ); + Pageable pageable = PageRequest.of(0, 20); + + // when + TrainSearchSlicePageResponse response = trainSearchService.searchTrains(request, pageable); + + // then - 기본 검증 + assertThat(response.content()).hasSize(scenario.expectedTrainCount); + assertThat(response.currentPage()).isEqualTo(0); + assertThat(response.first()).isTrue(); + + // 시간순 정렬 검증 + List actualDepartureTimes = response.content().stream() + .map(TrainSearchResponse::departureTime) + .toList(); + + assertThat(actualDepartureTimes) + .as("출발 시간이 시간순으로 정렬되어야 합니다.") + .isSorted() + .containsExactlyElementsOf(scenario.expectedDepartureTimes); + + // 각 열차 기본 정보 검증 + response.content().forEach(train -> { + assertThat(train.trainScheduleId()).isNotNull(); + assertThat(train.trainNumber()).isNotBlank(); + assertThat(train.trainName()).isEqualTo("KTX"); + assertThat(train.travelTime()).isPositive(); + assertThat(train.departureStationName()).isEqualTo("서울"); + assertThat(train.arrivalStationName()).isEqualTo("부산"); + }); + + log.info("시간순 필터링 시나리오 완료 - {}: {}시 이후 → {}건 조회, 출발시간: {}", + scenario.description, scenario.departureHour, response.content().size(), + actualDepartureTimes.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + )) + .toList(); + } + + @DisplayName("기본 일정 조회 시 존재하지 않는 스케줄 ID 로 조회하면 상세 오류 코드와 메시지가 포함된 예외를 던진다") + @Test + void getTrainScheduleBasicInfo_throwsInformativeExceptionForNonExistentScheduleId() { + // given + Long nonExistentScheduleId = 999999L; + + // when & then + assertThatThrownBy(() -> trainSearchService.getTrainScheduleBasicInfo(nonExistentScheduleId)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(TRAIN_SCHEDULE_DETAIL_NOT_FOUND.getMessage()); + + log.info("존재하지 않는 스케줄 ID({}) 조회 예외 처리 완료", nonExistentScheduleId); + } + + @DisplayName("열차 조회 시 페이징이 올바르게 동작하고, 페이지 내*간 중복 없이 시간순 정렬을 유지한다") + @Test + void searchTrains_paginatesLargeResultsCorrectlyWithoutDuplicationAndMaintainsTimeOrdering() { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + // 다양한 크기와 시간대로 10개 열차 생성 + List trains = IntStream.range(0, 10) + .mapToObj(i -> { + int standardCars = 2 + (i % 3); // 2-4개 일반실 + int firstClassCars = 1 + (i % 2); // 1-2개 특실 + return trainTestHelper.createRealisticTrain(standardCars, firstClassCars, 10, 6); + }) + .toList(); + + for (int i = 0; i < trains.size(); i++) { + createTrainSchedule(trains.get(i), searchDate, + String.format("KTX %03d", i + 1), + LocalTime.of(6 + i, 0), // 6시부터 1시간 간격 + LocalTime.of(9 + i, 0), + "서울", "부산"); + } + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + TrainSearchRequest request = new TrainSearchRequest( + seoul.getId(), busan.getId(), searchDate, 1, "00" + ); + + // when + record PagingTest(int pageSize, int expectedFirstPageSize, int expectedSecondPageSize) { + } + + // 다양한 페이지 크기로 테스트 + List pagingTests = List.of( + new PagingTest(3, 3, 3), // 3개씩, 10개 → 3, 3, 3, 1 + new PagingTest(4, 4, 4), // 4개씩, 10개 → 4, 4, 2 + new PagingTest(7, 7, 3) // 7개씩, 10개 → 7, 3 + ); + + pagingTests.forEach(test -> { + // 첫 번째 페이지 + Pageable firstPage = PageRequest.of(0, test.pageSize); + TrainSearchSlicePageResponse firstResponse = trainSearchService.searchTrains(request, firstPage); + + // 두 번째 페이지 + Pageable secondPage = PageRequest.of(1, test.pageSize); + TrainSearchSlicePageResponse secondResponse = trainSearchService.searchTrains(request, secondPage); + + // then - 첫 번째 페이지 검증 + assertThat(firstResponse.content()).hasSize(test.expectedFirstPageSize); + assertThat(firstResponse.currentPage()).isEqualTo(0); + assertThat(firstResponse.first()).isTrue(); + assertThat(firstResponse.hasNext()).isTrue(); + + // then - 두 번째 페이지 검증 + assertThat(secondResponse.content()).hasSize(test.expectedSecondPageSize); + assertThat(secondResponse.currentPage()).isEqualTo(1); + assertThat(secondResponse.first()).isFalse(); + + // 페이지 간 데이터 중복 없음 검증 + Set firstPageTrainScheduleIds = firstResponse.content().stream() + .map(TrainSearchResponse::trainScheduleId) + .collect(Collectors.toSet()); + Set secondPageTrainScheduleIds = secondResponse.content().stream() + .map(TrainSearchResponse::trainScheduleId) + .collect(Collectors.toSet()); + + assertThat(firstPageTrainScheduleIds).doesNotContainAnyElementsOf(secondPageTrainScheduleIds); + + // 시간순 정렬 검증 (각 페이지 내에서) + assertThat(firstResponse.content()) + .extracting(TrainSearchResponse::departureTime) + .isSorted(); + assertThat(secondResponse.content()) + .extracting(TrainSearchResponse::departureTime) + .isSorted(); + + // 페이지 간 시간 순서 검증 (첫 페이지 마지막 < 둘째 페이지 첫번째) + if (!firstResponse.content().isEmpty() && !secondResponse.content().isEmpty()) { + LocalTime lastTimeFirstPage = firstResponse.content() + .get(firstResponse.content().size() - 1) + .departureTime(); + LocalTime firstTimeSecondPage = secondResponse.content().get(0).departureTime(); + assertThat(lastTimeFirstPage).isBefore(firstTimeSecondPage); + } + + log.info("페이징 테스트 완료 (크기 {}): 1페이지 {}건, 2페이지 {}건", + test.pageSize, firstResponse.content().size(), secondResponse.content().size()); + }); + } + + @DisplayName("조회하는 구간의 요금 정보가 없으면 STATION_FARE_NOT_FOUND 예외를 던진다") + @Test + void shouldThrowStationFareNotFoundWhenFareIsMissing() { + // given + LocalDate searchDate = LocalDate.now().plusDays(1); + + // 역 생성 + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + + // 서울→부산 요금 등록 + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + + Train train = trainTestHelper.createRealisticTrain(1, 1, 10, 6); + // 요금 없는 방향으로 부산→서울 스케줄 생성 + createTrainSchedule(train, searchDate, "KTX Rev", + LocalTime.of(15, 0), LocalTime.of(18, 0), + "부산", "서울" + ); + + // when & then + TrainSearchRequest request = new TrainSearchRequest( + busan.getId(), // 출발: 요금 미등록 방향 + seoul.getId(), // 도착 + searchDate, + 1, + "15" // 15시 이후 + ); + + assertThatThrownBy(() -> + trainSearchService.searchTrains(request, PageRequest.of(0, 10)) + ) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> { + BusinessException be = (BusinessException)ex; + assertThat(be.getErrorCode()) + .isEqualTo(TrainErrorCode.STATION_FARE_NOT_FOUND); + assertThat(be.getMessage()) + .contains(TrainErrorCode.STATION_FARE_NOT_FOUND.getMessage()); + }); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleWithStopStations createTrainSchedule(Train train, LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java b/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java new file mode 100644 index 00000000..aba64c23 --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/TrainSearchValidationTest.java @@ -0,0 +1,179 @@ +package com.sudo.railo.train.application; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; + +import com.sudo.railo.global.exception.error.BusinessException; +import com.sudo.railo.support.annotation.ServiceTest; +import com.sudo.railo.support.helper.TrainScheduleTestHelper; +import com.sudo.railo.support.helper.TrainTestHelper; +import com.sudo.railo.train.application.dto.request.TrainSearchRequest; +import com.sudo.railo.train.application.dto.response.TrainSearchSlicePageResponse; +import com.sudo.railo.train.domain.Station; +import com.sudo.railo.train.domain.Train; +import com.sudo.railo.train.exception.TrainErrorCode; + +import lombok.extern.slf4j.Slf4j; + +@ServiceTest +@Slf4j +public class TrainSearchValidationTest { + + @Autowired + private TrainSearchService trainSearchService; + + @Autowired + private TrainTestHelper trainTestHelper; + + @Autowired + private TrainScheduleTestHelper trainScheduleTestHelper; + + @DisplayName("다양한 잘못된 검색 조건에 대해 적절한 비즈니스 예외가 발생한다") + @TestFactory + Collection shouldThrowAppropriateExceptionForInvalidSearchConditions() { + // given + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + LocalDate validDate = LocalDate.now().plusDays(1); + + int currentHour = LocalTime.now().getHour(); + int pastHour = (currentHour == 0 ? 0 : currentHour - 1); + String pastHourString = String.format("%02d", pastHour); + + record ValidationScenario( + String description, + TrainSearchRequest request, + Class expectedException, + TrainErrorCode expectedErrorCode, + String expectedMessageContains + ) { + @Override + public String toString() { + return description; + } + } + + List scenarios = List.of( + new ValidationScenario( + "출발역과 도착역이 동일한 경우", + new TrainSearchRequest(seoul.getId(), seoul.getId(), validDate, 1, "00"), + BusinessException.class, + TrainErrorCode.INVALID_ROUTE, + TrainErrorCode.INVALID_ROUTE.getMessage() + ), + new ValidationScenario( + "운행일이 너무 먼 미래인 경우 (3개월 후)", + new TrainSearchRequest(seoul.getId(), busan.getId(), LocalDate.now().plusMonths(3), 1, "00"), + BusinessException.class, + TrainErrorCode.OPERATION_DATE_TOO_FAR, + TrainErrorCode.OPERATION_DATE_TOO_FAR.getMessage() + ), + new ValidationScenario( + "과거 시각을 출발 시간으로 선택한 경우", + new TrainSearchRequest(seoul.getId(), busan.getId(), LocalDate.now(), 1, pastHourString), + BusinessException.class, + TrainErrorCode.DEPARTURE_TIME_PASSED, + TrainErrorCode.DEPARTURE_TIME_PASSED.getMessage() + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // when & then + assertThatThrownBy(() -> trainSearchService.searchTrains( + scenario.request, PageRequest.of(0, 20))) + .isInstanceOf(scenario.expectedException) + .hasMessageContaining(scenario.expectedMessageContains); + + log.info("검증 실패 시나리오 완료 - {}: {} 발생", + scenario.description, scenario.expectedException.getSimpleName()); + } + )) + .toList(); + } + + @DisplayName("다양한 검색 시나리오에서 검색 결과가 없을 경우 빈 리스트를 반환한다.") + @TestFactory + Collection shouldReturnEmptyListForNonexistentRoutesAndDates() { + // given - 기본 테스트 데이터 + Train train = trainTestHelper.createRealisticTrain(2, 1, 10, 6); + LocalDate searchDate = LocalDate.now().plusDays(1); + + trainScheduleTestHelper.createOrUpdateStationFare("서울", "부산", 50000, 80000); + createTrainSchedule(train, searchDate, "KTX 001", + LocalTime.of(10, 0), LocalTime.of(13, 0), "서울", "부산"); + + Station seoul = trainScheduleTestHelper.getOrCreateStation("서울"); + Station busan = trainScheduleTestHelper.getOrCreateStation("부산"); + Station daegu = trainScheduleTestHelper.getOrCreateStation("대구"); + + record NoResultScenario( + String description, + TrainSearchRequest request + ) { + @Override + public String toString() { + return description; + } + } + + List scenarios = List.of( + new NoResultScenario( + "존재하는 역이지만 해당 역을 경유하는 노선이 없는 경우 (서울-대구)", + new TrainSearchRequest(seoul.getId(), daegu.getId(), searchDate, 1, "00") + ), + new NoResultScenario( + "해당 날짜에 운행하는 열차 없음", + new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate.plusDays(1), 1, "00") + ), + new NoResultScenario( + "요청한 출발 시간 이후에 운행하는 열차 없음", + new TrainSearchRequest(seoul.getId(), busan.getId(), searchDate, 1, "15") + ) + ); + + return scenarios.stream() + .map(scenario -> DynamicTest.dynamicTest( + scenario.description, + () -> { + // when + TrainSearchSlicePageResponse response = trainSearchService.searchTrains( + scenario.request, PageRequest.of(0, 20)); + + // then: 검색 결과 없을 때 빈 리스트 반환 + assertThat(response.content()).isEmpty(); + + log.info("검색 결과 없음 시나리오 완료 - {}", scenario.description); + } + )) + .toList(); + } + + /** + * 열차 스케줄 생성 헬퍼 + */ + private TrainScheduleTestHelper.TrainScheduleWithStopStations createTrainSchedule(Train train, + LocalDate operationDate, + String scheduleName, LocalTime departureTime, LocalTime arrivalTime, + String departureStation, String arrivalStation) { + return trainScheduleTestHelper.createCustomSchedule() + .scheduleName(scheduleName) + .operationDate(operationDate) + .train(train) + .addStop(departureStation, null, departureTime) + .addStop(arrivalStation, arrivalTime, null) + .build(); + } +} diff --git a/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java b/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java new file mode 100644 index 00000000..bd4383dd --- /dev/null +++ b/src/test/java/com/sudo/railo/train/application/dto/request/TrainSearchRequestTest.java @@ -0,0 +1,94 @@ +package com.sudo.railo.train.application.dto.request; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.sudo.railo.support.annotation.ServiceTest; + +import jakarta.validation.Validator; + +@ServiceTest +class TrainSearchRequestTest { + + @Autowired + private Validator validator; + + record ValidationScenario( + String description, + TrainSearchRequest request, + String field, + String expectedMessage + ) { + @Override + public String toString() { + return description; + } + } + + static Stream requestValidationScenarios() { + LocalDate today = LocalDate.now(); + return Stream.of( + new ValidationScenario( + "출발역이 null인 경우", + new TrainSearchRequest(null, 1L, today, 1, "00"), + "departureStationId", + "출발역을 선택해주세요" + ), + new ValidationScenario( + "도착역이 null인 경우", + new TrainSearchRequest(1L, null, today, 1, "00"), + "arrivalStationId", + "도착역을 선택해주세요" + ), + new ValidationScenario( + "운행날짜가 과거인 경우", + new TrainSearchRequest(1L, 2L, today.minusDays(1), 1, "00"), + "operationDate", + "운행날짜는 오늘 이후여야 합니다" + ), + new ValidationScenario( + "승객 수가 0명인 경우", + new TrainSearchRequest(1L, 2L, today, 0, "00"), + "passengerCount", + "승객 수는 최소 1명이어야 합니다" + ), + new ValidationScenario( + "승객 수가 10명인 경우", + new TrainSearchRequest(1L, 2L, today, 10, "00"), + "passengerCount", + "승객 수는 최대 9명까지 가능합니다" + ), + new ValidationScenario( + "출발 희망 시간이 blank인 경우", + new TrainSearchRequest(1L, 2L, today, 1, ""), + "departureHour", + "출발 희망 시간을 선택해주세요" + ), + new ValidationScenario( + "잘못된 departureHour 형식(25시)", + new TrainSearchRequest(1L, 2L, today, 1, "25"), + "departureHour", + "출발 시간은 00~23 사이의 정시 값이어야 합니다" + ) + ); + } + + @DisplayName("잘못된 TrainSearchRequest DTO는 검증 예외가 발생한다.") + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("requestValidationScenarios") + void shouldFailValidationForInvalidDto(ValidationScenario scenario) { + var violations = validator.validate(scenario.request()); + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo(scenario.field()); + assertThat(v.getMessage()).isEqualTo(scenario.expectedMessage()); + }); + } +}