diff --git a/src/main/java/pl/agh/transit/StopController.java b/src/main/java/pl/agh/transit/StopController.java index 783a131..23e7c26 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); } -} +} \ No newline at end of file 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/TripUpdateScheduler.java b/src/main/java/pl/agh/transit/TripUpdateScheduler.java index a49ebf1..931894e 100644 --- a/src/main/java/pl/agh/transit/TripUpdateScheduler.java +++ b/src/main/java/pl/agh/transit/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/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 diff --git a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java index 40c281b..d290c35 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; @@ -26,6 +28,8 @@ @SpringBootTest @Transactional +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) @DisplayName("DirectConnectionService Integration Tests") class DirectConnectionServiceIntegrationTest { @@ -47,6 +51,7 @@ class DirectConnectionServiceIntegrationTest { @Autowired private CalendarRepository calendarRepository; + private static final LocalTime TEST_TIME = LocalTime.of(22, 0); @BeforeEach void setUp() { @@ -152,7 +157,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.get(0).getRouteName()).isEqualTo("1A"); @@ -162,7 +167,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(); } @@ -171,7 +176,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(); } @@ -180,7 +185,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(); } @@ -189,7 +194,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(); } @@ -200,7 +205,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(); } @@ -211,7 +216,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.get(0).getRouteName()).isEqualTo("1A"); @@ -225,7 +230,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.get(0).getRouteName()).isEqualTo("1A"); @@ -278,7 +283,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); @@ -297,7 +302,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); @@ -308,7 +313,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.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 436dc7d..b0c189d 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -20,8 +20,11 @@ import java.time.LocalTime; import java.util.List; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; + import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; 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 4155b1e..c8d3fa8 100644 --- a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java +++ b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java @@ -6,13 +6,17 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; 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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @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..48b49cd --- /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) + ); + } +} 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..25e2cbd --- /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.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 + ); + } + } +} 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