From 4443788fea325ada39d471f079e92c77cc0553a5 Mon Sep 17 00:00:00 2001 From: cnuart Date: Fri, 16 Jan 2026 18:31:17 +0100 Subject: [PATCH 01/13] fix an edge case --- .../java/pl/agh/transit/gtfs_static/GtfsStaticService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/pl/agh/transit/gtfs_static/GtfsStaticService.java b/src/main/java/pl/agh/transit/gtfs_static/GtfsStaticService.java index f04d2ef..94cc993 100644 --- a/src/main/java/pl/agh/transit/gtfs_static/GtfsStaticService.java +++ b/src/main/java/pl/agh/transit/gtfs_static/GtfsStaticService.java @@ -144,7 +144,7 @@ private void importData() { load(calendarDatesFile, "INSERT INTO calendar_dates (service_id, date, exception_type, service_name) VALUES (?, ?, ?, ?)", - row -> new Object[]{ row[0], toDate(row[1]), toInt(row[2]), row[3]} + row -> new Object[]{ row[0], toDate(row[1]), toInt(row[2]), get(row, 3) } ); load(tripsFile, @@ -159,7 +159,7 @@ private void importData() { load(feedInfoFile, "INSERT INTO feed_info (feed_publisher_name, feed_publisher_url, feed_lang, feed_start_date, feed_end_date, feed_version) VALUES (?, ?, ?, ?, ?, ?)", - row -> new Object[] { row[0], row[1], row[2], toDate(row[3]), toDate(row[4]), row[5] } + row -> new Object[] { row[0], row[1], row[2], toDate(row[3]), toDate(row[4]), get(row, 5) } ); log.info("Finished importing GTFS. Time: {}ms", System.currentTimeMillis() - start); From 6c7c4a2e329d37a0447a551582b9795dd3692b92 Mon Sep 17 00:00:00 2001 From: cnuart Date: Sat, 17 Jan 2026 10:55:47 +0100 Subject: [PATCH 02/13] extract a helper class from DirectConnectionService --- .../agh/transit/ConnectionServiceHelper.java | 206 +++++++++++ .../agh/transit/DirectConnectionService.java | 158 +-------- .../transit/ConnectionServiceHelperTest.java | 317 +++++++++++++++++ .../transit/DirectConnectionServiceTest.java | 332 ++++++++++-------- 4 files changed, 722 insertions(+), 291 deletions(-) create mode 100644 src/main/java/pl/agh/transit/ConnectionServiceHelper.java create mode 100644 src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java 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..32e2ba8 --- /dev/null +++ b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java @@ -0,0 +1,206 @@ +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, 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; + } + + /** + * 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..0f27448 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/DirectConnectionService.java @@ -6,10 +6,8 @@ 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; @@ -17,7 +15,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -34,6 +31,7 @@ public class DirectConnectionService { 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 @@ -51,10 +49,10 @@ public Optional findFastestDirectConnection(String fromStopNam public Optional findFastestDirectConnection(String fromStopName, String toStopName, LocalDate travelDate) { log.debug("[1] findFastestDirectConnection START: {} -> {} on {}", fromStopName, toStopName, travelDate); - 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()) { @@ -70,7 +68,7 @@ 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 @@ -104,32 +102,7 @@ public Optional findFastestDirectConnection(String fromStopNam 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 @@ -146,14 +119,14 @@ 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)) { + if (!connectionServiceHelper.isConnectionNotInPast(departureTime, travelDate)) { return Stream.empty(); } - String routeName = getRealtimeRouteName(trip); + String routeName = connectionServiceHelper.getRealtimeRouteName(trip); return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate); } @@ -163,7 +136,7 @@ private Stream findAllToStopsInTrip(List st .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,88 +167,6 @@ 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 @@ -294,20 +185,7 @@ private Optional findStaticConnection(String fromStopId, List< } 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 +214,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,7 +231,7 @@ 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())) { @@ -368,15 +246,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 +259,4 @@ private List getTripUpdates() { return result; } } + 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..1661ef9 --- /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() { + LocalDate today = LocalDate.now(); + LocalDateTime pastTime = today.atStartOfDay().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() { + LocalDate today = LocalDate.now(); + LocalDateTime futureTime = today.atStartOfDay().plusHours(20); + + boolean result = connectionServiceHelper.isConnectionNotInPast(futureTime, today); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Should return true when date is different from today") + void isConnectionNotInPast_DifferentDate() { + LocalDate tomorrow = LocalDate.now().plusDays(1); + LocalDateTime time = tomorrow.atStartOfDay().minusHours(1); + + boolean result = connectionServiceHelper.isConnectionNotInPast(time, tomorrow); + + assertThat(result).isTrue(); + } +} + diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java index 112d450..56e7b9a 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -29,6 +29,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -38,24 +40,27 @@ @DisplayName("DirectConnectionService Unit Tests") class DirectConnectionServiceTest { - @Mock + @Mock(lenient = true) private StopRepository stopRepository; - @Mock + @Mock(lenient = true) private StopTimeRepository stopTimeRepository; - @Mock + @Mock(lenient = true) private RouteRepository routeRepository; - @Mock + @Mock(lenient = true) private StaticTripRepository staticTripRepository; - @Mock + @Mock(lenient = true) private TripRepository tripRepository; - @Mock + @Mock(lenient = true) private CalendarService calendarService; + @Mock + private ConnectionServiceHelper connectionServiceHelper; + @Mock TripMapper tripMapper; @@ -150,13 +155,17 @@ void findFastestDirectConnection_WithRealtimeData() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -168,8 +177,8 @@ void findFastestDirectConnection_WithRealtimeData() { @DisplayName("Should return empty when no stops found") void findFastestDirectConnection_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()); @@ -180,8 +189,8 @@ void findFastestDirectConnection_NoStopsFound() { @DisplayName("Should return empty when destination stops not found") void findFastestDirectConnection_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()); @@ -206,23 +215,13 @@ void filterTripsByServiceCalendar_ShouldRemoveInactiveServices() { 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,13 +247,17 @@ void findConnectionInTrip_WithMultipleStops() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -264,22 +267,19 @@ void findConnectionInTrip_WithMultipleStops() { @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 + 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); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + // When no realtime and no static data, should return empty + assertThat(result).isEmpty(); } @Test @@ -287,13 +287,12 @@ void findFastestDirectConnection_FallbackToStaticData() { void findFastestDirectConnection_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); @@ -322,13 +321,17 @@ void findConnectionInTrip_RouteNameFromRealtime() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); @@ -356,13 +359,17 @@ void findConnectionInTrip_RouteNotFound() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -395,13 +402,17 @@ void findConnectionInTrip_SkipSameStop() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop, sameStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + 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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -428,13 +439,17 @@ void parseGtfsTime() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -448,8 +463,9 @@ void parseGtfsTime() { @DisplayName("Should handle empty trip updates list") void findFastestDirectConnection_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()); @@ -480,13 +496,17 @@ void findFastestDirectConnection_SelectsFastest() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:10:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(10)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -517,13 +537,17 @@ void findFastestDirectConnection_MultipleDestinationStops() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop, toStop2)); + 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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -534,8 +558,9 @@ void findFastestDirectConnection_MultipleDestinationStops() { @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()); @@ -567,13 +592,17 @@ void parseArrivalTimeWithDelay() { )) .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(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", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -585,8 +614,9 @@ void parseArrivalTimeWithDelay() { @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()); @@ -622,20 +652,16 @@ 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()); - - assertThat(result).isPresent(); + // When no realtime data and static data doesn't match properly, should return empty + assertThat(result).isEmpty(); } @Test @@ -659,17 +685,17 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); // when Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -700,15 +726,17 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); // when Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -744,15 +772,17 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); // when Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); @@ -784,24 +814,26 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + LocalDate today = LocalDate.now(); + 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); // then assertThat(result).isPresent(); - LocalDateTime expected = today.atStartOfDay().plusHours(23).plusMinutes(15).plusSeconds(75); - assertThat(result.get().getArrivalTime()).isEqualTo(expected); + assertThat(result.get().getArrivalTime()).isEqualTo(expectedArrival); } @Test @@ -825,15 +857,18 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay(null, LocalDate.now(), 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); @@ -867,15 +902,18 @@ 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"); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) + .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay("AA:BB:CC", LocalDate.now(), 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); From 57857960bd0cdfeeac25bb0d1aa74a19c3beb147 Mon Sep 17 00:00:00 2001 From: cnuart Date: Sat, 17 Jan 2026 14:10:34 +0100 Subject: [PATCH 03/13] add search for multiple earliest connections between stops based on a given travel date and time --- .../agh/transit/ConnectionServiceHelper.java | 7 +- .../agh/transit/DirectConnectionService.java | 89 ++-- .../java/pl/agh/transit/TripController.java | 14 +- src/main/java/pl/agh/transit/TripService.java | 4 +- src/main/resources/application.properties | 4 +- .../transit/ConnectionServiceHelperTest.java | 16 +- ...irectConnectionServiceIntegrationTest.java | 93 ++-- .../transit/DirectConnectionServiceTest.java | 469 ++++++++++-------- 8 files changed, 394 insertions(+), 302 deletions(-) diff --git a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java index 32e2ba8..111cbe9 100644 --- a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java +++ b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java @@ -152,10 +152,9 @@ public String getStaticRouteNameForTrip(String tripId) { /** * Checks if a connection departure time is not in the past */ - public 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); + 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; diff --git a/src/main/java/pl/agh/transit/DirectConnectionService.java b/src/main/java/pl/agh/transit/DirectConnectionService.java index 0f27448..e616f5f 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/DirectConnectionService.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -33,21 +34,23 @@ public class DirectConnectionService { 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 findFastestDirectConnection(String fromStopName, String toStopName) { + return findFastestDirectConnection(fromStopName, toStopName, LocalDate.now(), LocalTime.now(), 1); } /** - * 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 findFastestDirectConnection(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 = connectionServiceHelper.getStopsForName(fromStopName); log.debug("[2] Found {} stops for: {}", fromStops.size(), fromStopName); @@ -57,7 +60,7 @@ public Optional findFastestDirectConnection(String fromStopNam 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: "); @@ -72,44 +75,45 @@ public Optional findFastestDirectConnection(String fromStopNam 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; } - /** * 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()); @@ -122,16 +126,22 @@ private Stream findConnectionInTrip(TripUpdateDTO trip, String LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(fromStop.getDepartureTime(), travelDate, fromStop.getDepartureDelay()); log.debug(" [7g] Departure time: {}", departureTime); - if (!connectionServiceHelper.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 = 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)) @@ -169,19 +179,30 @@ private boolean isRealtimeDestinationCandidate(StopTimeUpdateDTO stop, List 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)); + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .toList(); } private boolean isStaticServiceActiveForTrip(StopTime stopTime, LocalDate travelDate) { @@ -234,10 +255,6 @@ private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fro 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) diff --git a/src/main/java/pl/agh/transit/TripController.java b/src/main/java/pl/agh/transit/TripController.java index d84e002..fb9bd97 100644 --- a/src/main/java/pl/agh/transit/TripController.java +++ b/src/main/java/pl/agh/transit/TripController.java @@ -10,6 +10,7 @@ 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; @@ -35,8 +36,17 @@ 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); + public List getDirectConnectionsWithDate( + @RequestParam String from, + @RequestParam String to, + @RequestParam String travelDate, + @RequestParam String travelTime, + @RequestParam(defaultValue = "3") int count + ) { + LocalDate date = LocalDate.parse(travelDate); + LocalTime time = LocalTime.parse(travelTime); + return directConnectionService.findFastestDirectConnection(from, to, date, time, 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..22605bc 100644 --- a/src/main/java/pl/agh/transit/TripService.java +++ b/src/main/java/pl/agh/transit/TripService.java @@ -48,11 +48,11 @@ private Optional pickRandom(List list){ } public TransportTypeDTO getDirectConnection(String from , String to){ - Optional connection = directConnectionService.findFastestDirectConnection(from, to); + List connection = directConnectionService.findFastestDirectConnection(from, to); if (connection.isEmpty()) { throw new ServiceUnavailableException("No connection available from " + from + " to " + to); } - return connection.get(); + return connection.getFirst(); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5eb76ff..5962ebb 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 index 1661ef9..493b99c 100644 --- a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java +++ b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java @@ -284,8 +284,8 @@ void getStaticRouteNameForTrip_RouteNotFound() { @Test @DisplayName("Should return false when departure time is in the past (same date)") void isConnectionNotInPast_TimeInPast() { - LocalDate today = LocalDate.now(); - LocalDateTime pastTime = today.atStartOfDay().minusHours(1); + LocalDateTime today = LocalDateTime.now(); + LocalDateTime pastTime = today.minusHours(1); boolean result = connectionServiceHelper.isConnectionNotInPast(pastTime, today); @@ -295,8 +295,8 @@ void isConnectionNotInPast_TimeInPast() { @Test @DisplayName("Should return true when departure time is in the future") void isConnectionNotInPast_TimeFuture() { - LocalDate today = LocalDate.now(); - LocalDateTime futureTime = today.atStartOfDay().plusHours(20); + LocalDateTime today = LocalDateTime.now(); + LocalDateTime futureTime = today.plusHours(20); boolean result = connectionServiceHelper.isConnectionNotInPast(futureTime, today); @@ -304,12 +304,12 @@ void isConnectionNotInPast_TimeFuture() { } @Test - @DisplayName("Should return true when date is different from today") + @DisplayName("Should return true when date is after travel date") void isConnectionNotInPast_DifferentDate() { - LocalDate tomorrow = LocalDate.now().plusDays(1); - LocalDateTime time = tomorrow.atStartOfDay().minusHours(1); + LocalDateTime travelDate = LocalDateTime.now(); + LocalDateTime time = travelDate.plusHours(1); - boolean result = connectionServiceHelper.isConnectionNotInPast(time, tomorrow); + 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..0f5c73c 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -13,15 +13,18 @@ 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 java.time.LocalDate; +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; import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Transactional @@ -100,7 +103,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 +126,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,18 +153,18 @@ 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 + .findFastestDirectConnection("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()); + List result = directConnectionService + .findFastestDirectConnection("Main Station", "Final Stop", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -169,8 +172,8 @@ void findFastestDirectConnection_noDirectConnection_returnsEmpty() { @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()); + List result = directConnectionService + .findFastestDirectConnection("Unknown Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -178,8 +181,8 @@ void findFastestDirectConnection_sourceStopNotFound_returnsEmpty() { @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()); + List result = directConnectionService + .findFastestDirectConnection("Main Station", "Unknown Station", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -187,8 +190,8 @@ void findFastestDirectConnection_destinationStopNotFound_returnsEmpty() { @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()); + List result = directConnectionService + .findFastestDirectConnection("Main Station", "Main Station", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -198,8 +201,8 @@ void findFastestDirectConnection_sameSourceAndDestination_returnsEmpty() { void findFastestDirectConnection_travelDateOutsideCalendar_returnsEmpty() { LocalDate futureDate = LocalDate.now().plusYears(1); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", futureDate); + List result = directConnectionService + .findFastestDirectConnection("Main Station", "City Center", futureDate, LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -209,25 +212,25 @@ void findFastestDirectConnection_travelDateOutsideCalendar_returnsEmpty() { void findFastestDirectConnection_validTravelDate_returnsConnection() { LocalDate validDate = LocalDate.now(); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", validDate); + List result = directConnectionService + .findFastestDirectConnection("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 findFastestDirectConnection_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 + .findFastestDirectConnection("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 @@ -276,12 +279,12 @@ void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { stopTime3To.setStopSequence(2); stopTimeRepository.save(stopTime3To); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnection("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 @@ -295,22 +298,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 + .findFastestDirectConnection("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()); + List result = directConnectionService + .findFastestDirectConnection("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 56e7b9a..84cf73d 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -11,53 +11,31 @@ 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 pl.agh.transit.gtfs_static.repository.StaticTripRepository; 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.eq; -import static org.mockito.ArgumentMatchers.isNull; +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(lenient = true) - private StopRepository stopRepository; - - @Mock(lenient = true) + @Mock private StopTimeRepository stopTimeRepository; - @Mock(lenient = true) - private RouteRepository routeRepository; - - @Mock(lenient = true) - private StaticTripRepository staticTripRepository; - - @Mock(lenient = true) + @Mock private TripRepository tripRepository; - @Mock(lenient = true) - private CalendarService calendarService; - @Mock private ConnectionServiceHelper connectionServiceHelper; @@ -69,11 +47,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() { @@ -88,51 +61,6 @@ 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 @@ -155,22 +83,26 @@ void findFastestDirectConnection_WithRealtimeData() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -180,7 +112,7 @@ void findFastestDirectConnection_NoStopsFound() { 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.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @@ -193,28 +125,28 @@ void findFastestDirectConnection_NoDestinationStopsFound() { when(connectionServiceHelper.getStopsForName("Unknown Destination")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "Unknown Destination", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnection("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"); - List trips = List.of(activeTrip, inactiveTrip); List filteredTrips = List.of(activeTrip); @@ -247,23 +179,28 @@ void findConnectionInTrip_WithMultipleStops() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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() { @@ -276,7 +213,7 @@ void findFastestDirectConnection_FallbackToStaticData() { when(tripMapper.toDtoList(any())).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, LocalTime.now(), 1); // When no realtime and no static data, should return empty assertThat(result).isEmpty(); @@ -295,7 +232,7 @@ void findFastestDirectConnection_WithSpecificDate() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -321,22 +258,26 @@ void findConnectionInTrip_RouteNameFromRealtime() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -359,23 +300,27 @@ void findConnectionInTrip_RouteNotFound() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("N/A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -402,21 +347,25 @@ void findConnectionInTrip_SkipSameStop() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @@ -439,24 +388,28 @@ void parseGtfsTime() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -470,7 +423,7 @@ void findFastestDirectConnection_EmptyTripUpdates() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -496,21 +449,25 @@ void findFastestDirectConnection_SelectsFastest() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:10:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(10)); + 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.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @@ -537,21 +494,25 @@ void findFastestDirectConnection_MultipleDestinationStops() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @@ -566,7 +527,7 @@ void findStaticConnection_NoMatchingStopTimes() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -592,22 +553,26 @@ void parseArrivalTimeWithDelay() { )) .build(); + 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(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:01:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(1)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -621,7 +586,7 @@ void findConnectionInTrip_FromStopNotInTrip() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); assertThat(result).isEmpty(); @@ -658,7 +623,7 @@ void findStaticConnection_WithComplexTripSequence() { when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(midStop)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); // When no realtime data and static data doesn't match properly, should return empty assertThat(result).isEmpty(); @@ -691,18 +656,21 @@ void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissin when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dtoWithoutRouteId)); when(connectionServiceHelper.getRealtimeRouteName(dtoWithoutRouteId)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -732,18 +700,21 @@ void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dtoWithRouteId)); when(connectionServiceHelper.getRealtimeRouteName(dtoWithRouteId)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -778,19 +749,22 @@ void findFastestDirectConnection_destinationMustBeAfterFromStop() { when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); - when(connectionServiceHelper.parseTimeWithDelay("23:15:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23).plusMinutes(15)); + 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.findFastestDirectConnection("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 @@ -820,20 +794,22 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); 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 - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", today); + List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", today, travelTime, 1); // then - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isEqualTo(expectedArrival); + assertThat(result).hasSize(1); + assertThat(result.get(0).getArrivalTime()).isEqualTo(expectedArrival); } @Test @@ -863,22 +839,25 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallb when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + 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, LocalDate.now(), 10)) + 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.findFastestDirectConnection("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 @@ -908,22 +887,104 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbac when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dto)); when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); - when(connectionServiceHelper.parseTimeWithDelay("23:00:00", LocalDate.now(), 0)) - .thenReturn(LocalDate.now().atStartOfDay().plusHours(23)); - when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), any(LocalDate.class))).thenReturn(true); + 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", LocalDate.now(), 10)) + 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.findFastestDirectConnection("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 findFastestDirectConnection_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.findFastestDirectConnection("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); + } + } From 4228ca26152f1ada5e95996411265657185485c3 Mon Sep 17 00:00:00 2001 From: cnuart Date: Sat, 17 Jan 2026 14:56:21 +0100 Subject: [PATCH 04/13] add GET /stops with pagination support --- .../java/pl/agh/transit/StopController.java | 33 +++++ src/main/java/pl/agh/transit/StopService.java | 119 ++++++++++++++++++ .../pl/agh/transit/dto/PagedResponseDTO.java | 18 +++ src/main/java/pl/agh/transit/dto/StopDTO.java | 13 ++ .../repository/StopRepository.java | 2 +- 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/main/java/pl/agh/transit/StopController.java create mode 100644 src/main/java/pl/agh/transit/StopService.java create mode 100644 src/main/java/pl/agh/transit/dto/PagedResponseDTO.java create mode 100644 src/main/java/pl/agh/transit/dto/StopDTO.java 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..e4f124a --- /dev/null +++ b/src/main/java/pl/agh/transit/StopController.java @@ -0,0 +1,33 @@ +package pl.agh.transit; + +import lombok.AllArgsConstructor; +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; + +@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); + } + +} 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..6267a2c --- /dev/null +++ b/src/main/java/pl/agh/transit/StopService.java @@ -0,0 +1,119 @@ +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.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.repository.StopRepository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service for managing stops using JDBC (manual pagination) + */ +@Slf4j +@Service +@AllArgsConstructor +public class StopService { + + private final StopRepository stopRepository; + private final JdbcTemplate jdbcTemplate; + private final ConnectionServiceHelper connectionServiceHelper; + + /** + * Manual pagination with JDBC + */ + 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) { + // Whitelist of allowed columns + switch (sortBy.toLowerCase()) { + case "stopname": + case "stop_name": + return "stop_name"; + case "stopid": + case "stop_id": + case "id": + return "id"; + case "stoplat": + case "stop_lat": + return "stop_lat"; + case "stoplon": + case "stop_lon": + return "stop_lon"; + default: + return "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; + } + } + + /** + * Convert Stop entity to DTO + */ + private StopDTO convertToDTO(Stop stop) { + return StopDTO.builder() + .id(stop.getId()) + .name(stop.getStopName()) + .latitude(stop.getStopLat()) + .longitude(stop.getStopLon()) + .build(); + } +} \ No newline at end of file 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/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 From 0b35ebd5c7eff6fb1a07304438949da7c16c8d1b Mon Sep 17 00:00:00 2001 From: cnuart Date: Sat, 17 Jan 2026 19:48:03 +0100 Subject: [PATCH 05/13] add GET /stops/routes --- .../java/pl/agh/transit/StopController.java | 18 + src/main/java/pl/agh/transit/StopService.java | 354 +++++++++++++++++- .../pl/agh/transit/dto/NextDepartureDTO.java | 16 + .../pl/agh/transit/dto/StopTimeUpdateDTO.java | 1 + .../repository/RouteRepository.java | 2 + 5 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 src/main/java/pl/agh/transit/dto/NextDepartureDTO.java diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java index e4f124a..230f1c2 100644 --- a/src/main/java/pl/agh/transit/StopController.java +++ b/src/main/java/pl/agh/transit/StopController.java @@ -1,13 +1,19 @@ 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.PathVariable; 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 @@ -30,4 +36,16 @@ public ResponseEntity> getAllStops( 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 + ) { + LocalDate travelDate = date != null ? date : LocalDate.now(); + List departures = stopService.getNextDeparturesForStopAndRoute( + stopName, routeName, travelDate, 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 index 6267a2c..68bcda0 100644 --- a/src/main/java/pl/agh/transit/StopService.java +++ b/src/main/java/pl/agh/transit/StopService.java @@ -7,16 +7,30 @@ 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 using JDBC (manual pagination) + * Service for managing stops with JDBC and real-time departures */ @Slf4j @Service @@ -26,9 +40,13 @@ 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; /** - * Manual pagination with JDBC + * Get all stops with pagination */ public PagedResponseDTO getAllStops(int page, int size, String sortBy) { log.debug("Getting stops - page: {}, size: {}, sortBy: {}", page, size, sortBy); @@ -116,4 +134,336 @@ private StopDTO convertToDTO(Stop stop) { .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, + int limit + ) { + log.debug("[0] Getting next {} departures for stop: {}, route: {}, date: {}", + limit, stopName, routeName, travelDate); + + // Get departures for the initial travel date + List results = getNextDeparturesForDate(stopName, routeName, travelDate, 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, 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; + } + + /** + * Get departures for a specific date + */ + private List getNextDeparturesForDate( + String stopName, + String routeName, + LocalDate travelDate, + int limit + ) { + log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, limit: {}", + stopName, routeName, travelDate, limit); + + // Find stop by name + 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 = travelDate.equals(LocalDate.now()) + ? LocalDateTime.now() + : travelDate.atStartOfDay(); + + log.debug("[2] Travel datetime: {}", travelDateTime); + + // Get realtime trip updates + List allTrips = getTripUpdates(); + log.debug("[3] Found {} trip updates", allTrips.size()); + + // Filter trips by service calendar for the travel date + List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); + log.debug("[4] Active trips after calendar filter: {}", activeTrips.size()); + + // Get realtime departures + 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()); + + // Get static departures as fallback, but only those departing at least 1 hour after initial travel time + List staticDepartures = findStaticDepartures( + stopId, routeId, travelDate, travelDateTime, stop.getStopName(), routeName); + + log.debug("[6] Static departures (at least 1h after last realtime): {}", staticDepartures.size()); + + // Merge realtime and static results, sort by departure time and limit + 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; + } + + /** + * Gets trip updates from GTFS Realtime feed + */ + 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) { + // Check if route ID is directly in realtime data + if (trip.getRouteId() != null && trip.getRouteId().equals(routeId)) { + log.debug(" [4a] Trip {} matches route {} in realtime data", + trip.getTripId(), routeId); + return true; + } + + // Fallback to static data + 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 + ) { + if (trip.getStopTimeUpdates() == null || trip.getStopTimeUpdates().isEmpty()) { + return Stream.empty(); + } + + log.debug(" [5a] Checking trip: {} with {} stops", + trip.getTripId(), trip.getStopTimeUpdates().size()); + + return trip.getStopTimeUpdates().stream() + .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) + .filter(stopUpdate -> { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + stopUpdate.getDepartureTime(), + travelDate, + stopUpdate.getDepartureDelay() + ); + + boolean notInPast = connectionServiceHelper.isConnectionNotInPast( + departureTime, travelDateTime); + + if (!notInPast) { + log.debug(" [5b] Skipping - departure {} is before travel time {}", + departureTime, travelDateTime); + } + + return notInPast; + }) + .map(stopUpdate -> { + return NextDepartureDTO.builder() + .departureTime(stopUpdate.getDepartureTime()) + .departureDelay(stopUpdate.getDepartureDelay()) // Used for sorting + .stopName(stopName) + .routeName(routeName) + .build(); + }) + .peek(dto -> log.debug(" [5c] Found realtime departure at stop {} in trip {}", + stopId, trip.getTripId())); + } + + /** + * Find static departures at least 1 hour after initial travel 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); + + // Calculate minimum time for static departures (1 hour after initial travel time) + final LocalDateTime minStaticTime = travelDateTime.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); + + // Must be at least 1 hour after initial travel time + 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(); + } + + + /** + * Converts StopTime entity to NextDepartureDTO + */ + private NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName, String routeName) { + return NextDepartureDTO.builder() + .departureTime(stopTime.getDepartureTime()) + .stopName(stopName) + .routeName(routeName) + .build(); + } + + /** + * Checks if static stop time belongs to the specified route + */ + 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; + } + + /** + * Converts StopTime entity to StopTimeUpdateDTO + */ + private StopTimeUpdateDTO convertStopTimeToDTO(StopTime stopTime) { + return StopTimeUpdateDTO.builder() + .stopId(stopTime.getStopId()) + .tripId(stopTime.getTripId()) + .arrivalTime(stopTime.getArrivalTime()) + .departureTime(stopTime.getDepartureTime()) + .arrivalDelay(null) // No delay in static data + .departureDelay(null) + .build(); + } + + /** + * Compares two NextDepartureDTO by their actual departure time + */ + 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") + * Done after sorting for correct time order + */ + private NextDepartureDTO normalizeDepartureTime(NextDepartureDTO dto) { + String departureTime = dto.getDepartureTime(); + + if (departureTime == null) { + return dto; + } + + String[] parts = departureTime.split(":"); + if (parts.length >= 1) { + 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"); + + log.debug("[7a] Normalized departure time: {} -> {}", departureTime, normalizedTime); + + return NextDepartureDTO.builder() + .departureTime(normalizedTime) + .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/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/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); } From c13f4952647f2f0101a23ebfd136795ce217a260 Mon Sep 17 00:00:00 2001 From: Filip Date: Sun, 18 Jan 2026 12:03:03 +0100 Subject: [PATCH 06/13] small controller and service refactor --- .../agh/transit/DirectConnectionService.java | 16 ++-- .../java/pl/agh/transit/StopController.java | 3 +- .../java/pl/agh/transit/TripController.java | 20 ++--- src/main/java/pl/agh/transit/TripService.java | 17 ++-- ...irectConnectionServiceIntegrationTest.java | 44 +++++------ .../transit/DirectConnectionServiceTest.java | 77 +++++++++---------- .../TripControllerIntegrationTest.java | 4 +- 7 files changed, 85 insertions(+), 96 deletions(-) diff --git a/src/main/java/pl/agh/transit/DirectConnectionService.java b/src/main/java/pl/agh/transit/DirectConnectionService.java index e616f5f..122c4ed 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/DirectConnectionService.java @@ -8,9 +8,6 @@ import pl.agh.transit.dto.TripUpdateDTO; import pl.agh.transit.gtfs_static.model.Stop; 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; @@ -18,6 +15,7 @@ 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; @@ -25,12 +23,8 @@ @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; @@ -40,8 +34,8 @@ public class DirectConnectionService { * Returns up to 'count' number of connections, sorted by departure time * Uses today's date and current time by default */ - public List findFastestDirectConnection(String fromStopName, String toStopName) { - return findFastestDirectConnection(fromStopName, toStopName, LocalDate.now(), LocalTime.now(), 1); + public List findFastestDirectConnections(String fromStopName, String toStopName, int count) { + return findFastestDirectConnections(fromStopName, toStopName, LocalDate.now(), LocalTime.now(), count); } /** @@ -49,7 +43,7 @@ public List findFastestDirectConnection(String fromStopName, S * Returns up to 'count' number of connections, sorted by departure time * Takes into account delays from GTFS Realtime and service calendar */ - public List findFastestDirectConnection(String fromStopName, String toStopName, LocalDate travelDate, LocalTime travelTime, int count) { + 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 = connectionServiceHelper.getStopsForName(fromStopName); @@ -200,7 +194,7 @@ private List findStaticConnection(String fromStopId, List findStaticDestinationStop(fromStopTime, toStopCandidates, fromStopId)) .map(toStopTime -> buildStaticTransportDTO(toStopTime, fromStopId, travelDate)) - .filter(transport -> transport != null) + .filter(Objects::nonNull) .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) .toList(); } diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java index 230f1c2..3dc9aee 100644 --- a/src/main/java/pl/agh/transit/StopController.java +++ b/src/main/java/pl/agh/transit/StopController.java @@ -5,7 +5,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import pl.agh.transit.dto.PagedResponseDTO; @@ -42,10 +41,10 @@ public ResponseEntity> getNextDepartures( @RequestParam String routeName, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date ) { + //TODO: consider adding time parameter in the future and handling it in the service layer LocalDate travelDate = date != null ? date : LocalDate.now(); List departures = stopService.getNextDeparturesForStopAndRoute( stopName, routeName, travelDate, 5); return ResponseEntity.ok(departures); } - } diff --git a/src/main/java/pl/agh/transit/TripController.java b/src/main/java/pl/agh/transit/TripController.java index fb9bd97..fb0bc51 100644 --- a/src/main/java/pl/agh/transit/TripController.java +++ b/src/main/java/pl/agh/transit/TripController.java @@ -12,7 +12,6 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; -import java.util.Optional; @RestController @AllArgsConstructor @@ -26,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(); } @@ -36,17 +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 List getDirectConnectionsWithDate( + @GetMapping(value = "/fastest-direct-connections", produces = MediaType.APPLICATION_JSON_VALUE) + public List getFastestConnections( @RequestParam String from, @RequestParam String to, - @RequestParam String travelDate, - @RequestParam String travelTime, + @RequestParam(required = false) LocalDate travelDate, // (YYYY-MM-DD) + @RequestParam(required = false) LocalTime travelTime, // (HH:mm) @RequestParam(defaultValue = "3") int count ) { - LocalDate date = LocalDate.parse(travelDate); - LocalTime time = LocalTime.parse(travelTime); - return directConnectionService.findFastestDirectConnection(from, to, date, time, 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 22605bc..2778912 100644 --- a/src/main/java/pl/agh/transit/TripService.java +++ b/src/main/java/pl/agh/transit/TripService.java @@ -39,20 +39,19 @@ public StopTimeUpdateDTO getRandomStopTime(){ .orElseThrow(() -> new ServiceUnavailableException("No stop time updates available")); } - private Optional pickRandom(List list){ - if (list.isEmpty()){ - return Optional.empty(); - } - int randomIndex = random.nextInt(list.size()); - return Optional.of(list.get(randomIndex)); - } - public TransportTypeDTO getDirectConnection(String from , String to){ - List connection = directConnectionService.findFastestDirectConnection(from, 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(); + } + int randomIndex = random.nextInt(list.size()); + return Optional.of(list.get(randomIndex)); + } } diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java index 0f5c73c..40c281b 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -24,8 +24,6 @@ import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; -import java.time.LocalDate; - @SpringBootTest @Transactional @DisplayName("DirectConnectionService Integration Tests") @@ -154,7 +152,7 @@ private void setupTestData() { @DisplayName("Should find fastest direct connection from static data") void findFastestDirectConnection_staticData_returnsFastestRoute() { List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isEqualTo("1A"); @@ -162,58 +160,58 @@ void findFastestDirectConnection_staticData_returnsFastestRoute() { @Test @DisplayName("Should return empty when no direct connection exists") - void findFastestDirectConnection_noDirectConnection_returnsEmpty() { + void findFastestDirectConnection_noDirectConnections_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnection("Main Station", "Final Stop", LocalDate.now(), LocalTime.now(), 1); + .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() { + void findFastestDirectConnections_sourceStopNotFound_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnection("Unknown Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .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() { + void findFastestDirectConnections_destinationStopNotFound_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnection("Main Station", "Unknown Station", LocalDate.now(), LocalTime.now(), 1); + .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() { + void findFastestDirectConnections_sameSourceAndDestination_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnection("Main Station", "Main Station", LocalDate.now(), LocalTime.now(), 1); + .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); List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", futureDate, LocalTime.now(), 1); + .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(); List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", validDate, LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", validDate, LocalTime.now(), 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isEqualTo("1A"); @@ -221,13 +219,13 @@ void findFastestDirectConnection_validTravelDate_returnsConnection() { @Test @DisplayName("Should compare departure times and return earliest departure route") - void findFastestDirectConnection_comparesDepartureTimes_returnsEarliestDepartureRoute() { + 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 List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isEqualTo("1A"); @@ -235,7 +233,7 @@ void findFastestDirectConnection_comparesDepartureTimes_returnsEarliestDeparture @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"); @@ -280,7 +278,7 @@ void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { stopTimeRepository.save(stopTime3To); List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); // Should select route 1A (active, earliest departure), not 3C (inactive) assertThat(result).hasSize(1); @@ -289,7 +287,7 @@ void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { @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"); @@ -299,7 +297,7 @@ void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { stopRepository.save(duplicateStop); List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); // Should find connection even with multiple stops with same name assertThat(result).hasSize(1); @@ -308,9 +306,9 @@ void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { @Test @DisplayName("Should return valid arrival time for connection") - void findFastestDirectConnection_returnsNonNullArrivalTime() { + void findFastestDirectConnections_returnsNonNullArrivalTime() { List result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); 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 84cf73d..436dc7d 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -14,7 +14,6 @@ import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; import java.time.LocalDate; import java.time.LocalDateTime; @@ -65,7 +64,7 @@ void setUp() { @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") @@ -99,7 +98,7 @@ void findFastestDirectConnection_WithRealtimeData() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isEqualTo("1A"); @@ -107,25 +106,25 @@ void findFastestDirectConnection_WithRealtimeData() { @Test @DisplayName("Should return empty when no stops found") - void findFastestDirectConnection_NoStopsFound() { + void findFastestDirectConnections_NoStopsFound() { when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of()); when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + 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(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); when(connectionServiceHelper.getStopsForName("Unknown Destination")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "Unknown Destination", LocalDate.now(), LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "Unknown Destination", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -195,7 +194,7 @@ void findConnectionInTrip_WithMultipleStops() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isNotEqualTo("N/A"); @@ -203,7 +202,7 @@ void findConnectionInTrip_WithMultipleStops() { @Test @DisplayName("Should fallback to static data when no realtime data available") - void findFastestDirectConnection_FallbackToStaticData() { + void findFastestDirectConnections_FallbackToStaticData() { LocalDate travelDate = LocalDate.now(); when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); @@ -213,7 +212,7 @@ void findFastestDirectConnection_FallbackToStaticData() { when(tripMapper.toDtoList(any())).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); // When no realtime and no static data, should return empty assertThat(result).isEmpty(); @@ -221,7 +220,7 @@ void findFastestDirectConnection_FallbackToStaticData() { @Test @DisplayName("Should filter trips inactive on specific date") - void findFastestDirectConnection_WithSpecificDate() { + void findFastestDirectConnections_WithSpecificDate() { LocalDate travelDate = LocalDate.of(2026, 1, 6); when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); @@ -232,7 +231,7 @@ void findFastestDirectConnection_WithSpecificDate() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -274,7 +273,7 @@ void findConnectionInTrip_RouteNameFromRealtime() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); assertThat(result.get(0).getRouteName()).isEqualTo("1A"); @@ -316,7 +315,7 @@ void findConnectionInTrip_RouteNotFound() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); @@ -363,7 +362,7 @@ void findConnectionInTrip_SkipSameStop() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); } @@ -405,7 +404,7 @@ void parseGtfsTime() { .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); @@ -414,7 +413,7 @@ void parseGtfsTime() { @Test @DisplayName("Should handle empty trip updates list") - void findFastestDirectConnection_EmptyTripUpdates() { + void findFastestDirectConnections_EmptyTripUpdates() { when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); @@ -423,7 +422,7 @@ void findFastestDirectConnection_EmptyTripUpdates() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -465,14 +464,14 @@ void findFastestDirectConnection_SelectsFastest() { when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); 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"); @@ -510,7 +509,7 @@ void findFastestDirectConnection_MultipleDestinationStops() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); } @@ -527,7 +526,7 @@ void findStaticConnection_NoMatchingStopTimes() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -569,7 +568,7 @@ void parseArrivalTimeWithDelay() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); assertThat(result.get(0).getArrivalTime()).isNotNull(); @@ -586,7 +585,7 @@ void findConnectionInTrip_FromStopNotInTrip() { when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center" ,1); assertThat(result).isEmpty(); @@ -623,7 +622,7 @@ void findStaticConnection_WithComplexTripSequence() { when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(midStop)); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); // When no realtime data and static data doesn't match properly, should return empty assertThat(result).isEmpty(); @@ -631,7 +630,7 @@ void findStaticConnection_WithComplexTripSequence() { @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") @@ -666,7 +665,7 @@ void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissin .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then assertThat(result).hasSize(1); @@ -675,7 +674,7 @@ void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissin @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") @@ -710,7 +709,7 @@ void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then assertThat(result).hasSize(1); @@ -719,7 +718,7 @@ void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { @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") @@ -759,7 +758,7 @@ void findFastestDirectConnection_destinationMustBeAfterFromStop() { .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + 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. @@ -769,7 +768,7 @@ void findFastestDirectConnection_destinationMustBeAfterFromStop() { @Test @DisplayName("parseArrivalTimeWithDelay should apply arrivalDelay in seconds") - void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_appliesDelay() { // given TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -805,7 +804,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { .thenReturn(expectedArrival); // when - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", today, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", today, travelTime, 1); // then assertThat(result).hasSize(1); @@ -814,7 +813,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { @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") @@ -851,7 +850,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallb // when LocalDateTime before = LocalDateTime.now(); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then @@ -862,7 +861,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallb @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") @@ -899,7 +898,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbac // when LocalDateTime before = LocalDateTime.now(); - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then @@ -909,7 +908,7 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbac @Test @DisplayName("Should select earliest departure time when multiple connections available") - void findFastestDirectConnection_selectsEarliestDeparture() { + 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() @@ -977,7 +976,7 @@ void findFastestDirectConnection_selectsEarliestDeparture() { .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); // when - List result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate, travelTime, 1); + 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); 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()); } } From db870feed00229c4bd94cded57ecd10f99cbdc59 Mon Sep 17 00:00:00 2001 From: cnuart Date: Sun, 18 Jan 2026 12:38:46 +0100 Subject: [PATCH 07/13] fix some edge cases and refactor code --- .../java/pl/agh/transit/StopController.java | 8 +- src/main/java/pl/agh/transit/StopService.java | 197 +++++++++--------- src/main/resources/application.properties | 2 +- 3 files changed, 99 insertions(+), 108 deletions(-) diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java index 3dc9aee..818d1b6 100644 --- a/src/main/java/pl/agh/transit/StopController.java +++ b/src/main/java/pl/agh/transit/StopController.java @@ -39,12 +39,14 @@ public ResponseEntity> getAllStops( 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.DATE) LocalDate date, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) java.time.LocalTime time ) { - //TODO: consider adding time parameter in the future and handling it in the service layer + 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, 5); + 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 index 68bcda0..d5d0178 100644 --- a/src/main/java/pl/agh/transit/StopService.java +++ b/src/main/java/pl/agh/transit/StopService.java @@ -88,24 +88,17 @@ public PagedResponseDTO getAllStops(int page, int size, String sortBy) * Sanitize sort column to prevent SQL injection */ private String sanitizeSortColumn(String sortBy) { - // Whitelist of allowed columns - switch (sortBy.toLowerCase()) { - case "stopname": - case "stop_name": - return "stop_name"; - case "stopid": - case "stop_id": - case "id": - return "id"; - case "stoplat": - case "stop_lat": - return "stop_lat"; - case "stoplon": - case "stop_lon": - return "stop_lon"; - default: - return "stop_name"; + + 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"; + }; } /** @@ -123,9 +116,6 @@ public Stop mapRow(ResultSet rs, int rowNum) throws SQLException { } } - /** - * Convert Stop entity to DTO - */ private StopDTO convertToDTO(Stop stop) { return StopDTO.builder() .id(stop.getId()) @@ -145,13 +135,13 @@ 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: {}", - limit, stopName, routeName, travelDate); + log.debug("[0] Getting next {} departures for stop: {}, route: {}, date: {}, time: {}", + limit, stopName, routeName, travelDate, travelTime); - // Get departures for the initial travel date - List results = getNextDeparturesForDate(stopName, routeName, travelDate, limit); + List results = getNextDeparturesForDate(stopName, routeName, travelDate, travelTime, limit); // If insufficient results, fetch from next day if (results.size() < limit) { @@ -159,7 +149,7 @@ public List getNextDeparturesForStopAndRoute( 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, remainingCount); + List nextDayResults = getNextDeparturesForDate(stopName, routeName, nextDate, travelTime, remainingCount); results = Stream.concat(results.stream(), nextDayResults.stream()) .limit(limit) .toList(); @@ -171,19 +161,17 @@ public List getNextDeparturesForStopAndRoute( return results; } - /** - * Get departures for a specific date - */ + private List getNextDeparturesForDate( String stopName, String routeName, LocalDate travelDate, + java.time.LocalTime travelTime, int limit ) { - log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, limit: {}", - stopName, routeName, travelDate, limit); + log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, time: {}, limit: {}", + stopName, routeName, travelDate, travelTime, limit); - // Find stop by name List stops = connectionServiceHelper.getStopsForName(stopName); if (stops.isEmpty()) { log.debug("[0a] Stop not found: {}", stopName); @@ -202,21 +190,17 @@ private List getNextDeparturesForDate( log.debug("[1] Resolved to stopId: {}, routeId: {}", stopId, routeId); - LocalDateTime travelDateTime = travelDate.equals(LocalDate.now()) - ? LocalDateTime.now() - : travelDate.atStartOfDay(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); log.debug("[2] Travel datetime: {}", travelDateTime); - // Get realtime trip updates List allTrips = getTripUpdates(); log.debug("[3] Found {} trip updates", allTrips.size()); - // Filter trips by service calendar for the travel date List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); log.debug("[4] Active trips after calendar filter: {}", activeTrips.size()); - // Get realtime departures + List realtimeDepartures = activeTrips.stream() .filter(trip -> isRealtimeTripForRoute(trip, routeId)) .flatMap(trip -> findStopInRealtimeTrip(trip, stopId, travelDateTime, travelDate, stop.getStopName(), routeName)) @@ -225,13 +209,11 @@ private List getNextDeparturesForDate( log.debug("[5] Realtime departures: {}", realtimeDepartures.size()); - // Get static departures as fallback, but only those departing at least 1 hour after initial travel time List staticDepartures = findStaticDepartures( stopId, routeId, travelDate, travelDateTime, stop.getStopName(), routeName); - log.debug("[6] Static departures (at least 1h after last realtime): {}", staticDepartures.size()); + log.debug("[6] Static departures: {}", staticDepartures.size()); - // Merge realtime and static results, sort by departure time and limit List result = Stream.concat( realtimeDepartures.stream(), staticDepartures.stream() @@ -246,9 +228,6 @@ private List getNextDeparturesForDate( return result; } - /** - * Gets trip updates from GTFS Realtime feed - */ private List getTripUpdates() { log.debug("[3a] getTripUpdates START"); List entities = @@ -264,14 +243,12 @@ private List getTripUpdates() { * Checks if realtime trip belongs to the specified route */ private boolean isRealtimeTripForRoute(TripUpdateDTO trip, String routeId) { - // Check if route ID is directly in realtime data if (trip.getRouteId() != null && trip.getRouteId().equals(routeId)) { log.debug(" [4a] Trip {} matches route {} in realtime data", trip.getTripId(), routeId); return true; } - // Fallback to static data Optional staticTrip = staticTripRepository.findById(trip.getTripId()); boolean matches = staticTrip.isPresent() && staticTrip.get().getRouteId().equals(routeId); @@ -295,46 +272,55 @@ private Stream findStopInRealtimeTrip( String stopName, String routeName ) { - if (trip.getStopTimeUpdates() == null || trip.getStopTimeUpdates().isEmpty()) { + List stopTimeUpdates = trip.getStopTimeUpdates(); + + if (stopTimeUpdates == null || stopTimeUpdates.isEmpty()) { return Stream.empty(); } log.debug(" [5a] Checking trip: {} with {} stops", - trip.getTripId(), trip.getStopTimeUpdates().size()); + trip.getTripId(), stopTimeUpdates.size()); - return trip.getStopTimeUpdates().stream() + return stopTimeUpdates.stream() .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) - .filter(stopUpdate -> { - LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( - stopUpdate.getDepartureTime(), - travelDate, - stopUpdate.getDepartureDelay() - ); + .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate, trip.getTripId(), stopId)) + .map(stopUpdate -> buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName, trip.getTripId(), stopId)); + } - boolean notInPast = connectionServiceHelper.isConnectionNotInPast( - departureTime, travelDateTime); + private boolean isStopUpdateValid( + StopTimeUpdateDTO stopUpdate, + LocalDateTime travelDateTime, + LocalDate travelDate, + String tripId, + String stopId + ) { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + stopUpdate.getDepartureTime(), + travelDate, + stopUpdate.getDepartureDelay()); - if (!notInPast) { - log.debug(" [5b] Skipping - departure {} is before travel time {}", - departureTime, travelDateTime); - } + return connectionServiceHelper.isConnectionNotInPast(departureTime, travelDateTime); + } - return notInPast; - }) - .map(stopUpdate -> { - return NextDepartureDTO.builder() - .departureTime(stopUpdate.getDepartureTime()) - .departureDelay(stopUpdate.getDepartureDelay()) // Used for sorting - .stopName(stopName) - .routeName(routeName) - .build(); - }) - .peek(dto -> log.debug(" [5c] Found realtime departure at stop {} in trip {}", - stopId, trip.getTripId())); + 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 1 hour after initial travel time + * Find static departures at least 59 mins after current time */ private List findStaticDepartures( String stopId, @@ -346,8 +332,10 @@ private List findStaticDepartures( ) { log.debug("[6a] Checking static data for stop: {}, route: {}", stopId, routeId); - // Calculate minimum time for static departures (1 hour after initial travel time) - final LocalDateTime minStaticTime = travelDateTime.plusMinutes(59); + 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); @@ -360,7 +348,6 @@ private List findStaticDepartures( LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( st.getDepartureTime(), travelDate, null); - // Must be at least 1 hour after initial travel time boolean isAfterMinTime = !departureTime.isBefore(minStaticTime); if (!isAfterMinTime) { @@ -377,9 +364,6 @@ private List findStaticDepartures( } - /** - * Converts StopTime entity to NextDepartureDTO - */ private NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName, String routeName) { return NextDepartureDTO.builder() .departureTime(stopTime.getDepartureTime()) @@ -388,9 +372,6 @@ private NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName .build(); } - /** - * Checks if static stop time belongs to the specified route - */ private boolean isStaticStopTimeForRoute(StopTime stopTime, String routeId) { Optional trip = staticTripRepository.findById(stopTime.getTripId()); boolean matches = trip.isPresent() && trip.get().getRouteId().equals(routeId); @@ -403,23 +384,6 @@ private boolean isStaticStopTimeForRoute(StopTime stopTime, String routeId) { return matches; } - /** - * Converts StopTime entity to StopTimeUpdateDTO - */ - private StopTimeUpdateDTO convertStopTimeToDTO(StopTime stopTime) { - return StopTimeUpdateDTO.builder() - .stopId(stopTime.getStopId()) - .tripId(stopTime.getTripId()) - .arrivalTime(stopTime.getArrivalTime()) - .departureTime(stopTime.getDepartureTime()) - .arrivalDelay(null) // No delay in static data - .departureDelay(null) - .build(); - } - - /** - * Compares two NextDepartureDTO by their actual departure time - */ private int compareByDepartureTime(NextDepartureDTO dto1, NextDepartureDTO dto2, LocalDate travelDate) { LocalDateTime time1 = connectionServiceHelper.parseTimeWithDelay( dto1.getDepartureTime(), travelDate, dto1.getDepartureDelay()); @@ -431,7 +395,7 @@ private int compareByDepartureTime(NextDepartureDTO dto1, NextDepartureDTO dto2, /** * Normalize times > 23:59 to times from start of day (e.g., "25:30" -> "01:30") - * Done after sorting for correct time order + * When hours >= 24, also increment the date by 1 day */ private NextDepartureDTO normalizeDepartureTime(NextDepartureDTO dto) { String departureTime = dto.getDepartureTime(); @@ -440,8 +404,23 @@ private NextDepartureDTO normalizeDepartureTime(NextDepartureDTO dto) { return dto; } - String[] parts = departureTime.split(":"); - if (parts.length >= 1) { + 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]); @@ -450,10 +429,20 @@ private NextDepartureDTO normalizeDepartureTime(NextDepartureDTO dto) { String normalizedTime = String.format("%02d:%s:%s", hours, parts[1], parts.length > 2 ? parts[2] : "00"); - log.debug("[7a] Normalized departure time: {} -> {}", departureTime, normalizedTime); + 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(normalizedTime) + .departureTime(fullNormalizedTime) .stopName(dto.getStopName()) .routeName(dto.getRouteName()) .build(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5962ebb..9b69ecc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,4 +27,4 @@ spring.h2.console.path=/h2 spring.sql.init.mode=always spring.sql.init.platform=h2 -#logging.level.pl.agh.transit=DEBUG +logging.level.pl.agh.transit=DEBUG From 000c7a007bf79be1515cdaba62ebc097fa6622ec Mon Sep 17 00:00:00 2001 From: cnuart Date: Sun, 18 Jan 2026 12:51:01 +0100 Subject: [PATCH 08/13] add testing for StopService --- .../java/pl/agh/transit/StopServiceTest.java | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 src/test/java/pl/agh/transit/StopServiceTest.java 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(); + } +} From 5ec20818d8a4920a924f20c063f7b0da25e0b971 Mon Sep 17 00:00:00 2001 From: Filip Date: Sun, 18 Jan 2026 13:35:26 +0100 Subject: [PATCH 09/13] integrate mcp protocol and create tools for mcp client --- .gitignore | 1 + README.md | 28 +++++++++++ build.gradle | 15 +++++- .../TransitLocationApiApplication.java | 12 +++++ .../java/pl/agh/transit/mcp/StopMcpTools.java | 50 +++++++++++++++++++ .../java/pl/agh/transit/mcp/TripMcpTools.java | 34 +++++++++++++ src/main/resources/application.properties | 7 ++- src/main/resources/logback-spring.xml | 11 ++++ 8 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/main/java/pl/agh/transit/mcp/StopMcpTools.java create mode 100644 src/main/java/pl/agh/transit/mcp/TripMcpTools.java create mode 100644 src/main/resources/logback-spring.xml diff --git a/.gitignore b/.gitignore index 057147d..f8a7ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ HELP.md /data/* !/data/.gitkeep .gradle +.log build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/README.md b/README.md index fc8978c..0bcc234 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,34 @@ When no data is available: } ``` +## MCP Client Setup + +### Claude Desktop + +1. Download and install Claude Desktop from the official website. +2. Add java 25 JDK to your system PATH. +3. In repository directory, run: + ```bash + ./gradlew clean bootJar + ``` +4. Copy absolute path of the JAR generated in `build/libs/`. +5. Go to claude_desktop_config.json +macOS: ~/Library/Application Support/Claude/claude_desktop_config.json +Windows: %APPDATA%\Claude\claude_desktop_config.json +6. Add the following entry to the main json object: + ```json + "mcpServers": { + "krakow-transport": { + "command": "java", + "args": [ + "-jar", + "your/copied/path/transit-location-api-0.0.1-SNAPSHOT.jar", + ] + } + } + ``` +7. Launch Claude Desktop and select "krakow-transport" as the MCP server. + ## Data Flow 1. **Initialization** (`@PostConstruct`): diff --git a/build.gradle b/build.gradle index 2074879..79055af 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,11 @@ configurations { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } +} + +ext { + set('springAiVersion', "1.1.2") } protobuf { @@ -34,7 +39,8 @@ protobuf { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.ai:spring-ai-starter-mcp-server' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' annotationProcessor 'org.projectlombok:lombok' implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' @@ -43,11 +49,18 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'com.google.protobuf:protobuf-java:4.28.2' testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' } +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" + } +} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/pl/agh/transit/TransitLocationApiApplication.java b/src/main/java/pl/agh/transit/TransitLocationApiApplication.java index 31c0a72..111099c 100644 --- a/src/main/java/pl/agh/transit/TransitLocationApiApplication.java +++ b/src/main/java/pl/agh/transit/TransitLocationApiApplication.java @@ -1,8 +1,15 @@ package pl.agh.transit; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; +import pl.agh.transit.mcp.StopMcpTools; +import pl.agh.transit.mcp.TripMcpTools; + +import java.util.List; @EnableScheduling @SpringBootApplication @@ -10,4 +17,9 @@ public class TransitLocationApiApplication { public static void main(String[] args) { SpringApplication.run(TransitLocationApiApplication.class, args); } + + @Bean + public List mcpToolCallbacks(StopMcpTools stopMcpTools, TripMcpTools tripMcpTools) { + return List.of(ToolCallbacks.from(stopMcpTools, tripMcpTools)); + } } \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/mcp/StopMcpTools.java b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java new file mode 100644 index 0000000..d8df4a8 --- /dev/null +++ b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java @@ -0,0 +1,50 @@ +package pl.agh.transit.mcp; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import pl.agh.transit.StopService; +import pl.agh.transit.dto.NextDepartureDTO; +import pl.agh.transit.dto.PagedResponseDTO; +import pl.agh.transit.dto.StopDTO; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class StopMcpTools { + + private final StopService stopService; + + @Tool(description = "Retrieves a paginated list of all available public transport stops.") + public PagedResponseDTO getAllStops( + @ToolParam(description = "Page number (starts from 0, default is 0)", required = false) Integer page, + @ToolParam(description = "Number of stops per page (default is 10, max is 100)", required = false) Integer size, + @ToolParam(description = "Field to sort by (default is 'stopName')", required = false) String sortBy + ) { + int pageNum = (page != null) ? page : 0; + int pageSize = (size != null) ? size : 10; + String sort = (sortBy != null && !sortBy.isBlank()) ? sortBy : "stopName"; + + if (pageSize > 100) { + pageSize = 100; + } + + return stopService.getAllStops(pageNum, pageSize, sort); + } + + @Tool(description = "Gets the next 5 departures for a specific stop and route (line number). " + + "Includes real-time delay information if available.") + public List getNextDepartures( + @ToolParam(description = "Name of the stop (e.g., 'Teatr Bagatela')") String stopName, + @ToolParam(description = "Route name or line number (e.g., '52', '179')") String routeName, + @ToolParam(description = "Date to check schedule for (YYYY-MM-DD). If omitted, defaults to today.", required = false) LocalDate date + ) { + //TODO: consider adding time parameter in the future and handling it in the service layer + LocalDate travelDate = (date != null) ? date : LocalDate.now(); + + return stopService.getNextDeparturesForStopAndRoute(stopName, routeName, travelDate, 5); + } +} \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/mcp/TripMcpTools.java b/src/main/java/pl/agh/transit/mcp/TripMcpTools.java new file mode 100644 index 0000000..1046126 --- /dev/null +++ b/src/main/java/pl/agh/transit/mcp/TripMcpTools.java @@ -0,0 +1,34 @@ +package pl.agh.transit.mcp; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import pl.agh.transit.DirectConnectionService; +import pl.agh.transit.dto.TransportTypeDTO; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class TripMcpTools { + + private final DirectConnectionService directConnectionService; + + @Tool(description = "Finds the 3 fastest direct public transport connections between two stops. " + + "If date and time are not provided, it defaults to the current time.") + public List getFastestConnections( + @ToolParam(description = "Name of the starting stop (origin)") String from, + @ToolParam(description = "Name of the destination stop") String to, + @ToolParam(description = "Travel date in YYYY-MM-DD format (optional)", required = false) LocalDate travelDate, + @ToolParam(description = "Travel time in HH:mm format (optional)", required = false) LocalTime travelTime + ) { + if (travelDate != null && travelTime != null) { + return directConnectionService.findFastestDirectConnections(from, to, travelDate, travelTime, 3); + } else { + return directConnectionService.findFastestDirectConnections(from, to, 3); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5962ebb..15564b6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,4 +27,9 @@ spring.h2.console.path=/h2 spring.sql.init.mode=always spring.sql.init.platform=h2 -#logging.level.pl.agh.transit=DEBUG +spring.main.banner-mode=off +spring.main.log-startup-info=false +logging.file.name=logs/application.log + +spring.ai.mcp.server.name=krakow-transport +spring.ai.mcp.server.version=1.0.0 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..94e11fc --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,11 @@ + + + System.err + + %msg%n + + + + + + \ No newline at end of file From c547d107c29f1219377862675d16c9287c90b972 Mon Sep 17 00:00:00 2001 From: Filip Date: Mon, 19 Jan 2026 18:00:10 +0100 Subject: [PATCH 10/13] small StopService refactor, more needed --- .../java/pl/agh/transit/StopController.java | 13 +-- src/main/java/pl/agh/transit/StopService.java | 96 +++++++++---------- .../java/pl/agh/transit/mcp/StopMcpTools.java | 11 ++- 3 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java index 783a131..217f96a 100644 --- a/src/main/java/pl/agh/transit/StopController.java +++ b/src/main/java/pl/agh/transit/StopController.java @@ -43,12 +43,13 @@ public ResponseEntity> getNextDepartures( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) LocalTime time ) { - //TODO: consider handling it in the service layer - - LocalDate travelDate = date != null ? date : LocalDate.now(); - LocalTime travelTime = time != null ? time : LocalTime.now(); - List departures = stopService.getNextDeparturesForStopAndRoute( - stopName, routeName, travelDate, travelTime, 5); + List departures; + if (date != null && time != null) { + departures = stopService.getNextDeparturesForStopAndRoute(stopName, routeName, date, time, 5); + } + else{ + departures = stopService.getNextDeparturesForStopAndRoute(stopName, routeName,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 index d5d0178..4404ce3 100644 --- a/src/main/java/pl/agh/transit/StopService.java +++ b/src/main/java/pl/agh/transit/StopService.java @@ -15,18 +15,15 @@ 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.time.LocalTime; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -37,7 +34,6 @@ @AllArgsConstructor public class StopService { - private final StopRepository stopRepository; private final JdbcTemplate jdbcTemplate; private final ConnectionServiceHelper connectionServiceHelper; private final StaticTripRepository staticTripRepository; @@ -69,7 +65,7 @@ public PagedResponseDTO getAllStops(int page, int size, String sortBy) List stopDTOs = stops.stream() .map(this::convertToDTO) - .collect(Collectors.toList()); + .toList(); log.debug("Found {} stops on page {} of {}", stopDTOs.size(), page, totalPages); @@ -84,6 +80,49 @@ public PagedResponseDTO getAllStops(int page, int size, String sortBy) .build(); } + public List getNextDeparturesForStopAndRoute(String stopName, String routeName, int limit) { + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + + return getNextDeparturesForStopAndRoute(stopName, routeName, travelDate, travelTime, limit); + } + + /** + * 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, + 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; + } + /** * Sanitize sort column to prevent SQL injection */ @@ -125,43 +164,6 @@ private StopDTO convertToDTO(Stop stop) { .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, @@ -177,7 +179,7 @@ private List getNextDeparturesForDate( log.debug("[0a] Stop not found: {}", stopName); return List.of(); } - Stop stop = stops.get(0); + Stop stop = stops.getFirst(); String stopId = stop.getId(); Optional routeOptional = routeRepository.findByRouteShortName(routeName); @@ -283,16 +285,14 @@ private Stream findStopInRealtimeTrip( return stopTimeUpdates.stream() .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) - .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate, trip.getTripId(), stopId)) + .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate)) .map(stopUpdate -> buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName, trip.getTripId(), stopId)); } private boolean isStopUpdateValid( StopTimeUpdateDTO stopUpdate, LocalDateTime travelDateTime, - LocalDate travelDate, - String tripId, - String stopId + LocalDate travelDate ) { LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( stopUpdate.getDepartureTime(), diff --git a/src/main/java/pl/agh/transit/mcp/StopMcpTools.java b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java index a59aace..b1947db 100644 --- a/src/main/java/pl/agh/transit/mcp/StopMcpTools.java +++ b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java @@ -44,10 +44,11 @@ public List getNextDepartures( @ToolParam(description = "Date to check schedule for (YYYY-MM-DD). If omitted, defaults to today.", required = false) LocalDate date, @ToolParam(description = "Time to check schedule for (HH:mm). If omitted, defaults to right now.", required = false) LocalTime time ) { - //TODO: consider handling it in the service layer - LocalDate travelDate = (date != null) ? date : LocalDate.now(); - LocalTime travelTime = time != null ? time : LocalTime.now(); - - return stopService.getNextDeparturesForStopAndRoute(stopName, routeName, travelDate, travelTime, 5); + if (date != null && time != null) { + return stopService.getNextDeparturesForStopAndRoute(stopName, routeName, date, time, 5); + } + else{ + return stopService.getNextDeparturesForStopAndRoute(stopName, routeName,5); + } } } \ No newline at end of file From 29477d46d270ccaee2f4455e221334bb2483ec3a Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 23 Jan 2026 12:38:26 +0100 Subject: [PATCH 11/13] major StopService refactor --- .../agh/transit/ConnectionServiceHelper.java | 38 -- .../agh/transit/DirectConnectionService.java | 16 +- src/main/java/pl/agh/transit/StopMapper.java | 107 ++++++ src/main/java/pl/agh/transit/StopService.java | 230 +++--------- src/main/java/pl/agh/transit/TripMapper.java | 37 +- .../repository/StopRepository.java | 3 +- src/main/resources/application.properties | 3 +- .../transit/ConnectionServiceHelperTest.java | 46 +-- .../transit/DirectConnectionServiceTest.java | 170 ++++----- .../java/pl/agh/transit/StopMapperTest.java | 149 ++++++++ .../java/pl/agh/transit/StopServiceTest.java | 351 ++++++------------ 11 files changed, 530 insertions(+), 620 deletions(-) create mode 100644 src/main/java/pl/agh/transit/StopMapper.java create mode 100644 src/test/java/pl/agh/transit/StopMapperTest.java diff --git a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java index 111cbe9..ed371ff 100644 --- a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java +++ b/src/main/java/pl/agh/transit/ConnectionServiceHelper.java @@ -4,15 +4,11 @@ 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; @@ -28,20 +24,9 @@ @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 @@ -160,20 +145,6 @@ public boolean isConnectionNotInPast(LocalDateTime departureTime, LocalDateTime 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 */ @@ -193,13 +164,4 @@ public boolean isStaticServiceActiveForTrip(StopTime stopTime, LocalDate travelD 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 122c4ed..9724306 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/DirectConnectionService.java @@ -8,6 +8,7 @@ import pl.agh.transit.dto.TripUpdateDTO; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; @@ -25,6 +26,7 @@ public class DirectConnectionService { private final StopTimeRepository stopTimeRepository; private final TripRepository tripRepository; + private final StopRepository stopRepository; private final TripMapper tripMapper; private final ConnectionServiceHelper connectionServiceHelper; @@ -46,10 +48,10 @@ public List findFastestDirectConnections(String fromStopName, 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 = connectionServiceHelper.getStopsForName(fromStopName); + List fromStops = stopRepository.findByStopName(fromStopName); log.debug("[2] Found {} stops for: {}", fromStops.size(), fromStopName); - List toStops = connectionServiceHelper.getStopsForName(toStopName); + List toStops = stopRepository.findByStopName(toStopName); log.debug("[3] Found {} stops for: {}", toStops.size(), toStopName); if (fromStops.isEmpty() || toStops.isEmpty()) { @@ -101,7 +103,6 @@ public List findFastestDirectConnections(String fromStopName, return result; } - /** * 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 @@ -132,10 +133,10 @@ private Stream findConnectionInTrip(TripUpdateDTO trip, String String routeName = connectionServiceHelper.getRealtimeRouteName(trip); - return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate, travelTime); + return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate); } - private Stream findAllToStopsInTrip(List stops, int fromStopSequence, List toStopCandidates, String fromStopId, String routeName, LocalDateTime departureTime, LocalDate travelDate, LocalTime travelTime) { + private Stream findAllToStopsInTrip(List stops, int fromStopSequence, List toStopCandidates, String fromStopId, String routeName, LocalDateTime departureTime, LocalDate travelDate) { return stops.stream() .skip(fromStopSequence + 1) .filter(stop -> isRealtimeDestinationCandidate(stop, toStopCandidates, fromStopId)) @@ -257,8 +258,6 @@ private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fro .build(); } - - private List getTripUpdates() { log.debug("[6a] getTripUpdates START"); List entities = tripRepository.getEntitiesOrEmpty(); @@ -269,5 +268,4 @@ private List getTripUpdates() { log.debug("[6c] getTripUpdates END: {} trips", result.size()); return result; } -} - +} \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/StopMapper.java b/src/main/java/pl/agh/transit/StopMapper.java new file mode 100644 index 0000000..330e1de --- /dev/null +++ b/src/main/java/pl/agh/transit/StopMapper.java @@ -0,0 +1,107 @@ +package pl.agh.transit; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import pl.agh.transit.dto.NextDepartureDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.StopTime; + +import java.time.LocalDate; + +@Slf4j +@Component +public class StopMapper { + + public StopDTO convertToDTO(Stop stop) { + return StopDTO.builder() + .id(stop.getId()) + .name(stop.getStopName()) + .latitude(stop.getStopLat()) + .longitude(stop.getStopLon()) + .build(); + } + + public NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName, String routeName) { + return NextDepartureDTO.builder() + .departureTime(stopTime.getDepartureTime()) + .stopName(stopName) + .routeName(routeName) + .build(); + } + + /** + * 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 + */ + public 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; + } + + return NextDepartureDTO.builder() + .departureTime(fullNormalizedTime) + .stopName(dto.getStopName()) + .routeName(dto.getRouteName()) + .build(); + } + } catch (NumberFormatException _) { + log.warn("Invalid time format for stop {}: '{}'", dto.getStopName(), timeToNormalize); + return dto; + } + } + + return dto; + } + + public NextDepartureDTO buildNextDepartureDTOFromUpdate( + StopTimeUpdateDTO stopUpdate, + String stopName, + String routeName + ) { + return NextDepartureDTO.builder() + .departureTime(stopUpdate.getDepartureTime()) + .departureDelay(stopUpdate.getDepartureDelay()) + .stopName(stopName) + .routeName(routeName) + .build(); + } +} diff --git a/src/main/java/pl/agh/transit/StopService.java b/src/main/java/pl/agh/transit/StopService.java index 4404ce3..b2ea373 100644 --- a/src/main/java/pl/agh/transit/StopService.java +++ b/src/main/java/pl/agh/transit/StopService.java @@ -2,8 +2,10 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import pl.agh.transit.dto.PagedResponseDTO; import pl.agh.transit.dto.StopDTO; @@ -16,9 +18,10 @@ import pl.agh.transit.gtfs_static.model.Route; import pl.agh.transit.gtfs_static.repository.StaticTripRepository; import pl.agh.transit.gtfs_static.repository.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.StopTimeRepository; + -import java.sql.ResultSet; -import java.sql.SQLException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -26,61 +29,53 @@ import java.util.Optional; import java.util.stream.Stream; -/** - * Service for managing stops with JDBC and real-time departures - */ + @Slf4j @Service @AllArgsConstructor public class StopService { - private final JdbcTemplate jdbcTemplate; private final ConnectionServiceHelper connectionServiceHelper; private final StaticTripRepository staticTripRepository; private final RouteRepository routeRepository; private final TripRepository tripRepository; + private final StopRepository stopRepository; + private final StopTimeRepository stopTimeRepository; private final TripMapper tripMapper; + private final StopMapper stopMapper; - /** - * 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; + Sort sort = (sortBy != null && !sortBy.isBlank()) + ? Sort.by(sortBy) + : Sort.unsorted(); - String countSql = "SELECT COUNT(*) FROM stops"; - Long totalElements = jdbcTemplate.queryForObject(countSql, Long.class); - if (totalElements == null) totalElements = 0L; + Pageable pageable = PageRequest.of(page, size, sort); - int totalPages = (int) Math.ceil((double) totalElements / size); + Page stopPage = stopRepository.findAll(pageable); - 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) + List stopDTOs = stopPage.getContent().stream() + .map(stopMapper::convertToDTO) .toList(); - log.debug("Found {} stops on page {} of {}", stopDTOs.size(), page, totalPages); + log.debug("Found {} stops on page {} of {}", stopDTOs.size(), page, stopPage.getTotalPages()); return PagedResponseDTO.builder() .content(stopDTOs) - .pageNumber(page) - .totalPages(totalPages) - .totalElements(totalElements) - .pageSize(size) - .hasNext(page < totalPages - 1) - .hasPrevious(page > 0) + .pageNumber(stopPage.getNumber()) + .totalPages(stopPage.getTotalPages()) + .totalElements(stopPage.getTotalElements()) + .pageSize(stopPage.getSize()) + .hasNext(stopPage.hasNext()) + .hasPrevious(stopPage.hasPrevious()) .build(); } - public List getNextDeparturesForStopAndRoute(String stopName, String routeName, int limit) { + public List getNextDeparturesForStopAndRoute( + String stopName, + String routeName, + int limit) { LocalDate travelDate = LocalDate.now(); LocalTime travelTime = LocalTime.now(); @@ -123,47 +118,6 @@ public List getNextDeparturesForStopAndRoute( return results; } - /** - * 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(); - } - private List getNextDeparturesForDate( String stopName, String routeName, @@ -174,7 +128,7 @@ private List getNextDeparturesForDate( log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, time: {}, limit: {}", stopName, routeName, travelDate, travelTime, limit); - List stops = connectionServiceHelper.getStopsForName(stopName); + List stops = stopRepository.findByStopName(stopName); if (stops.isEmpty()) { log.debug("[0a] Stop not found: {}", stopName); return List.of(); @@ -218,12 +172,11 @@ private List getNextDeparturesForDate( List result = Stream.concat( realtimeDepartures.stream(), - staticDepartures.stream() - ) + staticDepartures.stream()) .sorted((dto1, dto2) -> compareByDepartureTime(dto1, dto2, travelDate)) .distinct() .limit(limit) - .map(this::normalizeDepartureTime) + .map(stopMapper::normalizeDepartureTime) .toList(); log.debug("[7] Final results for date {}: {}", travelDate, result.size()); @@ -286,7 +239,7 @@ private Stream findStopInRealtimeTrip( return stopTimeUpdates.stream() .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate)) - .map(stopUpdate -> buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName, trip.getTripId(), stopId)); + .map(stopUpdate -> stopMapper.buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName)); } private boolean isStopUpdateValid( @@ -302,23 +255,6 @@ private boolean isStopUpdateValid( 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 */ @@ -338,38 +274,29 @@ private List findStaticDepartures( log.debug("[6a1] Initial travel time: {}, min static time: {}", travelDateTime, minStaticTime); - List stopTimesForStop = connectionServiceHelper.getStopTimesForStop(stopId); + List stopTimesForStop = stopTimeRepository.findByStopId(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)) + .filter(st -> isAfterMinTime(st, travelDate, minStaticTime)) + .map(st -> stopMapper.convertStopTimeToDTO(st, stopName, routeName)) .toList(); } + private boolean isAfterMinTime(StopTime stopTime, LocalDate travelDate, LocalDateTime minStaticTime) { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + stopTime.getDepartureTime(), travelDate, null); - private NextDepartureDTO convertStopTimeToDTO(StopTime stopTime, String stopName, String routeName) { - return NextDepartureDTO.builder() - .departureTime(stopTime.getDepartureTime()) - .stopName(stopName) - .routeName(routeName) - .build(); + boolean isAfterMinTime = !departureTime.isBefore(minStaticTime); + + if (!isAfterMinTime) { + log.debug("[6c] Skipping static - departure {} is before min time {}", + departureTime, minStaticTime); + } + + return isAfterMinTime; } private boolean isStaticStopTimeForRoute(StopTime stopTime, String routeId) { @@ -392,67 +319,4 @@ private int compareByDepartureTime(NextDepartureDTO dto1, NextDepartureDTO dto2, 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/TripMapper.java b/src/main/java/pl/agh/transit/TripMapper.java index c443a28..13b6849 100644 --- a/src/main/java/pl/agh/transit/TripMapper.java +++ b/src/main/java/pl/agh/transit/TripMapper.java @@ -15,22 +15,13 @@ @Component public class TripMapper { - private String extractTime(GtfsRealtime.TripUpdate.StopTimeEvent event) { - return Optional.ofNullable(event) - .filter(GtfsRealtime.TripUpdate.StopTimeEvent::hasTime) - .map(GtfsRealtime.TripUpdate.StopTimeEvent::getTime) - .map(this::convertUnixTime) - .orElse(null); - } - - private Integer extractDelay(GtfsRealtime.TripUpdate.StopTimeEvent event) { - return Optional.ofNullable(event) - .filter(GtfsRealtime.TripUpdate.StopTimeEvent::hasDelay) - .map(GtfsRealtime.TripUpdate.StopTimeEvent::getDelay) - .orElse(null); + public List toDtoList(List entities) { + return entities.stream() + .filter(GtfsRealtime.FeedEntity::hasTripUpdate) + .map(this::toDto) + .toList(); } - public TripUpdateDTO toDto(GtfsRealtime.FeedEntity entity) { if (!entity.hasTripUpdate()) return null; @@ -56,11 +47,19 @@ public TripUpdateDTO toDto(GtfsRealtime.FeedEntity entity) { .build(); } - public List toDtoList(List entities) { - return entities.stream() - .filter(GtfsRealtime.FeedEntity::hasTripUpdate) - .map(this::toDto) - .toList(); + private String extractTime(GtfsRealtime.TripUpdate.StopTimeEvent event) { + return Optional.ofNullable(event) + .filter(GtfsRealtime.TripUpdate.StopTimeEvent::hasTime) + .map(GtfsRealtime.TripUpdate.StopTimeEvent::getTime) + .map(this::convertUnixTime) + .orElse(null); + } + + private Integer extractDelay(GtfsRealtime.TripUpdate.StopTimeEvent event) { + return Optional.ofNullable(event) + .filter(GtfsRealtime.TripUpdate.StopTimeEvent::hasDelay) + .map(GtfsRealtime.TripUpdate.StopTimeEvent::getDelay) + .orElse(null); } private String convertUnixTime(long unixTime) { 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 a8a9323..c073f9e 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 @@ -1,12 +1,13 @@ package pl.agh.transit.gtfs_static.repository; import org.springframework.data.repository.ListCrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.stereotype.Repository; import pl.agh.transit.gtfs_static.model.Stop; import java.util.List; @Repository -public interface StopRepository extends ListCrudRepository { +public interface StopRepository extends ListCrudRepository, PagingAndSortingRepository { 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 dc641b2..052e79a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,7 +27,8 @@ spring.h2.console.path=/h2 spring.sql.init.mode=always spring.sql.init.platform=h2 -#logging.level.pl.agh.transit=DEBUG uncomment to enable debug logs, disabled for use with MCP Client +#uncomment to enable debug logs, disabled for use with MCP Client +#logging.level.pl.agh.transit=DEBUG logging.file.name=logs/application.log diff --git a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java index 493b99c..b1f1dce 100644 --- a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java +++ b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java @@ -21,16 +21,12 @@ 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; @@ -68,27 +64,6 @@ void setUp() { 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() { @@ -115,7 +90,7 @@ void filterTripsByServiceCalendar_RemovesInactiveTrips() { List result = connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now()); assertThat(result).hasSize(1); - assertThat(result.get(0).getTripId()).isEqualTo("trip-1"); + assertThat(result.getFirst().getTripId()).isEqualTo("trip-1"); } @Test @@ -188,14 +163,14 @@ void parseTimeWithDelay_DatetimeFormat() { @Test @DisplayName("Should get route name from realtime data") void getRealtimeRouteName_FromRealtimeData() { - TripUpdateDTO trip = TripUpdateDTO.builder() + TripUpdateDTO tripDto = TripUpdateDTO.builder() .tripId("trip-1") .routeId("route-1") .build(); when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - String result = connectionServiceHelper.getRealtimeRouteName(trip); + String result = connectionServiceHelper.getRealtimeRouteName(tripDto); assertThat(result).isEqualTo("1A"); } @@ -203,7 +178,7 @@ void getRealtimeRouteName_FromRealtimeData() { @Test @DisplayName("Should fallback to static data when realtime route not found") void getRealtimeRouteName_FallbackToStaticData() { - TripUpdateDTO trip = TripUpdateDTO.builder() + TripUpdateDTO tripDto = TripUpdateDTO.builder() .tripId("trip-1") .routeId("route-unknown") .build(); @@ -212,7 +187,7 @@ void getRealtimeRouteName_FallbackToStaticData() { when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(this.trip)); when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - String result = connectionServiceHelper.getRealtimeRouteName(trip); + String result = connectionServiceHelper.getRealtimeRouteName(tripDto); assertThat(result).isEqualTo("1A"); } @@ -220,7 +195,7 @@ void getRealtimeRouteName_FallbackToStaticData() { @Test @DisplayName("Should return N/A when route not found anywhere") void getRealtimeRouteName_NotFound() { - TripUpdateDTO trip = TripUpdateDTO.builder() + TripUpdateDTO tripDto = TripUpdateDTO.builder() .tripId("trip-1") .routeId("route-unknown") .build(); @@ -228,7 +203,7 @@ void getRealtimeRouteName_NotFound() { when(routeRepository.findById("route-unknown")).thenReturn(Optional.empty()); when(staticTripRepository.findById("trip-1")).thenReturn(Optional.empty()); - String result = connectionServiceHelper.getRealtimeRouteName(trip); + String result = connectionServiceHelper.getRealtimeRouteName(tripDto); assertThat(result).isEqualTo("N/A"); } @@ -236,7 +211,7 @@ void getRealtimeRouteName_NotFound() { @Test @DisplayName("Should return N/A when routeId is null") void getRealtimeRouteName_NullRouteId() { - TripUpdateDTO trip = TripUpdateDTO.builder() + TripUpdateDTO tripDto = TripUpdateDTO.builder() .tripId("trip-1") .routeId(null) .build(); @@ -244,7 +219,7 @@ void getRealtimeRouteName_NullRouteId() { when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(this.trip)); when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - String result = connectionServiceHelper.getRealtimeRouteName(trip); + String result = connectionServiceHelper.getRealtimeRouteName(tripDto); assertThat(result).isEqualTo("1A"); } @@ -313,5 +288,4 @@ void isConnectionNotInPast_DifferentDate() { assertThat(result).isTrue(); } -} - +} \ No newline at end of file diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java index 436dc7d..3962103 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -13,6 +13,7 @@ import pl.agh.transit.dto.TripUpdateDTO; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; @@ -37,6 +38,9 @@ class DirectConnectionServiceTest { @Mock private ConnectionServiceHelper connectionServiceHelper; + + @Mock + private StopRepository stopRepository; @Mock TripMapper tripMapper; @@ -86,8 +90,8 @@ void findFastestDirectConnections_WithRealtimeData() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -101,15 +105,15 @@ void findFastestDirectConnections_WithRealtimeData() { List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return empty when no stops found") void findFastestDirectConnections_NoStopsFound() { - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of()); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of()); + when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); @@ -120,8 +124,8 @@ void findFastestDirectConnections_NoStopsFound() { @DisplayName("Should return empty when destination stops not found") void findFastestDirectConnections_NoDestinationStopsFound() { - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("Unknown Destination")).thenReturn(List.of()); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("Unknown Destination")).thenReturn(List.of()); List result = directConnectionService.findFastestDirectConnections("Main Station", "Unknown Destination", LocalDate.now(), LocalTime.now(), 1); @@ -182,8 +186,8 @@ void findConnectionInTrip_WithMultipleStops() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -197,7 +201,7 @@ void findConnectionInTrip_WithMultipleStops() { List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isNotEqualTo("N/A"); + assertThat(result.getFirst().getRouteName()).isNotEqualTo("N/A"); } @Test @@ -205,8 +209,8 @@ void findConnectionInTrip_WithMultipleStops() { void findFastestDirectConnections_FallbackToStaticData() { LocalDate travelDate = LocalDate.now(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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()); @@ -223,8 +227,8 @@ void findFastestDirectConnections_FallbackToStaticData() { void findFastestDirectConnections_WithSpecificDate() { LocalDate travelDate = LocalDate.of(2026, 1, 6); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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()); @@ -261,8 +265,8 @@ void findConnectionInTrip_RouteNameFromRealtime() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -276,7 +280,7 @@ void findConnectionInTrip_RouteNameFromRealtime() { List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -303,8 +307,8 @@ void findConnectionInTrip_RouteNotFound() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -319,7 +323,7 @@ void findConnectionInTrip_RouteNotFound() { assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("N/A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("N/A"); } @Test @@ -350,49 +354,8 @@ void findConnectionInTrip_SkipSameStop() { 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(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)); - - List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - - assertThat(result).hasSize(1); - } - - @Test - @DisplayName("Should parse GTFS time correctly") - void parseGtfsTime() { - TripUpdateDTO dto = 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:15:00") - .arrivalDelay(0) - .build() - )) - .build(); - - 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop, sameStop)); + when(stopRepository.findByStopName("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)); @@ -403,20 +366,17 @@ void parseGtfsTime() { when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).hasSize(1); - assertThat(result.get(0).getArrivalTime()).isNotNull(); } @Test @DisplayName("Should handle empty trip updates list") void findFastestDirectConnections_EmptyTripUpdates() { - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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()); @@ -452,8 +412,8 @@ void findFastestDirectConnection_SelectsFastest() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -497,8 +457,8 @@ void findFastestDirectConnections_MultipleDestinationStops() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -518,8 +478,8 @@ void findFastestDirectConnections_MultipleDestinationStops() { @DisplayName("Should return empty when no matching stop times found") void findStaticConnection_NoMatchingStopTimes() { - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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()); @@ -556,8 +516,8 @@ void parseArrivalTimeWithDelay() { 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(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -571,15 +531,15 @@ void parseArrivalTimeWithDelay() { List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getArrivalTime()).isNotNull(); + assertThat(result.getFirst().getArrivalTime()).isNotNull(); } @Test @DisplayName("Should handle missing stop time in trip") void findConnectionInTrip_FromStopNotInTrip() { - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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()); @@ -616,8 +576,8 @@ void findStaticConnection_WithComplexTripSequence() { lastStop.setDepartureTime("23:16:00"); lastStop.setStopSequence(3); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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(midStop)); @@ -649,8 +609,8 @@ void findFastestDirectConnections_routeNameFallbackToStaticTrip_whenRouteIdMissi )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -669,7 +629,7 @@ void findFastestDirectConnections_routeNameFallbackToStaticTrip_whenRouteIdMissi // then assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -693,8 +653,8 @@ void findFastestDirectConnections_routeNameFromRealtime_whenPresent() { )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -713,7 +673,7 @@ void findFastestDirectConnections_routeNameFromRealtime_whenPresent() { // then assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -742,8 +702,8 @@ void findFastestDirectConnections_destinationMustBeAfterFromStop() { )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -763,7 +723,7 @@ void findFastestDirectConnections_destinationMustBeAfterFromStop() { // then: arrivalTime is computed from *fromStop* arrivalTime in current implementation // so here we just assert connection exists and route resolved. assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -787,8 +747,8 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_appliesDelay() { )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -808,7 +768,7 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_appliesDelay() { // then assertThat(result).hasSize(1); - assertThat(result.get(0).getArrivalTime()).isEqualTo(expectedArrival); + assertThat(result.getFirst().getArrivalTime()).isEqualTo(expectedArrival); } @Test @@ -832,8 +792,8 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_nullArrivalTime_fall )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -856,7 +816,7 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_nullArrivalTime_fall // then assertThat(result).hasSize(1); // allow small timing window - assertThat(result.get(0).getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result.getFirst().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); } @Test @@ -880,8 +840,8 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_invalidFormat_fallba )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -903,7 +863,7 @@ void findFastestDirectConnections_parseArrivalTimeWithDelay_invalidFormat_fallba // then assertThat(result).hasSize(1); - assertThat(result.get(0).getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result.getFirst().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); } @Test @@ -945,8 +905,8 @@ void findFastestDirectConnections_selectsEarliestDeparture() { )) .build(); - when(connectionServiceHelper.getStopsForName("Main Station")).thenReturn(List.of(fromStop)); - when(connectionServiceHelper.getStopsForName("City Center")).thenReturn(List.of(toStop)); + when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + when(stopRepository.findByStopName("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)); @@ -961,7 +921,7 @@ void findFastestDirectConnections_selectsEarliestDeparture() { 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))) + when(connectionServiceHelper.isConnectionNotInPast(trip1Departure, travelDateTime)) .thenReturn(true); when(connectionServiceHelper.parseTimeWithDelay("23:20:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(20)); @@ -970,7 +930,7 @@ void findFastestDirectConnections_selectsEarliestDeparture() { 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))) + when(connectionServiceHelper.isConnectionNotInPast(trip2Departure, travelDateTime)) .thenReturn(true); when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); @@ -980,8 +940,8 @@ void findFastestDirectConnections_selectsEarliestDeparture() { // 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); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getDepartureTime()).isEqualTo(trip1Departure); } } diff --git a/src/test/java/pl/agh/transit/StopMapperTest.java b/src/test/java/pl/agh/transit/StopMapperTest.java new file mode 100644 index 0000000..e25de6c --- /dev/null +++ b/src/test/java/pl/agh/transit/StopMapperTest.java @@ -0,0 +1,149 @@ +package pl.agh.transit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import pl.agh.transit.dto.NextDepartureDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.gtfs_static.model.Stop; +import pl.agh.transit.gtfs_static.model.StopTime; + +import static org.junit.jupiter.api.Assertions.*; + +class StopMapperTest { + + private StopMapper mapper; + + @BeforeEach + void setUp() { + mapper = new StopMapper(); + } + + @Test + void convertToDTO_mapsFieldsCorrectly() { + Stop stop = new Stop(); + stop.setId("STOP_123"); + stop.setStopName("Main Station"); + stop.setStopLat(50.0647); + stop.setStopLon(19.9450); + + StopDTO result = mapper.convertToDTO(stop); + + assertNotNull(result); + assertEquals("STOP_123", result.getId()); + assertEquals("Main Station", result.getName()); + assertEquals(50.0647, result.getLatitude()); + assertEquals(19.9450, result.getLongitude()); + } + + @Test + void convertStopTimeToDTO_mapsFieldsCorrectly() { + StopTime stopTime = new StopTime(); + stopTime.setDepartureTime("14:30:00"); + + NextDepartureDTO result = mapper.convertStopTimeToDTO(stopTime, "Teatr Bagatela", "4"); + + assertNotNull(result); + assertEquals("14:30:00", result.getDepartureTime()); + assertEquals("Teatr Bagatela", result.getStopName()); + assertEquals("4", result.getRouteName()); + } + + @Test + void normalizeDepartureTime_whenTimeIsNull_returnsOriginalDto() { + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime(null) + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertNull(result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenTimeUnder24h_returnsSameTime() { + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("14:30:00") + .stopName("Stop") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("14:30:00", result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenTimeOver24hNoDate_normalizesTime() { + // 25:30 -> 01:30 + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("25:30:00") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("01:30:00", result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenTimeOver24hWithDate_incrementsDateAndNormalizesTime() { + // 2024-05-10 26:15 -> 2024-05-11 02:15 + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("2024-05-10 26:15:00") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("2024-05-11 02:15:00", result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenTimeExactly24h_returnsZeroHour() { + // 24:00:00 -> 00:00:00 + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("24:00:00") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("00:00:00", result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenInvalidFormat_returnsOriginalDto() { + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("invalid-time") + .stopName("Stop") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("invalid-time", result.getDepartureTime()); + } + + @Test + void normalizeDepartureTime_whenDateParsingFails_usesDatePrefixWithNormalizedTime() { + NextDepartureDTO input = NextDepartureDTO.builder() + .departureTime("XX:00:00") + .build(); + + NextDepartureDTO result = mapper.normalizeDepartureTime(input); + + assertEquals("XX:00:00", result.getDepartureTime()); + } + + @Test + void buildNextDepartureDTOFromUpdate_mapsFieldsCorrectly() { + StopTimeUpdateDTO updateDTO = StopTimeUpdateDTO.builder() + .departureTime("15:45:00") + .departureDelay(120) + .build(); + + NextDepartureDTO result = mapper.buildNextDepartureDTOFromUpdate(updateDTO, "Rondo Mogilskie", "50"); + + assertNotNull(result); + assertEquals("15:45:00", result.getDepartureTime()); + assertEquals(120, result.getDepartureDelay()); + assertEquals("Rondo Mogilskie", result.getStopName()); + assertEquals("50", result.getRouteName()); + } +} diff --git a/src/test/java/pl/agh/transit/StopServiceTest.java b/src/test/java/pl/agh/transit/StopServiceTest.java index 6754843..61e14ab 100644 --- a/src/test/java/pl/agh/transit/StopServiceTest.java +++ b/src/test/java/pl/agh/transit/StopServiceTest.java @@ -5,18 +5,15 @@ 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.junit.jupiter.params.provider.NullAndEmptySource; 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 org.springframework.data.domain.*; 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; @@ -24,20 +21,18 @@ 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.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; @@ -50,7 +45,7 @@ class StopServiceTest { private StopRepository stopRepository; @Mock - private JdbcTemplate jdbcTemplate; + private StopTimeRepository stopTimeRepository; @Mock private ConnectionServiceHelper connectionServiceHelper; @@ -67,6 +62,9 @@ class StopServiceTest { @Mock private TripMapper tripMapper; + @Mock + private StopMapper stopMapper; + @InjectMocks private StopService stopService; @@ -75,7 +73,7 @@ class StopServiceTest { private Route route1; private Trip trip1; private StopTime stopTime1; - + private StopDTO stopDTO; @BeforeEach void setUp() { @@ -106,85 +104,116 @@ void setUp() { stopTime1.setStopId("stop-1"); stopTime1.setDepartureTime("10:30:00"); stopTime1.setArrivalTime("10:25:00"); - } + stopDTO = StopDTO.builder() + .id("stop-1") + .name("Main Station") + .latitude(50.0) + .longitude(19.0) + .build(); + } @Test - @DisplayName("Should get all stops with pagination") - void getAllStops_ShouldReturnPaginatedStops() { + @DisplayName("Should convert Stop to StopDTO correctly and return paginated result") + void getAllStops_Success() { // Arrange int page = 0; int size = 10; - String sortBy = "stop_name"; + String sortBy = "stopName"; + Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy)); - 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)); + Page stopPage = new PageImpl<>(List.of(stop1), pageable, 1); + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(stopPage); + when(stopMapper.convertToDTO(stop1)).thenReturn(stopDTO); // 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(); + assertThat(result.getContent()).hasSize(1); + StopDTO dto = result.getContent().getFirst(); + assertThat(dto.getId()).isEqualTo("stop-1"); + assertThat(dto.getName()).isEqualTo("Main Station"); + + verify(stopRepository).findAll(any(Pageable.class)); + verify(stopMapper).convertToDTO(stop1); } @Test - @DisplayName("Should calculate correct total pages") - void getAllStops_ShouldCalculateCorrectTotalPages() { + @DisplayName("Should pass correct PageRequest to repository") + void getAllStops_ShouldPassCorrectPageable() { // 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)); + int page = 2; + int size = 15; + String sortBy = "id"; + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); // Act - PagedResponseDTO result = stopService.getAllStops(0, 5, "stop_name"); + stopService.getAllStops(page, size, sortBy); // Assert - assertThat(result.getTotalPages()).isEqualTo(3); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(stopRepository).findAll(pageableCaptor.capture()); + + Pageable captured = pageableCaptor.getValue(); + assertThat(captured.getPageNumber()).isEqualTo(2); + assertThat(captured.getPageSize()).isEqualTo(15); + assertThat(captured.getSort()).isEqualTo(Sort.by("id")); + } + + @Test + @DisplayName("Should calculate metadata (totalPages, totalElements) correctly") + void getAllStops_ShouldReturnCorrectMetadata() { + // Arrange + // Symulacja: 25 elementów łącznie, strona 0, rozmiar 10 -> powinny być 3 strony + List content = List.of(stop1, stop2); + PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("stopName")); + Page stopPage = new PageImpl<>(content, pageRequest, 25); + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(stopPage); + when(stopMapper.convertToDTO(any(Stop.class))).thenReturn(stopDTO); + + // Act + PagedResponseDTO result = stopService.getAllStops(0, 10, "stopName"); + + // Assert + assertThat(result.getTotalElements()).isEqualTo(25L); + assertThat(result.getTotalPages()).isEqualTo(3); // 25 / 10 = 2.5 -> 3 strony + assertThat(result.getPageNumber()).isEqualTo(0); + assertThat(result.getPageSize()).isEqualTo(10); + assertThat(result.isHasNext()).isTrue(); + assertThat(result.isHasPrevious()).isFalse(); } @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()); + when(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); // Act - PagedResponseDTO result = stopService.getAllStops(0, 10, "stop_name"); + PagedResponseDTO result = stopService.getAllStops(0, 10, "stopName"); // Assert assertThat(result.getContent()).isEmpty(); assertThat(result.getTotalElements()).isEqualTo(0L); - assertThat(result.getTotalPages()).isEqualTo(0); + assertThat(result.getTotalPages()).isEqualTo(1); } @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)); + PageRequest pageRequest = PageRequest.of(1, 10); + Page stopPage = new PageImpl<>(List.of(stop1), pageRequest, 30); + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(stopPage); + when(stopMapper.convertToDTO(any())).thenReturn(stopDTO); // Act - PagedResponseDTO result = stopService.getAllStops(1, 10, "stop_name"); + PagedResponseDTO result = stopService.getAllStops(1, 10, "stopName"); // Assert assertThat(result.isHasNext()).isTrue(); @@ -195,117 +224,56 @@ void getAllStops_HasNextOnMiddlePage() { @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)); + PageRequest pageRequest = PageRequest.of(1, 10); + Page stopPage = new PageImpl<>(List.of(stop1), pageRequest, 20); + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(stopPage); + when(stopMapper.convertToDTO(any())).thenReturn(stopDTO); // Act - PagedResponseDTO result = stopService.getAllStops(1, 10, "stop_name"); + PagedResponseDTO result = stopService.getAllStops(1, 10, "stopName"); // 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"); + assertThat(result.isHasPrevious()).isTrue(); } @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()); + @NullAndEmptySource + @DisplayName("Should use unsorted pagination when sortBy is null or empty") + void getAllStops_ShouldSortUnsortedWhenNullOrEmpty(String sortBy) { + // Arrange + when(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); - stopService.getAllStops(0, 10, "invalid_column"); + // Act + stopService.getAllStops(0, 10, sortBy); // Assert - ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); - verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(stopRepository).findAll(captor.capture()); - String sql = sqlCaptor.getValue(); - assertThat(sql).contains("ORDER BY stop_name"); + Pageable captured = captor.getValue(); + assertThat(captured.getSort().isSorted()).isFalse(); // Sort.unsorted() } @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()); + @DisplayName("Should use specific sort when sortBy is provided") + void getAllStops_ShouldUseProvidedSort() { + // Arrange + String sortBy = "stopLat"; + when(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); - stopService.getAllStops(0, 10, null); + // Act + stopService.getAllStops(0, 10, sortBy); // Assert - ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); - verify(jdbcTemplate).query(sqlCaptor.capture(), any(RowMapper.class), anyInt(), anyInt()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(stopRepository).findAll(captor.capture()); - String sql = sqlCaptor.getValue(); - assertThat(sql).contains("ORDER BY stop_name"); + Pageable captured = captor.getValue(); + assertThat(captured.getSort().getOrderFor("stopLat")).isNotNull(); } - @Test @DisplayName("Should get next departures for stop and route") void getNextDeparturesForStopAndRoute_Success() { @@ -314,7 +282,7 @@ void getNextDeparturesForStopAndRoute_Success() { LocalTime travelTime = LocalTime.of(10, 0); int limit = 5; - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -324,7 +292,7 @@ void getNextDeparturesForStopAndRoute_Success() { .thenReturn(List.of()); when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) .thenReturn(List.of()); - when(connectionServiceHelper.getStopTimesForStop("stop-1")) + when(stopTimeRepository.findByStopId("stop-1")) .thenReturn(List.of()); // Act @@ -333,8 +301,9 @@ void getNextDeparturesForStopAndRoute_Success() { ); // Assert - assertThat(result).isNotNull(); - assertThat(result.size()).isLessThanOrEqualTo(limit); + assertThat(result) + .isNotNull() + .hasSizeLessThanOrEqualTo(limit); } @Test @@ -344,7 +313,7 @@ void getNextDeparturesForStopAndRoute_StopNotFound() { LocalDate travelDate = LocalDate.now(); LocalTime travelTime = LocalTime.of(10, 0); - when(connectionServiceHelper.getStopsForName("Unknown Stop")) + when(stopRepository.findByStopName("Unknown Stop")) .thenReturn(List.of()); // Act @@ -363,7 +332,7 @@ void getNextDeparturesForStopAndRoute_RouteNotFound() { LocalDate travelDate = LocalDate.now(); LocalTime travelTime = LocalTime.of(10, 0); - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("INVALID")) .thenReturn(Optional.empty()); @@ -385,7 +354,7 @@ void getNextDeparturesForStopAndRoute_FetchesNextDay() { LocalTime travelTime = LocalTime.of(23, 0); int limit = 5; - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -395,7 +364,7 @@ void getNextDeparturesForStopAndRoute_FetchesNextDay() { .thenReturn(List.of()); // No results on first day when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) .thenReturn(List.of()); - when(connectionServiceHelper.getStopTimesForStop("stop-1")) + when(stopTimeRepository.findByStopId("stop-1")) .thenReturn(List.of()); // No static results on first day // Act @@ -414,19 +383,7 @@ void normalizeDepartureTime_WithOverflowHours() { 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")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -436,7 +393,7 @@ void normalizeDepartureTime_WithOverflowHours() { .thenReturn(List.of()); when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) .thenReturn(List.of()); - when(connectionServiceHelper.getStopTimesForStop("stop-1")) + when(stopTimeRepository.findByStopId("stop-1")) .thenReturn(List.of()); // Assert - just verify it doesn't crash @@ -481,7 +438,7 @@ void isRealtimeTripForRoute_MatchingRoute() { LocalDate travelDate = LocalDate.now(); LocalTime travelTime = LocalTime.of(10, 0); - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -495,47 +452,6 @@ void isRealtimeTripForRoute_MatchingRoute() { 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() { @@ -548,7 +464,7 @@ void findStaticDepartures_Success() { stopTime.setStopId("stop-1"); stopTime.setDepartureTime("11:00:00"); - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -558,7 +474,7 @@ void findStaticDepartures_Success() { .thenReturn(List.of()); when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))) .thenReturn(List.of()); - when(connectionServiceHelper.getStopTimesForStop("stop-1")) + when(stopTimeRepository.findByStopId("stop-1")) .thenReturn(List.of(stopTime)); when(staticTripRepository.findById("trip-1")) .thenReturn(Optional.of(trip1)); @@ -584,7 +500,7 @@ void getNextDeparturesForStopAndRoute_RespectsLimit() { LocalTime travelTime = LocalTime.of(10, 0); int limit = 2; - when(connectionServiceHelper.getStopsForName("Main Station")) + when(stopRepository.findByStopName("Main Station")) .thenReturn(List.of(stop1)); when(routeRepository.findByRouteShortName("1A")) .thenReturn(Optional.of(route1)); @@ -597,25 +513,4 @@ void getNextDeparturesForStopAndRoute_RespectsLimit() { // 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(); - } -} +} \ No newline at end of file From 6b19a7dfb27c85544929e7e5d1d6158c0217d560 Mon Sep 17 00:00:00 2001 From: Filip Date: Fri, 23 Jan 2026 12:46:06 +0100 Subject: [PATCH 12/13] rearrange package structure --- .../pl/agh/transit/{ => core}/TripDataFetcher.java | 3 +-- .../pl/agh/transit/{ => core}/TripRepository.java | 2 +- .../agh/transit/{ => core}/TripUpdateScheduler.java | 2 +- src/main/java/pl/agh/transit/mcp/StopMcpTools.java | 2 +- src/main/java/pl/agh/transit/mcp/TripMcpTools.java | 2 +- .../pl/agh/transit/{ => stop}/StopController.java | 2 +- .../java/pl/agh/transit/{ => stop}/StopMapper.java | 2 +- .../java/pl/agh/transit/{ => stop}/StopService.java | 5 ++++- .../transit/{ => trip}/DirectConnectionService.java | 4 +++- .../pl/agh/transit/{ => trip}/TripController.java | 2 +- .../java/pl/agh/transit/{ => trip}/TripMapper.java | 2 +- .../java/pl/agh/transit/{ => trip}/TripService.java | 3 ++- .../pl/agh/transit/{ => util}/CalendarService.java | 2 +- .../transit/{ => util}/ConnectionServiceHelper.java | 2 +- .../java/pl/agh/transit/CalendarServiceTest.java | 1 + .../pl/agh/transit/ConnectionServiceHelperTest.java | 3 ++- .../DirectConnectionServiceIntegrationTest.java | 13 +++++++------ .../pl/agh/transit/DirectConnectionServiceTest.java | 4 ++++ src/test/java/pl/agh/transit/StopMapperTest.java | 1 + src/test/java/pl/agh/transit/StopServiceTest.java | 5 +++++ .../agh/transit/TripControllerIntegrationTest.java | 1 + .../java/pl/agh/transit/TripDataFetcherTest.java | 2 ++ src/test/java/pl/agh/transit/TripMapperTest.java | 2 ++ src/test/java/pl/agh/transit/TripServiceTest.java | 3 +++ 24 files changed, 48 insertions(+), 22 deletions(-) rename src/main/java/pl/agh/transit/{ => core}/TripDataFetcher.java (97%) rename src/main/java/pl/agh/transit/{ => core}/TripRepository.java (94%) rename src/main/java/pl/agh/transit/{ => core}/TripUpdateScheduler.java (98%) rename src/main/java/pl/agh/transit/{ => stop}/StopController.java (98%) rename src/main/java/pl/agh/transit/{ => stop}/StopMapper.java (99%) rename src/main/java/pl/agh/transit/{ => stop}/StopService.java (98%) rename src/main/java/pl/agh/transit/{ => trip}/DirectConnectionService.java (99%) rename src/main/java/pl/agh/transit/{ => trip}/TripController.java (98%) rename src/main/java/pl/agh/transit/{ => trip}/TripMapper.java (98%) rename src/main/java/pl/agh/transit/{ => trip}/TripService.java (96%) rename src/main/java/pl/agh/transit/{ => util}/CalendarService.java (99%) rename src/main/java/pl/agh/transit/{ => util}/ConnectionServiceHelper.java (99%) diff --git a/src/main/java/pl/agh/transit/TripDataFetcher.java b/src/main/java/pl/agh/transit/core/TripDataFetcher.java similarity index 97% rename from src/main/java/pl/agh/transit/TripDataFetcher.java rename to src/main/java/pl/agh/transit/core/TripDataFetcher.java index 3f311ac..ae8d4aa 100644 --- a/src/main/java/pl/agh/transit/TripDataFetcher.java +++ b/src/main/java/pl/agh/transit/core/TripDataFetcher.java @@ -1,11 +1,10 @@ -package pl.agh.transit; +package pl.agh.transit.core; import com.google.transit.realtime.GtfsRealtime; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; diff --git a/src/main/java/pl/agh/transit/TripRepository.java b/src/main/java/pl/agh/transit/core/TripRepository.java similarity index 94% rename from src/main/java/pl/agh/transit/TripRepository.java rename to src/main/java/pl/agh/transit/core/TripRepository.java index c267add..62af392 100644 --- a/src/main/java/pl/agh/transit/TripRepository.java +++ b/src/main/java/pl/agh/transit/core/TripRepository.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.core; import com.google.transit.realtime.GtfsRealtime; diff --git a/src/main/java/pl/agh/transit/TripUpdateScheduler.java b/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java similarity index 98% rename from src/main/java/pl/agh/transit/TripUpdateScheduler.java rename to src/main/java/pl/agh/transit/core/TripUpdateScheduler.java index a49ebf1..63936dc 100644 --- a/src/main/java/pl/agh/transit/TripUpdateScheduler.java +++ b/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.core; import jakarta.annotation.PostConstruct; import lombok.AllArgsConstructor; diff --git a/src/main/java/pl/agh/transit/mcp/StopMcpTools.java b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java index b1947db..2d7c3a2 100644 --- a/src/main/java/pl/agh/transit/mcp/StopMcpTools.java +++ b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java @@ -4,7 +4,7 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; -import pl.agh.transit.StopService; +import pl.agh.transit.stop.StopService; import pl.agh.transit.dto.NextDepartureDTO; import pl.agh.transit.dto.PagedResponseDTO; import pl.agh.transit.dto.StopDTO; diff --git a/src/main/java/pl/agh/transit/mcp/TripMcpTools.java b/src/main/java/pl/agh/transit/mcp/TripMcpTools.java index 1046126..5e27076 100644 --- a/src/main/java/pl/agh/transit/mcp/TripMcpTools.java +++ b/src/main/java/pl/agh/transit/mcp/TripMcpTools.java @@ -4,7 +4,7 @@ import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; -import pl.agh.transit.DirectConnectionService; +import pl.agh.transit.trip.DirectConnectionService; import pl.agh.transit.dto.TransportTypeDTO; import java.time.LocalDate; diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/stop/StopController.java similarity index 98% rename from src/main/java/pl/agh/transit/StopController.java rename to src/main/java/pl/agh/transit/stop/StopController.java index 217f96a..0a17b9c 100644 --- a/src/main/java/pl/agh/transit/StopController.java +++ b/src/main/java/pl/agh/transit/stop/StopController.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.stop; import lombok.AllArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; diff --git a/src/main/java/pl/agh/transit/StopMapper.java b/src/main/java/pl/agh/transit/stop/StopMapper.java similarity index 99% rename from src/main/java/pl/agh/transit/StopMapper.java rename to src/main/java/pl/agh/transit/stop/StopMapper.java index 330e1de..ede8ed8 100644 --- a/src/main/java/pl/agh/transit/StopMapper.java +++ b/src/main/java/pl/agh/transit/stop/StopMapper.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.stop; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/pl/agh/transit/StopService.java b/src/main/java/pl/agh/transit/stop/StopService.java similarity index 98% rename from src/main/java/pl/agh/transit/StopService.java rename to src/main/java/pl/agh/transit/stop/StopService.java index b2ea373..ba149ec 100644 --- a/src/main/java/pl/agh/transit/StopService.java +++ b/src/main/java/pl/agh/transit/stop/StopService.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.stop; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -7,6 +7,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import pl.agh.transit.util.ConnectionServiceHelper; +import pl.agh.transit.trip.TripMapper; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.PagedResponseDTO; import pl.agh.transit.dto.StopDTO; import pl.agh.transit.dto.StopTimeUpdateDTO; diff --git a/src/main/java/pl/agh/transit/DirectConnectionService.java b/src/main/java/pl/agh/transit/trip/DirectConnectionService.java similarity index 99% rename from src/main/java/pl/agh/transit/DirectConnectionService.java rename to src/main/java/pl/agh/transit/trip/DirectConnectionService.java index 9724306..cd56ac9 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/trip/DirectConnectionService.java @@ -1,8 +1,10 @@ -package pl.agh.transit; +package pl.agh.transit.trip; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import pl.agh.transit.util.ConnectionServiceHelper; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; diff --git a/src/main/java/pl/agh/transit/TripController.java b/src/main/java/pl/agh/transit/trip/TripController.java similarity index 98% rename from src/main/java/pl/agh/transit/TripController.java rename to src/main/java/pl/agh/transit/trip/TripController.java index fb0bc51..0976b5c 100644 --- a/src/main/java/pl/agh/transit/TripController.java +++ b/src/main/java/pl/agh/transit/trip/TripController.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.trip; import lombok.AllArgsConstructor; diff --git a/src/main/java/pl/agh/transit/TripMapper.java b/src/main/java/pl/agh/transit/trip/TripMapper.java similarity index 98% rename from src/main/java/pl/agh/transit/TripMapper.java rename to src/main/java/pl/agh/transit/trip/TripMapper.java index 13b6849..7874c5c 100644 --- a/src/main/java/pl/agh/transit/TripMapper.java +++ b/src/main/java/pl/agh/transit/trip/TripMapper.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.trip; import com.google.transit.realtime.GtfsRealtime; diff --git a/src/main/java/pl/agh/transit/TripService.java b/src/main/java/pl/agh/transit/trip/TripService.java similarity index 96% rename from src/main/java/pl/agh/transit/TripService.java rename to src/main/java/pl/agh/transit/trip/TripService.java index 2778912..2d67dab 100644 --- a/src/main/java/pl/agh/transit/TripService.java +++ b/src/main/java/pl/agh/transit/trip/TripService.java @@ -1,8 +1,9 @@ -package pl.agh.transit; +package pl.agh.transit.trip; import com.google.transit.realtime.GtfsRealtime; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; diff --git a/src/main/java/pl/agh/transit/CalendarService.java b/src/main/java/pl/agh/transit/util/CalendarService.java similarity index 99% rename from src/main/java/pl/agh/transit/CalendarService.java rename to src/main/java/pl/agh/transit/util/CalendarService.java index 13c7bd5..c5b129c 100644 --- a/src/main/java/pl/agh/transit/CalendarService.java +++ b/src/main/java/pl/agh/transit/util/CalendarService.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.util; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java b/src/main/java/pl/agh/transit/util/ConnectionServiceHelper.java similarity index 99% rename from src/main/java/pl/agh/transit/ConnectionServiceHelper.java rename to src/main/java/pl/agh/transit/util/ConnectionServiceHelper.java index ed371ff..36898da 100644 --- a/src/main/java/pl/agh/transit/ConnectionServiceHelper.java +++ b/src/main/java/pl/agh/transit/util/ConnectionServiceHelper.java @@ -1,4 +1,4 @@ -package pl.agh.transit; +package pl.agh.transit.util; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/pl/agh/transit/CalendarServiceTest.java b/src/test/java/pl/agh/transit/CalendarServiceTest.java index c72e725..c4ffec1 100644 --- a/src/test/java/pl/agh/transit/CalendarServiceTest.java +++ b/src/test/java/pl/agh/transit/CalendarServiceTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; +import pl.agh.transit.util.CalendarService; @ExtendWith(MockitoExtension.class) @DisplayName("CalendarService Unit Tests") diff --git a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java index b1f1dce..7e5a918 100644 --- a/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java +++ b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java @@ -13,7 +13,8 @@ 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.util.CalendarService; +import pl.agh.transit.util.ConnectionServiceHelper; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java index 40c281b..89e0fda 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -23,6 +23,7 @@ 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 pl.agh.transit.trip.DirectConnectionService; @SpringBootTest @Transactional @@ -155,7 +156,7 @@ void findFastestDirectConnection_staticData_returnsFastestRoute() { .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -214,7 +215,7 @@ void findFastestDirectConnection_validTravelDate_returnsConnections() { .findFastestDirectConnections("Main Station", "City Center", validDate, LocalTime.now(), 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -228,7 +229,7 @@ void findFastestDirectConnections_comparesDepartureTimes_returnsEarliestDepartur .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -282,7 +283,7 @@ void findFastestDirectConnections_inactiveServicesIgnored_returnsActiveRoute() { // Should select route 1A (active, earliest departure), not 3C (inactive) assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -301,7 +302,7 @@ void findFastestDirectConnections_multipleStopsSameName_handlesCorrectly() { // Should find connection even with multiple stops with same name assertThat(result).hasSize(1); - assertThat(result.get(0).getRouteName()).isEqualTo("1A"); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -311,7 +312,7 @@ void findFastestDirectConnections_returnsNonNullArrivalTime() { .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).hasSize(1); - assertThat(result.get(0).getArrivalTime()).isNotNull(); + assertThat(result.getFirst().getArrivalTime()).isNotNull(); } } diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java index 3962103..673eb00 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -8,6 +8,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; @@ -15,6 +16,9 @@ import pl.agh.transit.gtfs_static.model.StopTime; import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; +import pl.agh.transit.trip.DirectConnectionService; +import pl.agh.transit.trip.TripMapper; +import pl.agh.transit.util.ConnectionServiceHelper; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/src/test/java/pl/agh/transit/StopMapperTest.java b/src/test/java/pl/agh/transit/StopMapperTest.java index e25de6c..31265d3 100644 --- a/src/test/java/pl/agh/transit/StopMapperTest.java +++ b/src/test/java/pl/agh/transit/StopMapperTest.java @@ -7,6 +7,7 @@ import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; +import pl.agh.transit.stop.StopMapper; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/pl/agh/transit/StopServiceTest.java b/src/test/java/pl/agh/transit/StopServiceTest.java index 61e14ab..5ccb8a4 100644 --- a/src/test/java/pl/agh/transit/StopServiceTest.java +++ b/src/test/java/pl/agh/transit/StopServiceTest.java @@ -11,6 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.*; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.NextDepartureDTO; import pl.agh.transit.dto.PagedResponseDTO; import pl.agh.transit.dto.StopDTO; @@ -22,6 +23,10 @@ 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 pl.agh.transit.stop.StopMapper; +import pl.agh.transit.stop.StopService; +import pl.agh.transit.trip.TripMapper; +import pl.agh.transit.util.ConnectionServiceHelper; import java.time.LocalDate; import java.time.LocalDateTime; diff --git a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java index 4155b1e..96f79c8 100644 --- a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java +++ b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java @@ -7,6 +7,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; +import pl.agh.transit.core.TripRepository; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; diff --git a/src/test/java/pl/agh/transit/TripDataFetcherTest.java b/src/test/java/pl/agh/transit/TripDataFetcherTest.java index 82a9bd8..8592453 100644 --- a/src/test/java/pl/agh/transit/TripDataFetcherTest.java +++ b/src/test/java/pl/agh/transit/TripDataFetcherTest.java @@ -5,6 +5,8 @@ import okhttp3.mockwebserver.MockWebServer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import pl.agh.transit.core.TripDataFetcher; + import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertArrayEquals; diff --git a/src/test/java/pl/agh/transit/TripMapperTest.java b/src/test/java/pl/agh/transit/TripMapperTest.java index a10f113..b407adf 100644 --- a/src/test/java/pl/agh/transit/TripMapperTest.java +++ b/src/test/java/pl/agh/transit/TripMapperTest.java @@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test; import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.trip.TripMapper; + import java.util.List; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/src/test/java/pl/agh/transit/TripServiceTest.java b/src/test/java/pl/agh/transit/TripServiceTest.java index 1b6603f..fbabcf0 100644 --- a/src/test/java/pl/agh/transit/TripServiceTest.java +++ b/src/test/java/pl/agh/transit/TripServiceTest.java @@ -7,9 +7,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import pl.agh.transit.core.TripRepository; import pl.agh.transit.dto.StopTimeUpdateDTO; import pl.agh.transit.dto.TripUpdateDTO; import pl.agh.transit.exception.ServiceUnavailableException; +import pl.agh.transit.trip.TripMapper; +import pl.agh.transit.trip.TripService; import java.util.Collections; import java.util.List; From 568995207e2f5aa9052787f94669200651e8f1a4 Mon Sep 17 00:00:00 2001 From: gontarsky_04 Date: Sun, 25 Jan 2026 20:23:51 +0100 Subject: [PATCH 13/13] E2E Tests --- .../agh/transit/core/TripUpdateScheduler.java | 2 + .../pl/agh/transit/stop/StopController.java | 2 +- ...irectConnectionServiceIntegrationTest.java | 27 ++- .../transit/DirectConnectionServiceTest.java | 4 +- .../TripControllerIntegrationTest.java | 7 +- .../pl/agh/transit/e2e/AbstractE2ETest.java | 28 +++ .../transit/e2e/StopControllerE2ETest.java | 103 +++++++++ .../transit/e2e/TripControllerE2ETest.java | 207 ++++++++++++++++++ .../resources/application-test.properties | 32 +++ 9 files changed, 398 insertions(+), 14 deletions(-) create mode 100644 src/test/java/pl/agh/transit/e2e/AbstractE2ETest.java create mode 100644 src/test/java/pl/agh/transit/e2e/StopControllerE2ETest.java create mode 100644 src/test/java/pl/agh/transit/e2e/TripControllerE2ETest.java create mode 100644 src/test/resources/application-test.properties diff --git a/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java b/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java index 63936dc..14f03b3 100644 --- a/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java +++ b/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java @@ -3,6 +3,7 @@ import jakarta.annotation.PostConstruct; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import pl.agh.transit.gtfs_static.GtfsStaticService; @@ -11,6 +12,7 @@ @Component @AllArgsConstructor @Slf4j +@ConditionalOnProperty(name = "gtfs.scheduler.enabled", havingValue = "true", matchIfMissing = true) public class TripUpdateScheduler { private final TripDataFetcher fetcher; diff --git a/src/main/java/pl/agh/transit/stop/StopController.java b/src/main/java/pl/agh/transit/stop/StopController.java index 0a17b9c..8b9252f 100644 --- a/src/main/java/pl/agh/transit/stop/StopController.java +++ b/src/main/java/pl/agh/transit/stop/StopController.java @@ -52,4 +52,4 @@ public ResponseEntity> getNextDepartures( } return ResponseEntity.ok(departures); } -} +} \ No newline at end of file diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java index 89e0fda..4b13e5a 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -18,6 +18,8 @@ import java.time.LocalTime; import java.util.List; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.assertThat; import pl.agh.transit.gtfs_static.repository.RouteRepository; import pl.agh.transit.gtfs_static.repository.StaticTripRepository; @@ -28,6 +30,8 @@ @SpringBootTest @Transactional @DisplayName("DirectConnectionService Integration Tests") +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) class DirectConnectionServiceIntegrationTest { @Autowired @@ -48,6 +52,7 @@ class DirectConnectionServiceIntegrationTest { @Autowired private CalendarRepository calendarRepository; + private static final LocalTime TEST_TIME = LocalTime.of(22, 0); @BeforeEach void setUp() { @@ -153,7 +158,7 @@ private void setupTestData() { @DisplayName("Should find fastest direct connection from static data") void findFastestDirectConnection_staticData_returnsFastestRoute() { List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); assertThat(result).hasSize(1); assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); @@ -163,7 +168,7 @@ void findFastestDirectConnection_staticData_returnsFastestRoute() { @DisplayName("Should return empty when no direct connection exists") void findFastestDirectConnection_noDirectConnections_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnections("Main Station", "Final Stop", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "Final Stop", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @@ -172,7 +177,7 @@ void findFastestDirectConnection_noDirectConnections_returnsEmpty() { @DisplayName("Should return empty when source stop does not exist") void findFastestDirectConnections_sourceStopNotFound_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnections("Unknown Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Unknown Station", "City Center", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @@ -181,7 +186,7 @@ void findFastestDirectConnections_sourceStopNotFound_returnsEmpty() { @DisplayName("Should return empty when destination stop does not exist") void findFastestDirectConnections_destinationStopNotFound_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnections("Main Station", "Unknown Station", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "Unknown Station", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @@ -190,7 +195,7 @@ void findFastestDirectConnections_destinationStopNotFound_returnsEmpty() { @DisplayName("Should return empty when both stops are the same") void findFastestDirectConnections_sameSourceAndDestination_returnsEmpty() { List result = directConnectionService - .findFastestDirectConnections("Main Station", "Main Station", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "Main Station", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @@ -201,7 +206,7 @@ void findFastestDirectConnections_travelDateOutsideCalendar_returnsEmpty() { LocalDate futureDate = LocalDate.now().plusYears(1); List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", futureDate, LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", futureDate, TEST_TIME, 1); assertThat(result).isEmpty(); } @@ -212,7 +217,7 @@ void findFastestDirectConnection_validTravelDate_returnsConnections() { LocalDate validDate = LocalDate.now(); List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", validDate, LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", validDate, TEST_TIME, 1); assertThat(result).hasSize(1); assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); @@ -226,7 +231,7 @@ void findFastestDirectConnections_comparesDepartureTimes_returnsEarliestDepartur // Should select trip 1 because it departs earliest List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); assertThat(result).hasSize(1); assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); @@ -279,7 +284,7 @@ void findFastestDirectConnections_inactiveServicesIgnored_returnsActiveRoute() { stopTimeRepository.save(stopTime3To); List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); // Should select route 1A (active, earliest departure), not 3C (inactive) assertThat(result).hasSize(1); @@ -298,7 +303,7 @@ void findFastestDirectConnections_multipleStopsSameName_handlesCorrectly() { stopRepository.save(duplicateStop); List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); // Should find connection even with multiple stops with same name assertThat(result).hasSize(1); @@ -309,7 +314,7 @@ void findFastestDirectConnections_multipleStopsSameName_handlesCorrectly() { @DisplayName("Should return valid arrival time for connection") void findFastestDirectConnections_returnsNonNullArrivalTime() { List result = directConnectionService - .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); assertThat(result).hasSize(1); assertThat(result.getFirst().getArrivalTime()).isNotNull(); diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java index 673eb00..9e369e8 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -26,7 +26,9 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java index 96f79c8..8ac05e4 100644 --- a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java +++ b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java @@ -8,12 +8,17 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; import pl.agh.transit.core.TripRepository; +import org.springframework.test.context.ActiveProfiles; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class TripControllerIntegrationTest { diff --git a/src/test/java/pl/agh/transit/e2e/AbstractE2ETest.java b/src/test/java/pl/agh/transit/e2e/AbstractE2ETest.java new file mode 100644 index 0000000..47dd2aa --- /dev/null +++ b/src/test/java/pl/agh/transit/e2e/AbstractE2ETest.java @@ -0,0 +1,28 @@ +package pl.agh.transit.e2e; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.util.DefaultUriBuilderFactory; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public abstract class AbstractE2ETest { + @LocalServerPort + protected int port; + + @Autowired + protected TestRestTemplate restTemplate; + + @BeforeEach + void setUpBaseUrl() { + restTemplate.getRestTemplate().setUriTemplateHandler( + new DefaultUriBuilderFactory("http://localhost:" + port) + ); + } +} \ No newline at end of file diff --git a/src/test/java/pl/agh/transit/e2e/StopControllerE2ETest.java b/src/test/java/pl/agh/transit/e2e/StopControllerE2ETest.java new file mode 100644 index 0000000..d5761ab --- /dev/null +++ b/src/test/java/pl/agh/transit/e2e/StopControllerE2ETest.java @@ -0,0 +1,103 @@ +package pl.agh.transit.e2e; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import pl.agh.transit.dto.PagedResponseDTO; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("E2E Tests - Stop Controller") +class StopControllerE2ETest extends AbstractE2ETest{ + + @Nested + @DisplayName("GET /stops") + class GetStopsEndpoint { + + @Test + @DisplayName("powinien zwrócić listę przystanków z domyślną paginacją") + void shouldReturnStopsWithDefaultPagination() { + + ResponseEntity response = restTemplate.getForEntity( + "/stops", + PagedResponseDTO.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isNotNull(); + assertThat(response.getBody().getPageNumber()).isEqualTo(0); + assertThat(response.getBody().getPageSize()).isEqualTo(10); + } + + @Test + @DisplayName("powinien obsługiwać parametry paginacji") + void shouldHandlePaginationParameters() { + ResponseEntity response = restTemplate.getForEntity( + "/stops?page=1&size=5", + PagedResponseDTO.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isNotNull(); + assertThat(response.getBody().getPageNumber()).isEqualTo(1); + assertThat(response.getBody().getPageSize()).isEqualTo(5); + } + + @Test + @DisplayName("powinien ograniczyć maksymalny rozmiar strony do 100") + void shouldLimitMaxPageSize() { + ResponseEntity response = restTemplate.getForEntity( + "/stops?size=500", + PagedResponseDTO.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getContent()).isNotNull(); + assertThat(response.getBody().getPageSize()).isLessThanOrEqualTo(100); + } + + @Test + @DisplayName("powinien zwrócić błąd 500 dla ujemnego rozmiaru strony") + void shouldReturnBadRequestForNegativePageSize() { + ResponseEntity respose = restTemplate.getForEntity( + "/stops?size=-1", + String.class + ); + + assertThat(respose.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Nested + @DisplayName("GET /stops/routes") + class GetNextDeparturesEndpoint { + + @Test + @DisplayName("powinien zwrócić następne odjazdy dla przystanku i linii") + void shouldReturnNextDepartures() { + + String stopName = "Rondo Grunwaldzkie"; + String routeName = "52"; + + ResponseEntity response = restTemplate.getForEntity( + "/stops/routes?stopName=" + stopName + "&routeName=" + routeName, + String.class + ); + + assertThat(response.getStatusCode()).isIn(HttpStatus.OK, HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("powinien obsługiwać brakujące wymagane parametry") + void shouldHandleMissingRequiredParameters() { + ResponseEntity response = restTemplate.getForEntity( + "/stops/routes", + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} \ No newline at end of file diff --git a/src/test/java/pl/agh/transit/e2e/TripControllerE2ETest.java b/src/test/java/pl/agh/transit/e2e/TripControllerE2ETest.java new file mode 100644 index 0000000..b3d8e24 --- /dev/null +++ b/src/test/java/pl/agh/transit/e2e/TripControllerE2ETest.java @@ -0,0 +1,207 @@ +package pl.agh.transit.e2e; + +import com.google.transit.realtime.GtfsRealtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import pl.agh.transit.core.TripRepository; +import pl.agh.transit.dto.StopTimeUpdateDTO; +import pl.agh.transit.dto.TransportTypeDTO; +import pl.agh.transit.dto.TripUpdateDTO; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("E2E Tests - Trip Controller") +class TripControllerE2ETest extends AbstractE2ETest { + + @Autowired + private TripRepository tripRepository; + + private void givenSingleTripWithTwoStops() { + GtfsRealtime.FeedMessage feed = GtfsRealtime.FeedMessage.newBuilder() + .setHeader(GtfsRealtime.FeedHeader.newBuilder() + .setGtfsRealtimeVersion("2.0") + .setTimestamp(System.currentTimeMillis() / 1000) + .build()) + .addEntity(GtfsRealtime.FeedEntity.newBuilder() + .setId("entity1") + .setTripUpdate(GtfsRealtime.TripUpdate.newBuilder() + .setTrip(GtfsRealtime.TripDescriptor.newBuilder() + .setTripId("trip_001") + .setRouteId("route_52") + .build()) + .addStopTimeUpdate(GtfsRealtime.TripUpdate.StopTimeUpdate.newBuilder() + .setStopSequence(2) + .setStopId("stop_rondo_mogilskie") + .build()) + .build()) + .build()) + .build(); + + tripRepository.updateAllTrips(feed); + } + + private GtfsRealtime.TripUpdate.StopTimeUpdate stop(int seq, String id) { + return GtfsRealtime.TripUpdate.StopTimeUpdate.newBuilder() + .setStopSequence(seq) + .setStopId(id) + .build(); + } + + @Nested + @DisplayName("GET /trips") + class GetTripsEndpoint { + + @Test + @DisplayName("powinien zwrócić listę wszystkich aktualizacji kursów") + void shouldReturnAllTrips() { + givenSingleTripWithTwoStops(); + + ResponseEntity> response = restTemplate.exchange( + "/trips", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + TripUpdateDTO trip = response.getBody().get(0); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).hasSize(1); + + assertThat(trip.getTripId()).isEqualTo("trip_001"); + assertThat(trip.getStopTimeUpdates()).hasSize(1); + } + + @Test + @DisplayName("powinien zwrócić błąd 500 gdy brak danych") + void shouldReturnErrorWhenNoData() { + GtfsRealtime.FeedMessage emptyFeed = GtfsRealtime.FeedMessage.newBuilder() + .setHeader(GtfsRealtime.FeedHeader.newBuilder() + .setGtfsRealtimeVersion("2.0") + .setTimestamp(System.currentTimeMillis() / 1000) + .build()) + .build(); + tripRepository.updateAllTrips(emptyFeed); + + ResponseEntity response = restTemplate.getForEntity( + "/trips", + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Nested + @DisplayName("GET /random-stop-time") + class GetRandomStopTimeEndpoint { + + @Test + @DisplayName("powinien zwrócić losowy czas przystanku gdy dane istnieją") + void shouldReturnRandomStopTimeWhenDataExists() { + givenSingleTripWithTwoStops(); + + ResponseEntity response = restTemplate.getForEntity( + "/random-stop-time", + StopTimeUpdateDTO.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getStopId()).isNotBlank(); + } + + @Test + @DisplayName("powinien zwrócić błąd 500 gdy brak danych") + void shouldReturnNoContentWhenNoTrips() { + ResponseEntity response = restTemplate.getForEntity( + "/random-stop-time", + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Nested + @DisplayName("GET /fastest-direct-connection") + class GetFastestDirectConnectionEndpoint { + + @Test + @DisplayName("powinien zwrócić odpowiedź lub brak połączenia dla poprawnych parametrów") + void shouldHandleCorrectQuery() { + String from = "Rondo Grunwaldzkie"; + String to = "Rondo Mogilskie"; + ResponseEntity response = restTemplate.getForEntity( + "/fastest-direct-connection?from=" + from + "&to=" + to, + TransportTypeDTO.class + ); + + assertThat(response.getStatusCode()).isIn( + HttpStatus.OK, + HttpStatus.NOT_FOUND, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + @Test + @DisplayName("powinien zwrócić błąd 400 przy brakujących parametrach") + void shouldReturnBadRequestForMissingParams() { + ResponseEntity response = restTemplate.getForEntity( + "/fastest-direct-connection?from=Rondo", + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("GET /fastest-direct-connections") + class GetFastestDirectConnectionsEndpoint { + + @Test + @DisplayName("powinien obsługiwać zapytanie o wiele połączeń") + void shouldHandleMultipleConnectionsQuery() { + String from = "Rondo Grunwaldzkie"; + String to = "Rondo Mogilskie"; + + ResponseEntity> response = restTemplate.exchange( + "/fastest-direct-connections?from=" + from + "&to=" + to + "&count=3", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isIn(HttpStatus.OK, HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("powinien obsługiwać opcjonalne parametry daty i czasu") + void shouldHandleOptionalDateTimeParams() { + String from = "Rondo Grunwaldzkie"; + String to = "Rondo Mogilskie"; + String date = "2026-01-19"; + String time = "08:00"; + + ResponseEntity response = restTemplate.getForEntity( + "/fastest-direct-connections?from=" + from + + "&to=" + to + "&travelDate=" + date + "&travelTime=" + time, + String.class + ); + + assertThat(response.getStatusCode()).isIn( + HttpStatus.OK, + HttpStatus.NOT_FOUND, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..17dd28c --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,32 @@ +spring.application.name=Transit Location API - Test + +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.h2.console.enabled=false + +spring.sql.init.mode=always +spring.sql.init.platform=h2 + +# Disable GTFS scheduler during tests +gtfs.scheduler.enabled=false + +gtfs.tripUpdatesUrl=https://gtfs.ztp.krakow.pl/TripUpdates.pb +gtfs.staticDataUrl=https://gtfs.ztp.krakow.pl/GTFS_KRK.zip +gtfs.dataDir=/data +gtfs.localFile=data/trip-updates.pb + +gtfs.file.agency=agency.txt +gtfs.file.stops=stops.txt +gtfs.file.calendar=calendar.txt +gtfs.file.routes=routes.txt +gtfs.file.shapes=shapes.txt +gtfs.file.calendarDates=calendar_dates.txt +gtfs.file.trips=trips.txt +gtfs.file.stopTimes=stop_times.txt +gtfs.file.feedInfo=feed_info.txt + +logging.level.root=WARN +logging.level.pl.agh.transit=INFO \ No newline at end of file