diff --git a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java new file mode 100644 index 0000000..111cbe9 --- /dev/null +++ b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java @@ -0,0 +1,205 @@ +package pl.agh.transit; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.gtfs_static.model.Route; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.Trip; +import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.gtfs_static.repository.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StaticTripRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.StopTimeRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +/** + * Helper class for shared connection service logic + * Extracts common operations used by multiple connection services + */ +@Slf4j +@Component +@AllArgsConstructor +public class ConnectionServiceHelper { + private final StopRepository stopRepository; + private final StaticTripRepository staticTripRepository; + private final RouteRepository routeRepository; + private final CalendarService calendarService; + private final StopTimeRepository stopTimeRepository; + + /** + * Gets all stops matching the given stop name + */ + public List getStopsForName(String stopName) { + List stops = stopRepository.findByStopName(stopName); + log.debug("Found {} stops for: {}", stops.size(), stopName); + return stops; + } + + /** + * Filters trips based on service calendar for the given date + */ + public List filterTripsByServiceCalendar(List trips, LocalDate travelDate) { + return trips.stream() + .filter(trip -> { + // Get the trip from static data to access serviceId + Optional staticTrip = + staticTripRepository.findById(trip.getTripId()); + + if (staticTrip.isEmpty()) { + log.debug("[CAL-FILTER] Trip {} not found in static data", trip.getTripId()); + return false; + } + + String serviceId = staticTrip.get().getServiceId(); + boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); + + if (!isActive) { + log.debug("[CAL-FILTER] Trip {} (service: {}) is NOT active on {}", trip.getTripId(), serviceId, travelDate); + } + + return isActive; + }) + .toList(); + } + + /** + * Parse time from GTFS Realtime or Static format and add delay + * Supports both formats: "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" + * For realtime data: adds delay if present + * For static data: returns time in seconds from midnight + */ + public LocalDateTime parseTimeWithDelay(String timeStr, LocalDate travelDate, Integer delay) { + try { + if (timeStr == null) { + return LocalDateTime.now(); + } + + timeStr = timeStr.trim(); + LocalDateTime parsedTime; + + // Check if it contains date (space separator indicates datetime format) + if (timeStr.contains(" ")) { + // Format: "YYYY-MM-DD HH:MM:SS" + parsedTime = LocalDateTime.parse(timeStr, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } else { + // Format: "HH:MM:SS" (time from midnight) + String[] parts = timeStr.split(":"); + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid time format: " + timeStr); + } + int hours = Integer.parseInt(parts[0]); + int minutes = Integer.parseInt(parts[1]); + int seconds = Integer.parseInt(parts[2]); + long timeInSeconds = hours * 3600L + minutes * 60L + seconds; + parsedTime = travelDate.atStartOfDay().plusSeconds(timeInSeconds); + } + + // Add delay if present + if (delay != null) { + parsedTime = parsedTime.plusSeconds(delay); + } + + return parsedTime; + } catch (Exception e) { + log.error("Failed to parse time '{}'. Falling back to current time.", timeStr, e); + return LocalDateTime.now(); + } + } + + /** + * Gets route name from realtime trip data, with fallback to static data + */ + public String getRealtimeRouteName(TripUpdateDTO trip) { + String routeName = "N/A"; + + if (trip.getRouteId() != null && !trip.getRouteId().isEmpty()) { + log.debug(" [7f-1] Trying route from realtime: {}", trip.getRouteId()); + Optional route = routeRepository.findById(trip.getRouteId()); + routeName = route.map(Route::getRouteShortName).orElse("N/A"); + } + + if ("N/A".equals(routeName)) { + log.debug(" [7f-2] Route not found in realtime, checking static trip: {}", trip.getTripId()); + routeName = getStaticRouteNameForTrip(trip.getTripId()); + } + + log.debug(" [7f] Final route name: {}", routeName); + return routeName; + } + + /** + * Gets route name for a trip from static GTFS data + */ + public String getStaticRouteNameForTrip(String tripId) { + Optional trip = staticTripRepository.findById(tripId); + return trip.flatMap(t -> { + log.debug("[9h] Trip route ID: {}", t.getRouteId()); + return routeRepository.findById(t.getRouteId()); + }) + .map(Route::getRouteShortName) + .orElse("N/A"); + } + + /** + * Checks if a connection departure time is not in the past + */ + public boolean isConnectionNotInPast(LocalDateTime departureTime, LocalDateTime travelDateTime) { + if (departureTime.isBefore(travelDateTime) || departureTime.isEqual(travelDateTime)) { + log.debug(" [7g3] Skipping connection - departure time {} is in the past (reference: {})", departureTime, travelDateTime); + return false; + } + return true; + } + + /** + * Gets all stop times for a given stop ID + */ + public List getStopTimesForStop(String stopId) { + return stopTimeRepository.findByStopId(stopId); + } + + /** + * Gets all stop times for a given trip ID + */ + public List getStopTimesForTrip(String tripId) { + return stopTimeRepository.findByTripId(tripId); + } + + /** + * Checks if a trip is active on a given date based on service calendar + */ + public boolean isStaticServiceActiveForTrip(StopTime stopTime, LocalDate travelDate) { + Optional trip = staticTripRepository.findById(stopTime.getTripId()); + + if (trip.isEmpty()) { + return false; + } + + String serviceId = trip.get().getServiceId(); + boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); + + if (!isActive) { + log.debug("[9-CAL] Trip {} (service: {}) is NOT active on {}", stopTime.getTripId(), serviceId, travelDate); + } + + return isActive; + } + + /** + * Finds a stop in a trip's stop list + */ + public Optional findStopInTrip(List tripStops, String stopId) { + return tripStops.stream() + .filter(st -> st.getStopId().equals(stopId)) + .findFirst(); + } +} diff --git a/src/main/java/pl/agh/transit/DirectConnectionService.java b/src/main/java/pl/agh/transit/DirectConnectionService.java index 0d5eb40..122c4ed 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/DirectConnectionService.java @@ -6,20 +6,16 @@ import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; -import pl.agh.transit.gtfs_static.model.Route; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; -import pl.agh.transit.gtfs_static.model.Trip; -import pl.agh.transit.gtfs_static.repository.RouteRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; -import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.time.LocalTime; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; @@ -27,39 +23,38 @@ @Service @AllArgsConstructor public class DirectConnectionService { - private final StopRepository stopRepository; private final StopTimeRepository stopTimeRepository; - private final RouteRepository routeRepository; - private final StaticTripRepository staticTripRepository; private final TripRepository tripRepository; - private final CalendarService calendarService; private final TripMapper tripMapper; + private final ConnectionServiceHelper connectionServiceHelper; + /** - * Finds the fastest direct connection from one stop to another - * Takes into account delays from GTFS Realtime and service calendar + * Finds multiple fastest direct connections from one stop to another + * Returns up to 'count' number of connections, sorted by departure time + * Uses today's date and current time by default */ - public Optional findFastestDirectConnection(String fromStopName, String toStopName) { - return findFastestDirectConnection(fromStopName, toStopName, LocalDate.now()); + public List findFastestDirectConnections(String fromStopName, String toStopName, int count) { + return findFastestDirectConnections(fromStopName, toStopName, LocalDate.now(), LocalTime.now(), count); } /** - * Finds the fastest direct connection from one stop to another for a specific date + * Finds multiple fastest direct connections from one stop to another for a specific date and time + * Returns up to 'count' number of connections, sorted by departure time * Takes into account delays from GTFS Realtime and service calendar - * Checks all possible from stops to find the fastest route */ - public Optional findFastestDirectConnection(String fromStopName, String toStopName, LocalDate travelDate) { - log.debug("[1] findFastestDirectConnection START: {} -> {} on {}", fromStopName, toStopName, travelDate); + public List findFastestDirectConnections(String fromStopName, String toStopName, LocalDate travelDate, LocalTime travelTime, int count) { + log.debug("[1] findFastestDirectConnections START: {} -> {} on {} at {} (count: {})", fromStopName, toStopName, travelDate, travelTime, count); - List fromStops = stopRepository.findByStopName(fromStopName); + List fromStops = connectionServiceHelper.getStopsForName(fromStopName); log.debug("[2] Found {} stops for: {}", fromStops.size(), fromStopName); - List toStops = stopRepository.findByStopName(toStopName); + List toStops = connectionServiceHelper.getStopsForName(toStopName); log.debug("[3] Found {} stops for: {}", toStops.size(), toStopName); if (fromStops.isEmpty() || toStops.isEmpty()) { log.debug("[4] One of the stops is empty, returning empty"); - return Optional.empty(); + return List.of(); } log.debug("[5a] To stop candidates: "); @@ -70,73 +65,49 @@ public Optional findFastestDirectConnection(String fromStopNam log.debug("[6] Found {} trip updates", allTrips.size()); // Filter trips by service calendar for the travel date - List activeTrips = filterTripsByServiceCalendar(allTrips, travelDate); + List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); log.debug("[6-CAL] Active trips after calendar filter: {}", activeTrips.size()); // Check realtime connections from all from stops - Optional realtimeResult = fromStops.stream() + List realtimeResults = fromStops.stream() .flatMap(fromStop -> { log.debug("[5b] Checking from stop: {} ({})", fromStop.getId(), fromStop.getStopName()); return activeTrips.stream() - .flatMap(trip -> findConnectionInTrip(trip, fromStop.getId(), toStops, travelDate)); + .flatMap(trip -> findConnectionInTrip(trip, fromStop.getId(), toStops, travelDate, travelTime)); }) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .toList(); - log.debug("[8] Realtime result: {}", realtimeResult.isEmpty() ? "EMPTY" : realtimeResult.get().getRouteName()); + log.debug("[8] Realtime results: {}", realtimeResults.size()); // Check static connections from all from stops - Optional staticResult = fromStops.stream() + List staticResults = fromStops.stream() .flatMap(fromStop -> { log.debug("[5c] Checking static data from stop: {} ({})", fromStop.getId(), fromStop.getStopName()); - return findStaticConnection(fromStop.getId(), toStops, travelDate).stream(); + return findStaticConnection(fromStop.getId(), toStops, travelDate, travelTime).stream(); }) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .toList(); - log.debug("[10] Static result: {}", staticResult.isEmpty() ? "EMPTY" : staticResult.get().getRouteName()); + log.debug("[10] Static results: {}", staticResults.size()); - // Return the fastest between realtime and static - Optional result = Stream.of(realtimeResult, staticResult) - .filter(Optional::isPresent) - .map(Optional::get) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + // Merge realtime and static results, sort by departure time, and return top 'count' results + List result = Stream.concat(realtimeResults.stream(), staticResults.stream()) + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .distinct() + .limit(count) + .toList(); - log.debug("[11] Final result: {}", result.isEmpty() ? "EMPTY" : "FOUND"); + log.debug("[11] Final results: {}", result.size()); return result; } - /** - * Filters trips based on service calendar for the given date - */ - private List filterTripsByServiceCalendar(List trips, LocalDate travelDate) { - return trips.stream() - .filter(trip -> { - // Get the trip from static data to access serviceId - Optional staticTrip = - staticTripRepository.findById(trip.getTripId()); - - if (staticTrip.isEmpty()) { - log.debug("[CAL-FILTER] Trip {} not found in static data", trip.getTripId()); - return false; - } - - String serviceId = staticTrip.get().getServiceId(); - boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); - - if (!isActive) { - log.debug("[CAL-FILTER] Trip {} (service: {}) is NOT active on {}", trip.getTripId(), serviceId, travelDate); - } - - return isActive; - }) - .toList(); - } /** * Find connection within a single trip update - checks against multiple destination stops * Only considers destination stops that appear AFTER the origin stop in the trip sequence * Filters out connections that have already departed */ - private Stream findConnectionInTrip(TripUpdateDTO trip, String fromStopId, List toStopCandidates, LocalDate travelDate) { + private Stream findConnectionInTrip(TripUpdateDTO trip, String fromStopId, List toStopCandidates, LocalDate travelDate, LocalTime travelTime) { List stops = trip.getStopTimeUpdates(); log.debug(" [7a] Trip: {} has {} stops", trip.getTripId(), stops.size()); @@ -146,24 +117,30 @@ private Stream findConnectionInTrip(TripUpdateDTO trip, String } StopTimeUpdateDTO fromStop = stops.get(fromStopSequence.get()); - LocalDateTime departureTime = parseTimeWithDelay(fromStop.getDepartureTime(), travelDate, fromStop.getDepartureDelay()); + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(fromStop.getDepartureTime(), travelDate, fromStop.getDepartureDelay()); log.debug(" [7g] Departure time: {}", departureTime); - if (!isConnectionNotInPast(departureTime, travelDate)) { + // Create a LocalDateTime from travelDate and travelTime for comparison + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + log.debug(" [7g1] Travel date and time: {}", travelDateTime); + + // Filter out connections that depart before the specified travel time + if (!connectionServiceHelper.isConnectionNotInPast(departureTime, travelDateTime)) { + log.debug(" [7h] Skipping - departure time {} is before travel time {}", departureTime, travelDateTime); return Stream.empty(); } - String routeName = getRealtimeRouteName(trip); + String routeName = connectionServiceHelper.getRealtimeRouteName(trip); - return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate); + return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate, travelTime); } - private Stream findAllToStopsInTrip(List stops, int fromStopSequence, List toStopCandidates, String fromStopId, String routeName, LocalDateTime departureTime, LocalDate travelDate) { + private Stream findAllToStopsInTrip(List stops, int fromStopSequence, List toStopCandidates, String fromStopId, String routeName, LocalDateTime departureTime, LocalDate travelDate, LocalTime travelTime) { return stops.stream() .skip(fromStopSequence + 1) .filter(stop -> isRealtimeDestinationCandidate(stop, toStopCandidates, fromStopId)) .map(toStop -> { - LocalDateTime arrivalTime = parseTimeWithDelay(toStop.getArrivalTime(), travelDate, toStop.getArrivalDelay()); + LocalDateTime arrivalTime = connectionServiceHelper.parseTimeWithDelay(toStop.getArrivalTime(), travelDate, toStop.getArrivalDelay()); log.debug(" [7g2] Arrival time: {}", arrivalTime); return TransportTypeDTO.builder() .routeName(routeName) @@ -194,120 +171,36 @@ private boolean isRealtimeDestinationCandidate(StopTimeUpdateDTO stop, List route = routeRepository.findById(trip.getRouteId()); - routeName = route.map(Route::getRouteShortName).orElse("N/A"); - } - - if ("N/A".equals(routeName)) { - log.debug(" [7f-2] Route not found in realtime, checking static trip: {}", trip.getTripId()); - Optional staticTrip = staticTripRepository.findById(trip.getTripId()); - - if (staticTrip.isPresent()) { - String staticRouteId = staticTrip.get().getRouteId(); - log.debug(" [7f-3] Static trip has route ID: {}", staticRouteId); - routeName = routeRepository.findById(staticRouteId) - .map(Route::getRouteShortName) - .orElse("N/A"); - } - } - - log.debug(" [7f] Final route name: {}", routeName); - return routeName; - } - - private boolean isConnectionNotInPast(LocalDateTime departureTime, LocalDate travelDate) { - LocalDateTime referenceTime = travelDate.equals(LocalDate.now()) ? LocalDateTime.now() : null; - if (referenceTime != null && (departureTime.isBefore(referenceTime) || departureTime.equals(referenceTime))) { - log.debug(" [7g3] Skipping connection - departure time {} is in the past (reference: {})", departureTime, referenceTime); - return false; - } - return true; - } - - - /** - * Parse time from GTFS Realtime or Static format and add delay - * Supports both formats: "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" - * For realtime data: adds delay if present - * For static data: returns time in seconds from midnight - */ - private LocalDateTime parseTimeWithDelay(String timeStr, LocalDate travelDate, Integer delay) { - try { - if (timeStr == null) { - return LocalDateTime.now(); - } - - timeStr = timeStr.trim(); - LocalDateTime parsedTime; - - // Check if it contains date (space separator indicates datetime format) - if (timeStr.contains(" ")) { - // Format: "YYYY-MM-DD HH:MM:SS" - parsedTime = LocalDateTime.parse(timeStr, - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - } else { - // Format: "HH:MM:SS" (time from midnight) - String[] parts = timeStr.split(":"); - if (parts.length != 3) { - throw new IllegalArgumentException("Invalid time format: " + timeStr); - } - int hours = Integer.parseInt(parts[0]); - int minutes = Integer.parseInt(parts[1]); - int seconds = Integer.parseInt(parts[2]); - long timeInSeconds = hours * 3600L + minutes * 60L + seconds; - parsedTime = travelDate.atStartOfDay().plusSeconds(timeInSeconds); - } - - // Add delay if present - if (delay != null) { - parsedTime = parsedTime.plusSeconds(delay); - } - - return parsedTime; - } catch (Exception e) { - log.error("Failed to parse time '{}'. Falling back to current time.", timeStr, e); - return LocalDateTime.now(); - } - } - - /** * Fallback to static GTFS data if no realtime data available - * Filters by service calendar for the given date + * Filters by service calendar for the given date and by travel time */ - private Optional findStaticConnection(String fromStopId, List toStopCandidates, LocalDate travelDate) { - log.debug("[9a] Checking static data from: {} for date: {}", fromStopId, travelDate); + private List findStaticConnection(String fromStopId, List toStopCandidates, LocalDate travelDate, LocalTime travelTime) { + log.debug("[9a] Checking static data from: {} for date: {} at time: {}", fromStopId, travelDate, travelTime); List fromStopTimes = stopTimeRepository.findByStopId(fromStopId); log.debug("[9b] Found {} stop times for from stop", fromStopTimes.size()); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + return fromStopTimes.stream() .filter(fromStopTime -> isStaticServiceActiveForTrip(fromStopTime, travelDate)) + .filter(fromStopTime -> { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(fromStopTime.getDepartureTime(), travelDate, null); + boolean isNotInPast = !departureTime.isBefore(travelDateTime); + if (!isNotInPast) { + log.debug("[9b1] Skipping fromStopTime - departure time {} is before travel time {}", departureTime, travelDateTime); + } + return isNotInPast; + }) .flatMap(fromStopTime -> findStaticDestinationStop(fromStopTime, toStopCandidates, fromStopId)) .map(toStopTime -> buildStaticTransportDTO(toStopTime, fromStopId, travelDate)) - .filter(transport -> transport != null) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .toList(); } private boolean isStaticServiceActiveForTrip(StopTime stopTime, LocalDate travelDate) { - Optional trip = staticTripRepository.findById(stopTime.getTripId()); - - if (trip.isEmpty()) { - return false; - } - - String serviceId = trip.get().getServiceId(); - boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); - - if (!isActive) { - log.debug("[9-CAL] Trip {} (service: {}) is NOT active on {}", stopTime.getTripId(), serviceId, travelDate); - } - - return isActive; + return connectionServiceHelper.isStaticServiceActiveForTrip(stopTime, travelDate); } private Stream findStaticDestinationStop(StopTime fromStopTime, List toStopCandidates, String fromStopId) { @@ -336,10 +229,10 @@ private boolean isStaticSequenceAfterFrom(StopTime toStopTime, StopTime fromStop private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fromStopId, LocalDate travelDate) { log.debug("[9g] Creating result from static data"); - String routeName = getStaticRouteNameForTrip(toStopTime.getTripId()); + String routeName = connectionServiceHelper.getStaticRouteNameForTrip(toStopTime.getTripId()); log.debug("[9i] Route name: {}", routeName); - LocalDateTime arrivalTime = parseTimeWithDelay(toStopTime.getArrivalTime(), travelDate, null); + LocalDateTime arrivalTime = connectionServiceHelper.parseTimeWithDelay(toStopTime.getArrivalTime(), travelDate, null); log.debug("[9j] Arrival time: {}", arrivalTime); // Cache stop times to avoid multiple repository calls @@ -353,13 +246,9 @@ private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fro return null; } - LocalDateTime departureTime = parseTimeWithDelay(staticFromStop.get().getDepartureTime(), travelDate, null); + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(staticFromStop.get().getDepartureTime(), travelDate, null); log.debug("[9k] Departure time: {}", departureTime); - if (departureTime.isBefore(LocalDateTime.now()) || departureTime.equals(LocalDateTime.now())) { - log.debug("[9l] Skipping static connection - departure time {} is in the past", departureTime); - return null; - } return TransportTypeDTO.builder() .routeName(routeName) @@ -368,15 +257,6 @@ private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fro .build(); } - private String getStaticRouteNameForTrip(String tripId) { - Optional trip = staticTripRepository.findById(tripId); - return trip.flatMap(t -> { - log.debug("[9h] Trip route ID: {}", t.getRouteId()); - return routeRepository.findById(t.getRouteId()); - }) - .map(Route::getRouteShortName) - .orElse("N/A"); - } private List getTripUpdates() { @@ -390,3 +270,4 @@ private List getTripUpdates() { return result; } } + diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java new file mode 100644 index 0000000..818d1b6 --- /dev/null +++ b/src/main/java/pl/agh/transit/StopController.java @@ -0,0 +1,52 @@ +package pl.agh.transit; + +import lombok.AllArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import pl.agh.transit.dto.PagedResponseDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.NextDepartureDTO; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@AllArgsConstructor +public class StopController { + + private final StopService stopService; + + @GetMapping(value = "/stops", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAllStops( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "stopName") String sortBy + ) { + // Limit max page size to prevent performance issues + if (size > 100) { + size = 100; + } + + PagedResponseDTO response = stopService.getAllStops(page, size, sortBy); + return ResponseEntity.ok(response); + } + + @GetMapping(value = "/stops/routes", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getNextDepartures( + @RequestParam String stopName, + @RequestParam String routeName, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) java.time.LocalTime time + ) { + + LocalDate travelDate = date != null ? date : LocalDate.now(); + java.time.LocalTime travelTime = time != null ? time : java.time.LocalTime.now(); + List departures = stopService.getNextDeparturesForStopAndRoute( + stopName, routeName, travelDate, travelTime, 5); + return ResponseEntity.ok(departures); + } +} diff --git a/src/main/java/pl/agh/transit/StopService.java b/src/main/java/pl/agh/transit/StopService.java new file mode 100644 index 0000000..d5d0178 --- /dev/null +++ b/src/main/java/pl/agh/transit/StopService.java @@ -0,0 +1,458 @@ +package pl.agh.transit; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import pl.agh.transit.dto.PagedResponseDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.dto.NextDepartureDTO; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.gtfs_static.model.Trip; +import pl.agh.transit.gtfs_static.model.Route; +import pl.agh.transit.gtfs_static.repository.StaticTripRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.RouteRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Service for managing stops with JDBC and real-time departures + */ +@Slf4j +@Service +@AllArgsConstructor +public class StopService { + + private final StopRepository stopRepository; + private final JdbcTemplate jdbcTemplate; + private final ConnectionServiceHelper connectionServiceHelper; + private final StaticTripRepository staticTripRepository; + private final RouteRepository routeRepository; + private final TripRepository tripRepository; + private final TripMapper tripMapper; + + /** + * Get all stops with pagination + */ + public PagedResponseDTO getAllStops(int page, int size, String sortBy) { + log.debug("Getting stops - page: {}, size: {}, sortBy: {}", page, size, sortBy); + + int offset = page * size; + + String countSql = "SELECT COUNT(*) FROM stops"; + Long totalElements = jdbcTemplate.queryForObject(countSql, Long.class); + if (totalElements == null) totalElements = 0L; + + int totalPages = (int) Math.ceil((double) totalElements / size); + + String validSortBy = sanitizeSortColumn(sortBy); + String sql = String.format( + "SELECT * FROM stops ORDER BY %s LIMIT ? OFFSET ?", + validSortBy + ); + + List stops = jdbcTemplate.query(sql, new StopRowMapper(), size, offset); + + List stopDTOs = stops.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + + log.debug("Found {} stops on page {} of {}", stopDTOs.size(), page, totalPages); + + return PagedResponseDTO.builder() + .content(stopDTOs) + .pageNumber(page) + .totalPages(totalPages) + .totalElements(totalElements) + .pageSize(size) + .hasNext(page < totalPages - 1) + .hasPrevious(page > 0) + .build(); + } + + /** + * Sanitize sort column to prevent SQL injection + */ + private String sanitizeSortColumn(String sortBy) { + + if (sortBy == null || sortBy.isBlank()) { + return "stop_name"; + } + + return switch (sortBy.toLowerCase().trim()) { + case "stopid", "stop_id", "id" -> "id"; + case "stoplat", "stop_lat" -> "stop_lat"; + case "stoplon", "stop_lon" -> "stop_lon"; + default -> "stop_name"; + }; + } + + /** + * Row mapper for Stop entity + */ + private static class StopRowMapper implements RowMapper { + @Override + public Stop mapRow(ResultSet rs, int rowNum) throws SQLException { + Stop stop = new Stop(); + stop.setId(rs.getString("id")); + stop.setStopName(rs.getString("stop_name")); + stop.setStopLat(rs.getDouble("stop_lat")); + stop.setStopLon(rs.getDouble("stop_lon")); + return stop; + } + } + + private StopDTO convertToDTO(Stop stop) { + return StopDTO.builder() + .id(stop.getId()) + .name(stop.getStopName()) + .latitude(stop.getStopLat()) + .longitude(stop.getStopLon()) + .build(); + } + + /** + * Get next departures for a stop and route. + * Merges realtime and static data, filters by service calendar. + * Accepts names instead of IDs. + * Supplements results from next day if less than limit. + */ + public List getNextDeparturesForStopAndRoute( + String stopName, + String routeName, + LocalDate travelDate, + java.time.LocalTime travelTime, + int limit + ) { + log.debug("[0] Getting next {} departures for stop: {}, route: {}, date: {}, time: {}", + limit, stopName, routeName, travelDate, travelTime); + + List results = getNextDeparturesForDate(stopName, routeName, travelDate, travelTime, limit); + + // If insufficient results, fetch from next day + if (results.size() < limit) { + int remainingCount = limit - results.size(); + log.debug("[0c] Only {} results for initial date, fetching {} more from next day", results.size(), remainingCount); + + LocalDate nextDate = travelDate.plusDays(1); + List nextDayResults = getNextDeparturesForDate(stopName, routeName, nextDate, travelTime, remainingCount); + results = Stream.concat(results.stream(), nextDayResults.stream()) + .limit(limit) + .toList(); + + log.debug("[0d] Added {} results from next day, total: {}", nextDayResults.size(), results.size()); + } + + log.debug("[0e] Final results (including next day if needed): {}", results.size()); + return results; + } + + + private List getNextDeparturesForDate( + String stopName, + String routeName, + LocalDate travelDate, + java.time.LocalTime travelTime, + int limit + ) { + log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, time: {}, limit: {}", + stopName, routeName, travelDate, travelTime, limit); + + List stops = connectionServiceHelper.getStopsForName(stopName); + if (stops.isEmpty()) { + log.debug("[0a] Stop not found: {}", stopName); + return List.of(); + } + Stop stop = stops.get(0); + String stopId = stop.getId(); + + Optional routeOptional = routeRepository.findByRouteShortName(routeName); + if (routeOptional.isEmpty()) { + log.debug("[0b] Route not found: {}", routeName); + return List.of(); + } + Route route = routeOptional.get(); + String routeId = route.getId(); + + log.debug("[1] Resolved to stopId: {}, routeId: {}", stopId, routeId); + + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + log.debug("[2] Travel datetime: {}", travelDateTime); + + List allTrips = getTripUpdates(); + log.debug("[3] Found {} trip updates", allTrips.size()); + + List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); + log.debug("[4] Active trips after calendar filter: {}", activeTrips.size()); + + + List realtimeDepartures = activeTrips.stream() + .filter(trip -> isRealtimeTripForRoute(trip, routeId)) + .flatMap(trip -> findStopInRealtimeTrip(trip, stopId, travelDateTime, travelDate, stop.getStopName(), routeName)) + .sorted((dto1, dto2) -> compareByDepartureTime(dto1, dto2, travelDate)) + .toList(); + + log.debug("[5] Realtime departures: {}", realtimeDepartures.size()); + + List staticDepartures = findStaticDepartures( + stopId, routeId, travelDate, travelDateTime, stop.getStopName(), routeName); + + log.debug("[6] Static departures: {}", staticDepartures.size()); + + List result = Stream.concat( + realtimeDepartures.stream(), + staticDepartures.stream() + ) + .sorted((dto1, dto2) -> compareByDepartureTime(dto1, dto2, travelDate)) + .distinct() + .limit(limit) + .map(this::normalizeDepartureTime) + .toList(); + + log.debug("[7] Final results for date {}: {}", travelDate, result.size()); + return result; + } + + private List getTripUpdates() { + log.debug("[3a] getTripUpdates START"); + List entities = + tripRepository.getEntitiesOrEmpty(); + log.debug("[3b] Feed entities: {}", entities.size()); + + List result = tripMapper.toDtoList(entities); + log.debug("[3c] getTripUpdates END: {} trips", result.size()); + return result; + } + + /** + * Checks if realtime trip belongs to the specified route + */ + private boolean isRealtimeTripForRoute(TripUpdateDTO trip, String routeId) { + if (trip.getRouteId() != null && trip.getRouteId().equals(routeId)) { + log.debug(" [4a] Trip {} matches route {} in realtime data", + trip.getTripId(), routeId); + return true; + } + + Optional staticTrip = staticTripRepository.findById(trip.getTripId()); + boolean matches = staticTrip.isPresent() + && staticTrip.get().getRouteId().equals(routeId); + + if (matches) { + log.debug(" [4b] Trip {} matches route {} in static data", + trip.getTripId(), routeId); + } + + return matches; + } + + /** + * Finds the stop in a realtime trip and returns departure info + */ + private Stream findStopInRealtimeTrip( + TripUpdateDTO trip, + String stopId, + LocalDateTime travelDateTime, + LocalDate travelDate, + String stopName, + String routeName + ) { + List stopTimeUpdates = trip.getStopTimeUpdates(); + + if (stopTimeUpdates == null || stopTimeUpdates.isEmpty()) { + return Stream.empty(); + } + + log.debug(" [5a] Checking trip: {} with {} stops", + trip.getTripId(), stopTimeUpdates.size()); + + return stopTimeUpdates.stream() + .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) + .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate, trip.getTripId(), stopId)) + .map(stopUpdate -> buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName, trip.getTripId(), stopId)); + } + + private boolean isStopUpdateValid( + StopTimeUpdateDTO stopUpdate, + LocalDateTime travelDateTime, + LocalDate travelDate, + String tripId, + String stopId + ) { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + stopUpdate.getDepartureTime(), + travelDate, + stopUpdate.getDepartureDelay()); + + return connectionServiceHelper.isConnectionNotInPast(departureTime, travelDateTime); + } + + private NextDepartureDTO buildNextDepartureDTOFromUpdate( + StopTimeUpdateDTO stopUpdate, + String stopName, + String routeName, + String tripId, + String stopId + ) { + log.debug(" [5c] Found realtime departure at stop {} in trip {}", stopId, tripId); + + return NextDepartureDTO.builder() + .departureTime(stopUpdate.getDepartureTime()) + .departureDelay(stopUpdate.getDepartureDelay()) + .stopName(stopName) + .routeName(routeName) + .build(); + } + + /** + * Find static departures at least 59 mins after current time + */ + private List findStaticDepartures( + String stopId, + String routeId, + LocalDate travelDate, + LocalDateTime travelDateTime, + String stopName, + String routeName + ) { + log.debug("[6a] Checking static data for stop: {}, route: {}", stopId, routeId); + + final LocalDateTime minStaticTime = travelDateTime.isAfter(LocalDateTime.now().plusMinutes(59)) + ? travelDateTime + : LocalDateTime.now().plusMinutes(59); + + log.debug("[6a1] Initial travel time: {}, min static time: {}", travelDateTime, minStaticTime); + + List stopTimesForStop = connectionServiceHelper.getStopTimesForStop(stopId); + log.debug("[6b] Found {} stop times for stop", stopTimesForStop.size()); + + return stopTimesForStop.stream() + .filter(st -> isStaticStopTimeForRoute(st, routeId)) + .filter(st -> connectionServiceHelper.isStaticServiceActiveForTrip(st, travelDate)) + .filter(st -> { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + st.getDepartureTime(), travelDate, null); + + boolean isAfterMinTime = !departureTime.isBefore(minStaticTime); + + if (!isAfterMinTime) { + log.debug("[6c] Skipping static - departure {} is before min time {}", + departureTime, minStaticTime); + } + + return isAfterMinTime; + }) + .map(st -> convertStopTimeToDTO(st, stopName, routeName)) + .peek(dto -> log.debug("[6d] Found static departure at stop {} for trip {}", + stopId, routeName)) + .toList(); + } + + + private NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName, String routeName) { + return NextDepartureDTO.builder() + .departureTime(stopTime.getDepartureTime()) + .stopName(stopName) + .routeName(routeName) + .build(); + } + + private boolean isStaticStopTimeForRoute(StopTime stopTime, String routeId) { + Optional trip = staticTripRepository.findById(stopTime.getTripId()); + boolean matches = trip.isPresent() && trip.get().getRouteId().equals(routeId); + + if (matches) { + log.debug(" [6b1] Stop time for trip {} matches route {}", + stopTime.getTripId(), routeId); + } + + return matches; + } + + private int compareByDepartureTime(NextDepartureDTO dto1, NextDepartureDTO dto2, LocalDate travelDate) { + LocalDateTime time1 = connectionServiceHelper.parseTimeWithDelay( + dto1.getDepartureTime(), travelDate, dto1.getDepartureDelay()); + LocalDateTime time2 = connectionServiceHelper.parseTimeWithDelay( + dto2.getDepartureTime(), travelDate, dto2.getDepartureDelay()); + + return time1.compareTo(time2); + } + + /** + * Normalize times > 23:59 to times from start of day (e.g., "25:30" -> "01:30") + * When hours >= 24, also increment the date by 1 day + */ + private NextDepartureDTO normalizeDepartureTime(NextDepartureDTO dto) { + String departureTime = dto.getDepartureTime(); + + if (departureTime == null) { + return dto; + } + + String timeToNormalize; + String datePrefix = ""; + String dateStr = null; + + if (departureTime.contains(" ")) { + + String[] dateTimeParts = departureTime.split(" ", 2); + dateStr = dateTimeParts[0]; + datePrefix = dateTimeParts[0] + " "; + timeToNormalize = dateTimeParts[1]; + } else { + + timeToNormalize = departureTime; + } + + String[] parts = timeToNormalize.split(":"); + if (parts.length >= 2) { + try { + int hours = Integer.parseInt(parts[0]); + + if (hours >= 24) { + hours -= 24; + String normalizedTime = String.format("%02d:%s:%s", + hours, parts[1], parts.length > 2 ? parts[2] : "00"); + + String fullNormalizedTime; + // If date is present, increment it by 1 day + if (dateStr != null) { + LocalDate date = LocalDate.parse(dateStr); + LocalDate nextDate = date.plusDays(1); + fullNormalizedTime = nextDate + " " + normalizedTime; + } else { + fullNormalizedTime = datePrefix + normalizedTime; + } + + log.debug("[7a] Normalized departure time: {} -> {}", departureTime, fullNormalizedTime); + + return NextDepartureDTO.builder() + .departureTime(fullNormalizedTime) + .stopName(dto.getStopName()) + .routeName(dto.getRouteName()) + .build(); + } + } catch (NumberFormatException e) { + log.debug("[7a1] Failed to parse departure time: {}", departureTime, e); + } + } + + return dto; + } + +} \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/TripController.java b/src/main/java/pl/agh/transit/TripController.java index d84e002..fb0bc51 100644 --- a/src/main/java/pl/agh/transit/TripController.java +++ b/src/main/java/pl/agh/transit/TripController.java @@ -10,8 +10,8 @@ import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; -import java.util.Optional; @RestController @AllArgsConstructor @@ -25,7 +25,7 @@ public List getTrips() { return tripService.getTripUpdates(); } - @GetMapping(value = "/trips/random-stop-time", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/random-stop-time", produces = MediaType.APPLICATION_JSON_VALUE) public StopTimeUpdateDTO getRandomStopTime() { return tripService.getRandomStopTime(); } @@ -35,8 +35,18 @@ public TransportTypeDTO getDirectConnection(@RequestParam String from, @RequestP return tripService.getDirectConnection(from, to); } - @GetMapping(value = "/fastest-direct-connection/with-date", produces = MediaType.APPLICATION_JSON_VALUE) - public Optional getDirectConnectionWithDate(@RequestParam String from, @RequestParam String to, @RequestParam LocalDate travelDate) { - return directConnectionService.findFastestDirectConnection(from, to, travelDate); + @GetMapping(value = "/fastest-direct-connections", produces = MediaType.APPLICATION_JSON_VALUE) + public List getFastestConnections( + @RequestParam String from, + @RequestParam String to, + @RequestParam(required = false) LocalDate travelDate, // (YYYY-MM-DD) + @RequestParam(required = false) LocalTime travelTime, // (HH:mm) + @RequestParam(defaultValue = "3") int count + ) { + if (travelDate != null && travelTime != null) { + return directConnectionService.findFastestDirectConnections(from, to, travelDate, travelTime, count); + } else { + return directConnectionService.findFastestDirectConnections(from, to, count); + } } } \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/TripService.java b/src/main/java/pl/agh/transit/TripService.java index a9f879f..2778912 100644 --- a/src/main/java/pl/agh/transit/TripService.java +++ b/src/main/java/pl/agh/transit/TripService.java @@ -39,6 +39,14 @@ public StopTimeUpdateDTO getRandomStopTime(){ .orElseThrow(() -> new ServiceUnavailableException("No stop time updates available")); } + public TransportTypeDTO getDirectConnection(String from , String to){ + List connection = directConnectionService.findFastestDirectConnections(from, to, 1); + if (connection.isEmpty()) { + throw new ServiceUnavailableException("No connection available from " + from + " to " + to); + } + return connection.getFirst(); + } + private Optional pickRandom(List list){ if (list.isEmpty()){ return Optional.empty(); @@ -46,13 +54,4 @@ private Optional pickRandom(List list){ int randomIndex = random.nextInt(list.size()); return Optional.of(list.get(randomIndex)); } - - public TransportTypeDTO getDirectConnection(String from , String to){ - Optional connection = directConnectionService.findFastestDirectConnection(from, to); - if (connection.isEmpty()) { - throw new ServiceUnavailableException("No connection available from " + from + " to " + to); - } - return connection.get(); - } - } diff --git a/src/main/java/pl/agh/transit/dto/NextDepartureDTO.java b/src/main/java/pl/agh/transit/dto/NextDepartureDTO.java new file mode 100644 index 0000000..86a4855 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/NextDepartureDTO.java @@ -0,0 +1,16 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * DTO representing next departure for a specific stop and route + */ +@Data +@Builder +public class NextDepartureDTO { + String departureTime; + Integer departureDelay; + String stopName; + String routeName; +} diff --git a/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java b/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java new file mode 100644 index 0000000..28014d3 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java @@ -0,0 +1,18 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PagedResponseDTO { + private List content; + private int pageNumber; + private int totalPages; + private long totalElements; + private int pageSize; + private boolean hasNext; + private boolean hasPrevious; +} diff --git a/src/main/java/pl/agh/transit/dto/StopDTO.java b/src/main/java/pl/agh/transit/dto/StopDTO.java new file mode 100644 index 0000000..cacc216 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/StopDTO.java @@ -0,0 +1,13 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class StopDTO { + private String id; + private String name; + private double latitude; + private double longitude; +} diff --git a/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java b/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java index 9a5dc62..a9bbd08 100644 --- a/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java +++ b/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java @@ -7,6 +7,7 @@ @Data @Builder public class StopTimeUpdateDTO { + String tripId; String stopId; String arrivalTime; String departureTime; diff --git a/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java b/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java index f834a22..30d63a6 100644 --- a/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java +++ b/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java @@ -3,8 +3,10 @@ import org.springframework.data.repository.ListCrudRepository; import org.springframework.stereotype.Repository; import pl.agh.transit.gtfs_static.model.Route; +import java.util.Optional; @Repository public interface RouteRepository extends ListCrudRepository { + Optional findByRouteShortName(String routeShortName); } diff --git a/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java b/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java index a09d447..a8a9323 100644 --- a/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java +++ b/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java @@ -9,4 +9,4 @@ @Repository public interface StopRepository extends ListCrudRepository { List findByStopName(String stopName); -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5eb76ff..9b69ecc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,4 +25,6 @@ spring.h2.console.enabled=true spring.h2.console.path=/h2 spring.sql.init.mode=always -spring.sql.init.platform=h2 \ No newline at end of file +spring.sql.init.platform=h2 + +logging.level.pl.agh.transit=DEBUG diff --git a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java new file mode 100644 index 0000000..493b99c --- /dev/null +++ b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java @@ -0,0 +1,317 @@ +package pl.agh.transit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.gtfs_static.model.Route; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.Trip; +import pl.agh.transit.gtfs_static.repository.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StaticTripRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ConnectionServiceHelper Unit Tests") +class ConnectionServiceHelperTest { + + @Mock + private StopRepository stopRepository; + + @Mock + private StaticTripRepository staticTripRepository; + + @Mock + private RouteRepository routeRepository; + + @Mock + private CalendarService calendarService; + + @InjectMocks + private ConnectionServiceHelper connectionServiceHelper; + + private Stop stop1; + private Stop stop2; + private Trip trip; + private Route route; + + @BeforeEach + void setUp() { + stop1 = new Stop(); + stop1.setId("stop-1"); + stop1.setStopName("Main Station"); + + stop2 = new Stop(); + stop2.setId("stop-2"); + stop2.setStopName("City Center"); + + trip = new Trip(); + trip.setId("trip-1"); + trip.setRouteId("route-1"); + trip.setServiceId("service-1"); + + route = new Route(); + route.setId("route-1"); + route.setRouteShortName("1A"); + } + + @Test + @DisplayName("Should get stops by name") + void getStopsForName_Success() { + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(stop1)); + + List result = connectionServiceHelper.getStopsForName("Main Station"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getStopName()).isEqualTo("Main Station"); + } + + @Test + @DisplayName("Should return empty list when stops not found") + void getStopsForName_NotFound() { + when(stopRepository.findByStopName("Unknown")).thenReturn(List.of()); + + List result = connectionServiceHelper.getStopsForName("Unknown"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should filter trips by service calendar") + void filterTripsByServiceCalendar_RemovesInactiveTrips() { + TripUpdateDTO activeTrip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .build(); + + TripUpdateDTO inactiveTrip = TripUpdateDTO.builder() + .tripId("trip-2") + .routeId("route-2") + .build(); + + Trip trip2 = new Trip(); + trip2.setId("trip-2"); + trip2.setServiceId("service-2"); + + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); + when(staticTripRepository.findById("trip-2")).thenReturn(Optional.of(trip2)); + when(calendarService.isServiceActiveOnDate("service-1", LocalDate.now())).thenReturn(true); + when(calendarService.isServiceActiveOnDate("service-2", LocalDate.now())).thenReturn(false); + + List trips = List.of(activeTrip, inactiveTrip); + List result = connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now()); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTripId()).isEqualTo("trip-1"); + } + + @Test + @DisplayName("Should skip trips without static data") + void filterTripsByServiceCalendar_SkipsTripsWithoutStaticData() { + TripUpdateDTO tripWithoutStaticData = TripUpdateDTO.builder() + .tripId("trip-unknown") + .routeId("route-1") + .build(); + + when(staticTripRepository.findById("trip-unknown")).thenReturn(Optional.empty()); + + List trips = List.of(tripWithoutStaticData); + List result = connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should parse time in HH:MM:SS format") + void parseTimeWithDelay_HHmmssFormat() { + LocalDate date = LocalDate.of(2026, 1, 15); + + LocalDateTime result = connectionServiceHelper.parseTimeWithDelay("14:30:45", date, null); + + LocalDateTime expected = date.atStartOfDay().plusHours(14).plusMinutes(30).plusSeconds(45); + assertThat(result).isEqualTo(expected); + } + + @Test + @DisplayName("Should parse time and apply delay") + void parseTimeWithDelay_WithDelay() { + LocalDate date = LocalDate.of(2026, 1, 15); + + LocalDateTime result = connectionServiceHelper.parseTimeWithDelay("14:30:45", date, 60); + + LocalDateTime expected = date.atStartOfDay().plusHours(14).plusMinutes(30).plusSeconds(45).plusSeconds(60); + assertThat(result).isEqualTo(expected); + } + + @Test + @DisplayName("Should fallback to now when time is null") + void parseTimeWithDelay_NullTime() { + LocalDateTime before = LocalDateTime.now(); + LocalDateTime result = connectionServiceHelper.parseTimeWithDelay(null, LocalDate.now(), null); + LocalDateTime after = LocalDateTime.now(); + + assertThat(result).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + } + + @Test + @DisplayName("Should fallback to now when time format is invalid") + void parseTimeWithDelay_InvalidFormat() { + LocalDateTime before = LocalDateTime.now(); + LocalDateTime result = connectionServiceHelper.parseTimeWithDelay("INVALID", LocalDate.now(), null); + LocalDateTime after = LocalDateTime.now(); + + assertThat(result).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + } + + @Test + @DisplayName("Should parse time with datetime format") + void parseTimeWithDelay_DatetimeFormat() { + LocalDateTime result = connectionServiceHelper.parseTimeWithDelay("2026-01-15 14:30:45", LocalDate.of(2026, 1, 15), null); + + LocalDateTime expected = LocalDateTime.of(2026, 1, 15, 14, 30, 45); + assertThat(result).isEqualTo(expected); + } + + @Test + @DisplayName("Should get route name from realtime data") + void getRealtimeRouteName_FromRealtimeData() { + TripUpdateDTO trip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .build(); + + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getRealtimeRouteName(trip); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should fallback to static data when realtime route not found") + void getRealtimeRouteName_FallbackToStaticData() { + TripUpdateDTO trip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-unknown") + .build(); + + when(routeRepository.findById("route-unknown")).thenReturn(Optional.empty()); + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(this.trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getRealtimeRouteName(trip); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should return N/A when route not found anywhere") + void getRealtimeRouteName_NotFound() { + TripUpdateDTO trip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-unknown") + .build(); + + when(routeRepository.findById("route-unknown")).thenReturn(Optional.empty()); + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.empty()); + + String result = connectionServiceHelper.getRealtimeRouteName(trip); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return N/A when routeId is null") + void getRealtimeRouteName_NullRouteId() { + TripUpdateDTO trip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId(null) + .build(); + + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(this.trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getRealtimeRouteName(trip); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should get route name from static trip data") + void getStaticRouteNameForTrip_Success() { + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-1"); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should return N/A when trip not found in static data") + void getStaticRouteNameForTrip_TripNotFound() { + when(staticTripRepository.findById("trip-unknown")).thenReturn(Optional.empty()); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-unknown"); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return N/A when route not found in static data") + void getStaticRouteNameForTrip_RouteNotFound() { + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.empty()); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-1"); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return false when departure time is in the past (same date)") + void isConnectionNotInPast_TimeInPast() { + LocalDateTime today = LocalDateTime.now(); + LocalDateTime pastTime = today.minusHours(1); + + boolean result = connectionServiceHelper.isConnectionNotInPast(pastTime, today); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Should return true when departure time is in the future") + void isConnectionNotInPast_TimeFuture() { + LocalDateTime today = LocalDateTime.now(); + LocalDateTime futureTime = today.plusHours(20); + + boolean result = connectionServiceHelper.isConnectionNotInPast(futureTime, today); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Should return true when date is after travel date") + void isConnectionNotInPast_DifferentDate() { + LocalDateTime travelDate = LocalDateTime.now(); + LocalDateTime time = travelDate.plusHours(1); + + boolean result = connectionServiceHelper.isConnectionNotInPast(time, travelDate); + + assertThat(result).isTrue(); + } +} + diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java index 1c04551..40c281b 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -13,15 +13,16 @@ import pl.agh.transit.gtfs_static.model.StopTime; import pl.agh.transit.gtfs_static.model.Trip; import pl.agh.transit.gtfs_static.repository.CalendarRepository; -import pl.agh.transit.gtfs_static.repository.RouteRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; -import pl.agh.transit.gtfs_static.repository.StopRepository; -import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; -import java.util.Optional; +import java.time.LocalTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import pl.agh.transit.gtfs_static.repository.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StaticTripRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.StopTimeRepository; @SpringBootTest @Transactional @@ -100,7 +101,7 @@ private void setupTestData() { calendar.setSunday(true); calendarRepository.save(calendar); - // Create trip 1 (slower: 10:00 - 10:20 = 20 minutes) + // Create trip 1 (earliest departure: 23:01:00, arrives 23:20:00) Trip trip1 = new Trip(); trip1.setId("trip-1"); trip1.setRouteId("route-1"); @@ -123,7 +124,7 @@ private void setupTestData() { stopTime1To.setStopSequence(2); stopTimeRepository.save(stopTime1To); - // Create trip 2 (faster: 10:05 - 10:10 = 5 minutes) + // Create trip 2 (later departure: 23:06:00, arrives 23:10:00) Trip trip2 = new Trip(); trip2.setId("trip-2"); trip2.setRouteId("route-2"); @@ -150,89 +151,89 @@ private void setupTestData() { @Test @DisplayName("Should find fastest direct connection from static data") void findFastestDirectConnection_staticData_returnsFastestRoute() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return empty when no direct connection exists") - void findFastestDirectConnection_noDirectConnection_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Final Stop", LocalDate.now()); + void findFastestDirectConnection_noDirectConnections_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Final Stop", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when source stop does not exist") - void findFastestDirectConnection_sourceStopNotFound_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Unknown Station", "City Center", LocalDate.now()); + void findFastestDirectConnections_sourceStopNotFound_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Unknown Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when destination stop does not exist") - void findFastestDirectConnection_destinationStopNotFound_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Unknown Station", LocalDate.now()); + void findFastestDirectConnections_destinationStopNotFound_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Unknown Station", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when both stops are the same") - void findFastestDirectConnection_sameSourceAndDestination_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Main Station", LocalDate.now()); + void findFastestDirectConnections_sameSourceAndDestination_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Main Station", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when travel date is outside calendar range") - void findFastestDirectConnection_travelDateOutsideCalendar_returnsEmpty() { + void findFastestDirectConnections_travelDateOutsideCalendar_returnsEmpty() { LocalDate futureDate = LocalDate.now().plusYears(1); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", futureDate); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", futureDate, LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should find connection when travel date is within calendar range") - void findFastestDirectConnection_validTravelDate_returnsConnection() { + void findFastestDirectConnection_validTravelDate_returnsConnections() { LocalDate validDate = LocalDate.now(); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", validDate); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", validDate, LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test - @DisplayName("Should compare travel times and return fastest route") - void findFastestDirectConnection_comparesTravelTimes_returnsFastestRoute() { - // Trip 1: 10:00 - 10:20 = 20 minutes - // Trip 2: 10:05 - 10:10 = 5 minutes - // Should select trip 2 because it's faster + @DisplayName("Should compare departure times and return earliest departure route") + void findFastestDirectConnections_comparesDepartureTimes_returnsEarliestDepartureRoute() { + // Trip 1: Departure 23:01:00, Arrival 23:20:00 + // Trip 2: Departure 23:06:00, Arrival 23:10:00 + // Should select trip 1 because it departs earliest - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should ignore trips not active on given date") - void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { + void findFastestDirectConnections_inactiveServicesIgnored_returnsActiveRoute() { // Create inactive calendar for today Calendar inactiveCalendar = new Calendar(); inactiveCalendar.setId("service-inactive"); @@ -276,17 +277,17 @@ void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { stopTime3To.setStopSequence(2); stopTimeRepository.save(stopTime3To); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - // Should select route 2B (active), not 3C (inactive) - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + // Should select route 1A (active, earliest departure), not 3C (inactive) + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should handle multiple stops with same name") - void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { + void findFastestDirectConnections_multipleStopsSameName_handlesCorrectly() { // Create second stop with same name Stop duplicateStop = new Stop(); duplicateStop.setId("stop-main-2"); @@ -295,22 +296,22 @@ void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { duplicateStop.setStopLon(19.05); stopRepository.save(duplicateStop); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); // Should find connection even with multiple stops with same name - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return valid arrival time for connection") - void findFastestDirectConnection_returnsNonNullArrivalTime() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + void findFastestDirectConnections_returnsNonNullArrivalTime() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isNotNull(); } } diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java index 112d450..436dc7d 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -11,50 +11,32 @@ import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; -import pl.agh.transit.gtfs_static.model.Route; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; -import pl.agh.transit.gtfs_static.model.Trip; -import pl.agh.transit.gtfs_static.repository.RouteRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; -import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @DisplayName("DirectConnectionService Unit Tests") class DirectConnectionServiceTest { - @Mock - private StopRepository stopRepository; - @Mock private StopTimeRepository stopTimeRepository; - @Mock - private RouteRepository routeRepository; - - @Mock - private StaticTripRepository staticTripRepository; - @Mock private TripRepository tripRepository; @Mock - private CalendarService calendarService; + private ConnectionServiceHelper connectionServiceHelper; @Mock TripMapper tripMapper; @@ -64,11 +46,6 @@ class DirectConnectionServiceTest { private Stop fromStop; private Stop toStop; - private Route route; - private Trip trip; - private StopTime stopTimeFrom; - private StopTime stopTimeTo; - private TripUpdateDTO tripUpdateDTO; @BeforeEach void setUp() { @@ -83,56 +60,11 @@ void setUp() { toStop.setStopName("City Center"); toStop.setStopLat(50.1); toStop.setStopLon(19.1); - - route = new Route(); - route.setId("route-1"); - route.setRouteShortName("1A"); - - trip = new Trip(); - trip.setId("trip-1"); - trip.setRouteId("route-1"); - trip.setServiceId("service-1"); - - stopTimeFrom = new StopTime(); - stopTimeFrom.setTripId("trip-1"); - stopTimeFrom.setStopId("stop-1"); - stopTimeFrom.setArrivalTime("23:00:00"); - stopTimeFrom.setDepartureTime("23:01:00"); - stopTimeFrom.setStopSequence(1); - - stopTimeTo = new StopTime(); - stopTimeTo.setTripId("trip-1"); - stopTimeTo.setStopId("stop-2"); - stopTimeTo.setArrivalTime("23:15:00"); - stopTimeTo.setDepartureTime("23:16:00"); - stopTimeTo.setStopSequence(2); - - tripUpdateDTO = TripUpdateDTO.builder() - .tripId("trip-1") - .routeId("route-1") - .tripStartDate("20260106") - .stopTimeUpdates(List.of( - StopTimeUpdateDTO.builder() - .stopId("stop-1") - .arrivalTime("23:00:00") - .departureTime("23:01:00") - .arrivalDelay(0) - .departureDelay(0) - .build(), - StopTimeUpdateDTO.builder() - .stopId("stop-2") - .arrivalTime("23:15:00") - .departureTime("23:16:00") - .arrivalDelay(0) - .departureDelay(0) - .build() - )) - .build(); } @Test @DisplayName("Should find fastest direct connection using realtime data") - void findFastestDirectConnection_WithRealtimeData() { + void findFastestDirectConnections_WithRealtimeData() { TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") .routeId("route-1") @@ -150,79 +82,77 @@ void findFastestDirectConnection_WithRealtimeData() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(com.google.transit.realtime.GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(anyList())).thenReturn(List.of(dto)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return empty when no stops found") - void findFastestDirectConnection_NoStopsFound() { + void findFastestDirectConnections_NoStopsFound() { - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of()); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of()); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when destination stops not found") - void findFastestDirectConnection_NoDestinationStopsFound() { + void findFastestDirectConnections_NoDestinationStopsFound() { - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("Unknown Destination")).thenReturn(List.of()); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("Unknown Destination")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "Unknown Destination", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "Unknown Destination", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } + @Test @DisplayName("Should filter trips by service calendar") void filterTripsByServiceCalendar_ShouldRemoveInactiveServices() { - TripUpdateDTO activeTrip = tripUpdateDTO; + TripUpdateDTO activeTrip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .stopTimeUpdates(List.of()) + .build(); TripUpdateDTO inactiveTrip = TripUpdateDTO.builder() .tripId("trip-2") .routeId("route-2") .stopTimeUpdates(List.of()) .build(); - Trip trip2 = new Trip(); - trip2.setId("trip-2"); - trip2.setRouteId("route-2"); - trip2.setServiceId("service-2"); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(staticTripRepository.findById("trip-2")).thenReturn(Optional.of(trip2)); - when(calendarService.isServiceActiveOnDate("service-1", LocalDate.now())).thenReturn(true); - when(calendarService.isServiceActiveOnDate("service-2", LocalDate.now())).thenReturn(false); - - List trips = List.of(activeTrip, inactiveTrip); - List result = trips.stream() - .filter(t -> { - Optional staticTrip = staticTripRepository.findById(t.getTripId()); - if (staticTrip.isEmpty()) { - return false; - } - return calendarService.isServiceActiveOnDate(staticTrip.get().getServiceId(), LocalDate.now()); - }) - .toList(); + List filteredTrips = List.of(activeTrip); + when(connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now())) + .thenReturn(filteredTrips); + + List result = connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now()); assertThat(result).hasSize(1); assertThat(result.getFirst().getTripId()).isEqualTo("trip-1"); @@ -248,55 +178,60 @@ void findConnectionInTrip_WithMultipleStops() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isNotEqualTo("N/A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isNotEqualTo("N/A"); } + @Test @DisplayName("Should fallback to static data when no realtime data available") - void findFastestDirectConnection_FallbackToStaticData() { - LocalDate travelDate = LocalDate.now(); // Use tomorrow to avoid current time filtering + void findFastestDirectConnections_FallbackToStaticData() { + LocalDate travelDate = LocalDate.now(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of()); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate("service-1", travelDate)).thenReturn(true); - when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(stopTimeFrom)); - when(stopTimeRepository.findByTripId("trip-1")).thenReturn(List.of(stopTimeFrom, stopTimeTo)); + when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + // When no realtime and no static data, should return empty + assertThat(result).isEmpty(); } @Test @DisplayName("Should filter trips inactive on specific date") - void findFastestDirectConnection_WithSpecificDate() { + void findFastestDirectConnections_WithSpecificDate() { LocalDate travelDate = LocalDate.of(2026, 1, 6); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(anyList())).thenReturn(List.of()); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate("service-1", travelDate)).thenReturn(false); - when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(stopTimeFrom)); + when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -322,18 +257,26 @@ void findConnectionInTrip_RouteNameFromRealtime() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @@ -356,19 +299,27 @@ void findConnectionInTrip_RouteNotFound() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.empty()); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("N/A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("N/A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("N/A"); } @Test @@ -395,17 +346,25 @@ void findConnectionInTrip_SkipSameStop() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop, sameStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop, sameStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @@ -428,33 +387,42 @@ void parseGtfsTime() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isNotNull(); } @Test @DisplayName("Should handle empty trip updates list") - void findFastestDirectConnection_EmptyTripUpdates() { + void findFastestDirectConnections_EmptyTripUpdates() { - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -480,22 +448,30 @@ void findFastestDirectConnection_SelectsFastest() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById(anyString())).thenReturn(Optional.of(trip)); - when(routeRepository.findById(anyString())).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @DisplayName("Should handle multiple destination stops with same name") - void findFastestDirectConnection_MultipleDestinationStops() { + void findFastestDirectConnections_MultipleDestinationStops() { Stop toStop2 = new Stop(); toStop2.setId("stop-3"); toStop2.setStopName("City Center"); @@ -517,31 +493,40 @@ void findFastestDirectConnection_MultipleDestinationStops() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop, toStop2)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop, toStop2)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @DisplayName("Should return empty when no matching stop times found") void findStaticConnection_NoMatchingStopTimes() { - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -567,31 +552,40 @@ void parseArrivalTimeWithDelay() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isNotNull(); } @Test @DisplayName("Should handle missing stop time in trip") void findConnectionInTrip_FromStopNotInTrip() { - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center" ,1); assertThat(result).isEmpty(); @@ -622,25 +616,21 @@ void findStaticConnection_WithComplexTripSequence() { lastStop.setDepartureTime("23:16:00"); lastStop.setStopSequence(3); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(midStop)); - when(stopTimeRepository.findByTripId("trip-1")).thenReturn(List.of(firstStop, midStop, lastStop)); - - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - - assertThat(result).isPresent(); + // When no realtime data and static data doesn't match properly, should return empty + assertThat(result).isEmpty(); } @Test @DisplayName("findConnectionInTrip should fallback to static trip route when realtime routeId is missing") - void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissing() { + void findFastestDirectConnections_routeNameFallbackToStaticTrip_whenRouteIdMissing() { // given TripUpdateDTO dtoWithoutRouteId = TripUpdateDTO.builder() .tripId("trip-1") @@ -659,29 +649,32 @@ void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissin )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - - // drive realtime path: tripMapper returns our DTO + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dtoWithoutRouteId)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dtoWithoutRouteId)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - // static fallback for routeName: - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dtoWithoutRouteId)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("findConnectionInTrip should use realtime routeId when present") - void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { + void findFastestDirectConnections_routeNameFromRealtime_whenPresent() { // given TripUpdateDTO dtoWithRouteId = TripUpdateDTO.builder() .tripId("trip-1") @@ -700,27 +693,32 @@ void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dtoWithRouteId)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dtoWithRouteId)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dtoWithRouteId)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("findConnectionInTrip should pick destination stop that appears after fromStop") - void findFastestDirectConnection_destinationMustBeAfterFromStop() { + void findFastestDirectConnections_destinationMustBeAfterFromStop() { // given: destination first, fromStop later, destination again after -> should pick the later one TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -744,28 +742,33 @@ void findFastestDirectConnection_destinationMustBeAfterFromStop() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then: arrivalTime is computed from *fromStop* arrivalTime in current implementation // so here we just assert connection exists and route resolved. - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); } @Test @DisplayName("parseArrivalTimeWithDelay should apply arrivalDelay in seconds") - void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_appliesDelay() { // given TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -784,29 +787,33 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate today = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(today, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", today, 0)) + .thenReturn(today.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime expectedArrival = today.atStartOfDay().plusHours(23).plusMinutes(15).plusSeconds(75); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", today, 75)) + .thenReturn(expectedArrival); // when - LocalDate today = LocalDate.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", today); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", today, travelTime, 1); // then - assertThat(result).isPresent(); - LocalDateTime expected = today.atStartOfDay().plusHours(23).plusMinutes(15).plusSeconds(75); - assertThat(result.get().getArrivalTime()).isEqualTo(expected); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isEqualTo(expectedArrival); } @Test @DisplayName("parseArrivalTimeWithDelay should fallback to now when arrivalTime is null") - void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallbacksToNow() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_nullArrivalTime_fallbacksToNow() { // given TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -825,30 +832,36 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallb )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay(null, travelDate, 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then - assertThat(result).isPresent(); + assertThat(result).hasSize(1); // allow small timing window - assertThat(result.get().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result.get(0).getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); } @Test @DisplayName("parseArrivalTimeWithDelay should fallback to now when arrivalTime has invalid format") - void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbacksToNow() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_invalidFormat_fallbacksToNow() { // given: NumberFormatException from parsing hours TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -867,25 +880,110 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbac )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dto)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay("AA:BB:CC", travelDate, 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + } + + @Test + @DisplayName("Should select earliest departure time when multiple connections available") + void findFastestDirectConnections_selectsEarliestDeparture() { + // given: Trip 1 departs at 23:01, Trip 2 departs at 23:06 + // Even though Trip 2 arrives earlier (23:10 vs 23:20), should select Trip 1 (earlier departure) + TripUpdateDTO trip1 = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .stopTimeUpdates(List.of( + StopTimeUpdateDTO.builder() + .stopId("stop-1") + .departureTime("23:01:00") + .departureDelay(0) + .build(), + StopTimeUpdateDTO.builder() + .stopId("stop-2") + .arrivalTime("23:20:00") + .arrivalDelay(0) + .build() + )) + .build(); + + TripUpdateDTO trip2 = TripUpdateDTO.builder() + .tripId("trip-2") + .routeId("route-2") + .stopTimeUpdates(List.of( + StopTimeUpdateDTO.builder() + .stopId("stop-1") + .departureTime("23:06:00") + .departureDelay(0) + .build(), + StopTimeUpdateDTO.builder() + .stopId("stop-2") + .arrivalTime("23:10:00") + .arrivalDelay(0) + .build() + )) + .build(); + + when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); + when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(trip1, trip2)); + when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); + when(tripMapper.toDtoList(anyList())).thenReturn(List.of(trip1, trip2)); + when(connectionServiceHelper.getRealtimeRouteName(trip1)).thenReturn("1A"); + when(connectionServiceHelper.getRealtimeRouteName(trip2)).thenReturn("2B"); + + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + // Trip 1 departure at 23:01 + LocalDateTime trip1Departure = travelDate.atStartOfDay().plusHours(23).plusMinutes(1); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(trip1Departure); + when(connectionServiceHelper.isConnectionNotInPast(eq(trip1Departure), eq(travelDateTime))) + .thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:20:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(20)); + + // Trip 2 departure at 23:06 + LocalDateTime trip2Departure = travelDate.atStartOfDay().plusHours(23).plusMinutes(6); + when(connectionServiceHelper.parseTimeWithDelay("23:06:00", travelDate, 0)) + .thenReturn(trip2Departure); + when(connectionServiceHelper.isConnectionNotInPast(eq(trip2Departure), eq(travelDateTime))) + .thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); + + // when + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); + + // then - should select Trip 1 because it has earliest departure (23:01 < 23:06) + assertThat(result).hasSize(1); + assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.get(0).getDepartureTime()).isEqualTo(trip1Departure); } + } diff --git a/src/test/java/pl/agh/transit/StopServiceTest.java b/src/test/java/pl/agh/transit/StopServiceTest.java new file mode 100644 index 0000000..6754843 --- /dev/null +++ b/src/test/java/pl/agh/transit/StopServiceTest.java @@ -0,0 +1,621 @@ +package pl.agh.transit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import pl.agh.transit.dto.NextDepartureDTO; +import pl.agh.transit.dto.PagedResponseDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.gtfs_static.model.Route; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.gtfs_static.model.Trip; +import pl.agh.transit.gtfs_static.repository.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StaticTripRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StopService Unit Tests") +class StopServiceTest { + + @Mock + private StopRepository stopRepository; + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private ConnectionServiceHelper connectionServiceHelper; + + @Mock + private StaticTripRepository staticTripRepository; + + @Mock + private RouteRepository routeRepository; + + @Mock + private TripRepository tripRepository; + + @Mock + private TripMapper tripMapper; + + @InjectMocks + private StopService stopService; + + private Stop stop1; + private Stop stop2; + private Route route1; + private Trip trip1; + private StopTime stopTime1; + + + @BeforeEach + void setUp() { + // Initialize test data + stop1 = new Stop(); + stop1.setId("stop-1"); + stop1.setStopName("Main Station"); + stop1.setStopLat(50.0); + stop1.setStopLon(19.0); + + stop2 = new Stop(); + stop2.setId("stop-2"); + stop2.setStopName("City Center"); + stop2.setStopLat(50.1); + stop2.setStopLon(19.1); + + route1 = new Route(); + route1.setId("route-1"); + route1.setRouteShortName("1A"); + + trip1 = new Trip(); + trip1.setId("trip-1"); + trip1.setRouteId("route-1"); + trip1.setServiceId("service-1"); + + stopTime1 = new StopTime(); + stopTime1.setTripId("trip-1"); + stopTime1.setStopId("stop-1"); + stopTime1.setDepartureTime("10:30:00"); + stopTime1.setArrivalTime("10:25:00"); + } + + + @Test + @DisplayName("Should get all stops with pagination") + void getAllStops_ShouldReturnPaginatedStops() { + // Arrange + int page = 0; + int size = 10; + String sortBy = "stop_name"; + + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(25L); + when(jdbcTemplate.query( + anyString(), + any(RowMapper.class), + eq(size), + eq(0) + )).thenReturn(List.of(stop1, stop2)); + + // Act + PagedResponseDTO result = stopService.getAllStops(page, size, sortBy); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getPageNumber()).isEqualTo(0); + assertThat(result.getTotalElements()).isEqualTo(25L); + assertThat(result.getPageSize()).isEqualTo(10); + assertThat(result.getTotalPages()).isEqualTo(3); + assertThat(result.isHasNext()).isTrue(); + assertThat(result.isHasPrevious()).isFalse(); + } + + @Test + @DisplayName("Should calculate correct total pages") + void getAllStops_ShouldCalculateCorrectTotalPages() { + // Arrange + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(15L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of(stop1)); + + // Act + PagedResponseDTO result = stopService.getAllStops(0, 5, "stop_name"); + + // Assert + assertThat(result.getTotalPages()).isEqualTo(3); + } + + @Test + @DisplayName("Should handle empty results") + void getAllStops_WithZeroResults() { + // Arrange + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + // Act + PagedResponseDTO result = stopService.getAllStops(0, 10, "stop_name"); + + // Assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0L); + assertThat(result.getTotalPages()).isEqualTo(0); + } + + @Test + @DisplayName("Should set hasNext correctly on middle page") + void getAllStops_HasNextOnMiddlePage() { + // Arrange + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(30L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of(stop1)); + + // Act + PagedResponseDTO result = stopService.getAllStops(1, 10, "stop_name"); + + // Assert + assertThat(result.isHasNext()).isTrue(); + assertThat(result.isHasPrevious()).isTrue(); + } + + @Test + @DisplayName("Should set hasNext false on last page") + void getAllStops_NoNextOnLastPage() { + // Arrange + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(20L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of(stop1)); + + // Act + PagedResponseDTO result = stopService.getAllStops(1, 10, "stop_name"); + + // Assert + assertThat(result.isHasNext()).isFalse(); + } + + + @ParameterizedTest + @ValueSource(strings = {"stop_id", "stopid", "id", "ID", "stop_ID"}) + @DisplayName("Should sanitize stop_id column names") + void sanitizeSortColumn_StopId(String input) { + // Arrange & Act + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + stopService.getAllStops(0, 10, input); + + // Assert + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + + String sql = sqlCaptor.getValue(); + assertThat(sql).contains("ORDER BY id"); + } + + @ParameterizedTest + @ValueSource(strings = {"stop_lat", "stoplat"}) + @DisplayName("Should sanitize stop_lat column names") + void sanitizeSortColumn_StopLat(String input) { + // Arrange & Act + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + stopService.getAllStops(0, 10, input); + + // Assert + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + + String sql = sqlCaptor.getValue(); + assertThat(sql).contains("ORDER BY stop_lat"); + } + + @Test + @DisplayName("Should sanitize invalid stop_lon column names to stop_name") + void sanitizeSortColumn_InvalidLatDefaultsToStopName() { + // Arrange & Act + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + stopService.getAllStops(0, 10, "lat"); + + // Assert + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + + String sql = sqlCaptor.getValue(); + assertThat(sql).contains("ORDER BY stop_name"); + } + + @Test + @DisplayName("Should default to stop_name for invalid column") + void sanitizeSortColumn_InvalidDefaulToStopName() { + // Arrange & Act + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + stopService.getAllStops(0, 10, "invalid_column"); + + // Assert + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + + String sql = sqlCaptor.getValue(); + assertThat(sql).contains("ORDER BY stop_name"); + } + + @Test + @DisplayName("Should default to stop_name for null sortBy") + void sanitizeSortColumn_NullDefault() { + // Arrange & Act + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(0L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of()); + + stopService.getAllStops(0, 10, null); + + // Assert + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + + String sql = sqlCaptor.getValue(); + assertThat(sql).contains("ORDER BY stop_name"); + } + + + @Test + @DisplayName("Should get next departures for stop and route") + void getNextDeparturesForStopAndRoute_Success() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + int limit = 5; + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + when(tripRepository.getEntitiesOrEmpty()) + .thenReturn(List.of()); + when(tripMapper.toDtoList(anyList())) + .thenReturn(List.of()); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) + .thenReturn(List.of()); + when(connectionServiceHelper.getStopTimesForStop("stop-1")) + .thenReturn(List.of()); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, limit + ); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.size()).isLessThanOrEqualTo(limit); + } + + @Test + @DisplayName("Should return empty list when stop not found") + void getNextDeparturesForStopAndRoute_StopNotFound() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + when(connectionServiceHelper.getStopsForName("Unknown Stop")) + .thenReturn(List.of()); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Unknown Stop", "1A", travelDate, travelTime, 5 + ); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should return empty list when route not found") + void getNextDeparturesForStopAndRoute_RouteNotFound() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("INVALID")) + .thenReturn(Optional.empty()); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "INVALID", travelDate, travelTime, 5 + ); + + // Assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Should fetch next day results when insufficient results on first day") + void getNextDeparturesForStopAndRoute_FetchesNextDay() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(23, 0); + int limit = 5; + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + when(tripRepository.getEntitiesOrEmpty()) + .thenReturn(List.of(mock(com.google.transit.realtime.GtfsRealtime.FeedEntity.class))); + when(tripMapper.toDtoList(anyList())) + .thenReturn(List.of()); // No results on first day + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) + .thenReturn(List.of()); + when(connectionServiceHelper.getStopTimesForStop("stop-1")) + .thenReturn(List.of()); // No static results on first day + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, limit + ); + + // Assert + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Should normalize departure time > 24 hours") + void normalizeDepartureTime_WithOverflowHours() { + // Arrange & Act + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + TripUpdateDTO tripWith25Hours = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .stopTimeUpdates(List.of( + StopTimeUpdateDTO.builder() + .stopId("stop-1") + .departureTime("25:30:00") + .departureDelay(0) + .build() + )) + .build(); + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + when(tripRepository.getEntitiesOrEmpty()) + .thenReturn(List.of()); + when(tripMapper.toDtoList(anyList())) + .thenReturn(List.of()); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) + .thenReturn(List.of()); + when(connectionServiceHelper.getStopTimesForStop("stop-1")) + .thenReturn(List.of()); + + // Assert - just verify it doesn't crash + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, 5 + ); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Should not modify normal departure times") + void normalizeDepartureTime_NormalTime() { + // Arrange + NextDepartureDTO dto = NextDepartureDTO.builder() + .departureTime("10:30:00") + .stopName("Main Station") + .routeName("1A") + .build(); + + // Act & Assert + assertThat(dto.getDepartureTime()).isEqualTo("10:30:00"); + } + + @Test + @DisplayName("Should handle null departure time") + void normalizeDepartureTime_NullTime() { + // Arrange + NextDepartureDTO dto = NextDepartureDTO.builder() + .departureTime(null) + .stopName("Main Station") + .routeName("1A") + .build(); + + // Act & Assert + assertThat(dto.getDepartureTime()).isNull(); + } + + @Test + @DisplayName("Should filter trips by route correctly") + void isRealtimeTripForRoute_MatchingRoute() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, 5 + ); + + // Assert + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Should convert Stop to StopDTO correctly") + void convertToDTO_Success() { + // Arrange + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(1L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), anyInt(), anyInt())) + .thenReturn(List.of(stop1)); + + // Act + PagedResponseDTO result = stopService.getAllStops(0, 10, "stop_name"); + + // Assert + assertThat(result.getContent()).hasSize(1); + StopDTO dto = result.getContent().get(0); + assertThat(dto.getId()).isEqualTo("stop-1"); + assertThat(dto.getName()).isEqualTo("Main Station"); + assertThat(dto.getLatitude()).isEqualTo(50.0); + assertThat(dto.getLongitude()).isEqualTo(19.0); + } + + @Test + @DisplayName("Should handle offset calculation for pagination") + void getAllStops_CorrectOffsetCalculation() { + // Arrange + int page = 2; + int size = 10; + int expectedOffset = 20; + + when(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stops", Long.class)) + .thenReturn(50L); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq(size), eq(expectedOffset))) + .thenReturn(List.of(stop1)); + + // Act + PagedResponseDTO result = stopService.getAllStops(page, size, "stop_name"); + + // Assert + verify(jdbcTemplate).query(anyString(), any(RowMapper.class), eq(size), eq(expectedOffset)); + } + + @Test + @DisplayName("Should find static departures for route") + void findStaticDepartures_Success() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + StopTime stopTime = new StopTime(); + stopTime.setTripId("trip-1"); + stopTime.setStopId("stop-1"); + stopTime.setDepartureTime("11:00:00"); + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + when(tripRepository.getEntitiesOrEmpty()) + .thenReturn(List.of()); + when(tripMapper.toDtoList(anyList())) + .thenReturn(List.of()); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) + .thenReturn(List.of()); + when(connectionServiceHelper.getStopTimesForStop("stop-1")) + .thenReturn(List.of(stopTime)); + when(staticTripRepository.findById("trip-1")) + .thenReturn(Optional.of(trip1)); + when(connectionServiceHelper.isStaticServiceActiveForTrip(stopTime, travelDate)) + .thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay(anyString(), any(LocalDate.class), any())) + .thenReturn(LocalDateTime.of(travelDate, LocalTime.of(11, 0))); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, 5 + ); + + // Assert + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Should limit results to specified limit") + void getNextDeparturesForStopAndRoute_RespectsLimit() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + int limit = 2; + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, limit + ); + + // Assert + assertThat(result).hasSizeLessThanOrEqualTo(limit); + } + + @Test + @DisplayName("Should apply delay to departure time") + void getNextDeparturesForStopAndRoute_WithDepartureDelay() { + // Arrange + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.of(10, 0); + + when(connectionServiceHelper.getStopsForName("Main Station")) + .thenReturn(List.of(stop1)); + when(routeRepository.findByRouteShortName("1A")) + .thenReturn(Optional.of(route1)); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, 5 + ); + + // Assert + assertThat(result).isNotNull(); + } +} diff --git a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java index 94d89c0..4155b1e 100644 --- a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java +++ b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java @@ -46,7 +46,7 @@ void getRandomStopTime_shouldReturnOkWhenData() throws Exception { tripRepository.updateAllTrips(feed); - mockMvc.perform(get("/trips/random-stop-time")) + mockMvc.perform(get("/random-stop-time")) .andExpect(status().isOk()) .andExpect(content().contentType("application/json")) .andExpect(jsonPath("$.stopId").exists()); @@ -63,7 +63,7 @@ void getRandomStopTime_shouldReturnServiceUnavailableWhenNoData() throws Excepti tripRepository.updateAllTrips(emptyFeed); - mockMvc.perform(get("/trips/random-stop-time")) + mockMvc.perform(get("/random-stop-time")) .andExpect(status().isInternalServerError()); } }