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/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 87% rename from src/main/java/pl/agh/transit/TripUpdateScheduler.java rename to src/main/java/pl/agh/transit/core/TripUpdateScheduler.java index a49ebf1..14f03b3 100644 --- a/src/main/java/pl/agh/transit/TripUpdateScheduler.java +++ b/src/main/java/pl/agh/transit/core/TripUpdateScheduler.java @@ -1,8 +1,9 @@ -package pl.agh.transit; +package pl.agh.transit.core; 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/dto/NextDepartureDTO.java b/src/main/java/pl/agh/transit/dto/NextDepartureDTO.java new file mode 100644 index 0000000..86a4855 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/NextDepartureDTO.java @@ -0,0 +1,16 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +/** + * DTO representing next departure for a specific stop and route + */ +@Data +@Builder +public class NextDepartureDTO { + String departureTime; + Integer departureDelay; + String stopName; + String routeName; +} diff --git a/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java b/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java new file mode 100644 index 0000000..28014d3 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/PagedResponseDTO.java @@ -0,0 +1,18 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class PagedResponseDTO { + private List content; + private int pageNumber; + private int totalPages; + private long totalElements; + private int pageSize; + private boolean hasNext; + private boolean hasPrevious; +} diff --git a/src/main/java/pl/agh/transit/dto/StopDTO.java b/src/main/java/pl/agh/transit/dto/StopDTO.java new file mode 100644 index 0000000..cacc216 --- /dev/null +++ b/src/main/java/pl/agh/transit/dto/StopDTO.java @@ -0,0 +1,13 @@ +package pl.agh.transit.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class StopDTO { + private String id; + private String name; + private double latitude; + private double longitude; +} diff --git a/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java b/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java index 9a5dc62..a9bbd08 100644 --- a/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java +++ b/src/main/java/pl/agh/transit/dto/StopTimeUpdateDTO.java @@ -7,6 +7,7 @@ @Data @Builder public class StopTimeUpdateDTO { + String tripId; String stopId; String arrivalTime; String departureTime; diff --git a/src/main/java/pl/agh/transit/gtfs_static/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); diff --git a/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java b/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java index f834a22..30d63a6 100644 --- a/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java +++ b/src/main/java/pl/agh/transit/gtfs_static/repository/RouteRepository.java @@ -3,8 +3,10 @@ import org.springframework.data.repository.ListCrudRepository; import org.springframework.stereotype.Repository; import pl.agh.transit.gtfs_static.model.Route; +import java.util.Optional; @Repository public interface RouteRepository extends ListCrudRepository { + Optional findByRouteShortName(String routeShortName); } diff --git a/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java b/src/main/java/pl/agh/transit/gtfs_static/repository/StopRepository.java index a09d447..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/java/pl/agh/transit/mcp/StopMcpTools.java b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java new file mode 100644 index 0000000..2d7c3a2 --- /dev/null +++ b/src/main/java/pl/agh/transit/mcp/StopMcpTools.java @@ -0,0 +1,54 @@ +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.stop.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.time.LocalTime; +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, + @ToolParam(description = "Time to check schedule for (HH:mm). If omitted, defaults to right now.", required = false) LocalTime time + ) { + 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/main/java/pl/agh/transit/mcp/TripMcpTools.java b/src/main/java/pl/agh/transit/mcp/TripMcpTools.java new file mode 100644 index 0000000..5e27076 --- /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.trip.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/java/pl/agh/transit/stop/StopController.java b/src/main/java/pl/agh/transit/stop/StopController.java new file mode 100644 index 0000000..8b9252f --- /dev/null +++ b/src/main/java/pl/agh/transit/stop/StopController.java @@ -0,0 +1,55 @@ +package pl.agh.transit.stop; + +import lombok.AllArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import pl.agh.transit.dto.PagedResponseDTO; +import pl.agh.transit.dto.StopDTO; +import pl.agh.transit.dto.NextDepartureDTO; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +@RestController +@AllArgsConstructor +public class StopController { + + private final StopService stopService; + + @GetMapping(value = "/stops", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getAllStops( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "stopName") String sortBy + ) { + // Limit max page size to prevent performance issues + if (size > 100) { + size = 100; + } + + PagedResponseDTO response = stopService.getAllStops(page, size, sortBy); + return ResponseEntity.ok(response); + } + + @GetMapping(value = "/stops/routes", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getNextDepartures( + @RequestParam String stopName, + @RequestParam String routeName, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.TIME) LocalTime time + ) { + 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/stop/StopMapper.java b/src/main/java/pl/agh/transit/stop/StopMapper.java new file mode 100644 index 0000000..ede8ed8 --- /dev/null +++ b/src/main/java/pl/agh/transit/stop/StopMapper.java @@ -0,0 +1,107 @@ +package pl.agh.transit.stop; + +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/stop/StopService.java b/src/main/java/pl/agh/transit/stop/StopService.java new file mode 100644 index 0000000..ba149ec --- /dev/null +++ b/src/main/java/pl/agh/transit/stop/StopService.java @@ -0,0 +1,325 @@ +package pl.agh.transit.stop; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.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; +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.RouteRepository; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.StopTimeRepository; + + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + + +@Slf4j +@Service +@AllArgsConstructor +public class StopService { + + 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; + + public PagedResponseDTO getAllStops(int page, int size, String sortBy) { + log.debug("Getting stops - page: {}, size: {}, sortBy: {}", page, size, sortBy); + + Sort sort = (sortBy != null && !sortBy.isBlank()) + ? Sort.by(sortBy) + : Sort.unsorted(); + + Pageable pageable = PageRequest.of(page, size, sort); + + Page stopPage = stopRepository.findAll(pageable); + + List stopDTOs = stopPage.getContent().stream() + .map(stopMapper::convertToDTO) + .toList(); + + log.debug("Found {} stops on page {} of {}", stopDTOs.size(), page, stopPage.getTotalPages()); + + return PagedResponseDTO.builder() + .content(stopDTOs) + .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) { + 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; + } + + private List getNextDeparturesForDate( + String stopName, + String routeName, + LocalDate travelDate, + java.time.LocalTime travelTime, + int limit + ) { + log.debug("[0f] Getting departures for stop: {}, route: {}, date: {}, time: {}, limit: {}", + stopName, routeName, travelDate, travelTime, limit); + + List stops = stopRepository.findByStopName(stopName); + if (stops.isEmpty()) { + log.debug("[0a] Stop not found: {}", stopName); + return List.of(); + } + Stop stop = stops.getFirst(); + String stopId = stop.getId(); + + Optional routeOptional = routeRepository.findByRouteShortName(routeName); + if (routeOptional.isEmpty()) { + log.debug("[0b] Route not found: {}", routeName); + return List.of(); + } + Route route = routeOptional.get(); + String routeId = route.getId(); + + log.debug("[1] Resolved to stopId: {}, routeId: {}", stopId, routeId); + + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + + log.debug("[2] Travel datetime: {}", travelDateTime); + + List allTrips = getTripUpdates(); + log.debug("[3] Found {} trip updates", allTrips.size()); + + List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); + log.debug("[4] Active trips after calendar filter: {}", activeTrips.size()); + + + List realtimeDepartures = activeTrips.stream() + .filter(trip -> isRealtimeTripForRoute(trip, routeId)) + .flatMap(trip -> findStopInRealtimeTrip(trip, stopId, travelDateTime, travelDate, stop.getStopName(), routeName)) + .sorted((dto1, dto2) -> compareByDepartureTime(dto1, dto2, travelDate)) + .toList(); + + log.debug("[5] Realtime departures: {}", realtimeDepartures.size()); + + List staticDepartures = findStaticDepartures( + stopId, routeId, travelDate, travelDateTime, stop.getStopName(), routeName); + + log.debug("[6] Static departures: {}", staticDepartures.size()); + + List result = Stream.concat( + realtimeDepartures.stream(), + staticDepartures.stream()) + .sorted((dto1, dto2) -> compareByDepartureTime(dto1, dto2, travelDate)) + .distinct() + .limit(limit) + .map(stopMapper::normalizeDepartureTime) + .toList(); + + log.debug("[7] Final results for date {}: {}", travelDate, result.size()); + return result; + } + + private List getTripUpdates() { + log.debug("[3a] getTripUpdates START"); + List entities = + tripRepository.getEntitiesOrEmpty(); + log.debug("[3b] Feed entities: {}", entities.size()); + + List result = tripMapper.toDtoList(entities); + log.debug("[3c] getTripUpdates END: {} trips", result.size()); + return result; + } + + /** + * Checks if realtime trip belongs to the specified route + */ + private boolean isRealtimeTripForRoute(TripUpdateDTO trip, String routeId) { + if (trip.getRouteId() != null && trip.getRouteId().equals(routeId)) { + log.debug(" [4a] Trip {} matches route {} in realtime data", + trip.getTripId(), routeId); + return true; + } + + Optional staticTrip = staticTripRepository.findById(trip.getTripId()); + boolean matches = staticTrip.isPresent() + && staticTrip.get().getRouteId().equals(routeId); + + if (matches) { + log.debug(" [4b] Trip {} matches route {} in static data", + trip.getTripId(), routeId); + } + + return matches; + } + + /** + * Finds the stop in a realtime trip and returns departure info + */ + private Stream findStopInRealtimeTrip( + TripUpdateDTO trip, + String stopId, + LocalDateTime travelDateTime, + LocalDate travelDate, + String stopName, + String routeName + ) { + List stopTimeUpdates = trip.getStopTimeUpdates(); + + if (stopTimeUpdates == null || stopTimeUpdates.isEmpty()) { + return Stream.empty(); + } + + log.debug(" [5a] Checking trip: {} with {} stops", + trip.getTripId(), stopTimeUpdates.size()); + + return stopTimeUpdates.stream() + .filter(stopUpdate -> stopId.equals(stopUpdate.getStopId())) + .filter(stopUpdate -> isStopUpdateValid(stopUpdate, travelDateTime, travelDate)) + .map(stopUpdate -> stopMapper.buildNextDepartureDTOFromUpdate(stopUpdate, stopName, routeName)); + } + + private boolean isStopUpdateValid( + StopTimeUpdateDTO stopUpdate, + LocalDateTime travelDateTime, + LocalDate travelDate + ) { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay( + stopUpdate.getDepartureTime(), + travelDate, + stopUpdate.getDepartureDelay()); + + return connectionServiceHelper.isConnectionNotInPast(departureTime, travelDateTime); + } + + /** + * Find static departures at least 59 mins after current time + */ + private List findStaticDepartures( + String stopId, + String routeId, + LocalDate travelDate, + LocalDateTime travelDateTime, + String stopName, + String routeName + ) { + log.debug("[6a] Checking static data for stop: {}, route: {}", stopId, routeId); + + final LocalDateTime minStaticTime = travelDateTime.isAfter(LocalDateTime.now().plusMinutes(59)) + ? travelDateTime + : LocalDateTime.now().plusMinutes(59); + + log.debug("[6a1] Initial travel time: {}, min static time: {}", travelDateTime, minStaticTime); + + List stopTimesForStop = 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 -> 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); + + 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) { + Optional trip = staticTripRepository.findById(stopTime.getTripId()); + boolean matches = trip.isPresent() && trip.get().getRouteId().equals(routeId); + + if (matches) { + log.debug(" [6b1] Stop time for trip {} matches route {}", + stopTime.getTripId(), routeId); + } + + return matches; + } + + private int compareByDepartureTime(NextDepartureDTO dto1, NextDepartureDTO dto2, LocalDate travelDate) { + LocalDateTime time1 = connectionServiceHelper.parseTimeWithDelay( + dto1.getDepartureTime(), travelDate, dto1.getDepartureDelay()); + LocalDateTime time2 = connectionServiceHelper.parseTimeWithDelay( + dto2.getDepartureTime(), travelDate, dto2.getDepartureDelay()); + + return time1.compareTo(time2); + } +} \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/DirectConnectionService.java b/src/main/java/pl/agh/transit/trip/DirectConnectionService.java similarity index 50% rename from src/main/java/pl/agh/transit/DirectConnectionService.java rename to src/main/java/pl/agh/transit/trip/DirectConnectionService.java index 0d5eb40..cd56ac9 100644 --- a/src/main/java/pl/agh/transit/DirectConnectionService.java +++ b/src/main/java/pl/agh/transit/trip/DirectConnectionService.java @@ -1,25 +1,24 @@ -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; -import pl.agh.transit.gtfs_static.model.Route; import pl.agh.transit.gtfs_static.model.Stop; import pl.agh.transit.gtfs_static.model.StopTime; -import pl.agh.transit.gtfs_static.model.Trip; -import pl.agh.transit.gtfs_static.repository.RouteRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; import pl.agh.transit.gtfs_static.repository.StopRepository; import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.time.LocalTime; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; @@ -27,29 +26,29 @@ @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 StopRepository stopRepository; private final TripMapper tripMapper; + private final ConnectionServiceHelper connectionServiceHelper; + /** - * Finds the fastest direct connection from one stop to another - * Takes into account delays from GTFS Realtime and service calendar + * Finds multiple fastest direct connections from one stop to another + * Returns up to 'count' number of connections, sorted by departure time + * Uses today's date and current time by default */ - public Optional findFastestDirectConnection(String fromStopName, String toStopName) { - return findFastestDirectConnection(fromStopName, toStopName, LocalDate.now()); + public List findFastestDirectConnections(String fromStopName, String toStopName, int count) { + return findFastestDirectConnections(fromStopName, toStopName, LocalDate.now(), LocalTime.now(), count); } /** - * Finds the fastest direct connection from one stop to another for a specific date + * Finds multiple fastest direct connections from one stop to another for a specific date and time + * Returns up to 'count' number of connections, sorted by departure time * Takes into account delays from GTFS Realtime and service calendar - * Checks all possible from stops to find the fastest route */ - public Optional findFastestDirectConnection(String fromStopName, String toStopName, LocalDate travelDate) { - log.debug("[1] findFastestDirectConnection START: {} -> {} on {}", fromStopName, toStopName, travelDate); + public List findFastestDirectConnections(String fromStopName, String toStopName, LocalDate travelDate, LocalTime travelTime, int count) { + log.debug("[1] findFastestDirectConnections START: {} -> {} on {} at {} (count: {})", fromStopName, toStopName, travelDate, travelTime, count); List fromStops = stopRepository.findByStopName(fromStopName); log.debug("[2] Found {} stops for: {}", fromStops.size(), fromStopName); @@ -59,7 +58,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: "); @@ -70,73 +69,48 @@ public Optional findFastestDirectConnection(String fromStopNam log.debug("[6] Found {} trip updates", allTrips.size()); // Filter trips by service calendar for the travel date - List activeTrips = filterTripsByServiceCalendar(allTrips, travelDate); + List activeTrips = connectionServiceHelper.filterTripsByServiceCalendar(allTrips, travelDate); log.debug("[6-CAL] Active trips after calendar filter: {}", activeTrips.size()); // Check realtime connections from all from stops - Optional realtimeResult = fromStops.stream() + List realtimeResults = fromStops.stream() .flatMap(fromStop -> { log.debug("[5b] Checking from stop: {} ({})", fromStop.getId(), fromStop.getStopName()); return activeTrips.stream() - .flatMap(trip -> findConnectionInTrip(trip, fromStop.getId(), toStops, travelDate)); + .flatMap(trip -> findConnectionInTrip(trip, fromStop.getId(), toStops, travelDate, travelTime)); }) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .toList(); - log.debug("[8] Realtime result: {}", realtimeResult.isEmpty() ? "EMPTY" : realtimeResult.get().getRouteName()); + log.debug("[8] Realtime results: {}", realtimeResults.size()); // Check static connections from all from stops - Optional staticResult = fromStops.stream() + List staticResults = fromStops.stream() .flatMap(fromStop -> { log.debug("[5c] Checking static data from stop: {} ({})", fromStop.getId(), fromStop.getStopName()); - return findStaticConnection(fromStop.getId(), toStops, travelDate).stream(); + return findStaticConnection(fromStop.getId(), toStops, travelDate, travelTime).stream(); }) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .toList(); - log.debug("[10] Static result: {}", staticResult.isEmpty() ? "EMPTY" : staticResult.get().getRouteName()); + log.debug("[10] Static results: {}", staticResults.size()); - // Return the fastest between realtime and static - Optional result = Stream.of(realtimeResult, staticResult) - .filter(Optional::isPresent) - .map(Optional::get) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + // Merge realtime and static results, sort by departure time, and return top 'count' results + List result = Stream.concat(realtimeResults.stream(), staticResults.stream()) + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .distinct() + .limit(count) + .toList(); - log.debug("[11] Final result: {}", result.isEmpty() ? "EMPTY" : "FOUND"); + log.debug("[11] Final results: {}", result.size()); return result; } - /** - * Filters trips based on service calendar for the given date - */ - private List filterTripsByServiceCalendar(List trips, LocalDate travelDate) { - return trips.stream() - .filter(trip -> { - // Get the trip from static data to access serviceId - Optional staticTrip = - staticTripRepository.findById(trip.getTripId()); - - if (staticTrip.isEmpty()) { - log.debug("[CAL-FILTER] Trip {} not found in static data", trip.getTripId()); - return false; - } - - String serviceId = staticTrip.get().getServiceId(); - boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); - - if (!isActive) { - log.debug("[CAL-FILTER] Trip {} (service: {}) is NOT active on {}", trip.getTripId(), serviceId, travelDate); - } - - return isActive; - }) - .toList(); - } - /** * Find connection within a single trip update - checks against multiple destination stops * Only considers destination stops that appear AFTER the origin stop in the trip sequence * Filters out connections that have already departed */ - private Stream findConnectionInTrip(TripUpdateDTO trip, String fromStopId, List toStopCandidates, LocalDate travelDate) { + private Stream findConnectionInTrip(TripUpdateDTO trip, String fromStopId, List toStopCandidates, LocalDate travelDate, LocalTime travelTime) { List stops = trip.getStopTimeUpdates(); log.debug(" [7a] Trip: {} has {} stops", trip.getTripId(), stops.size()); @@ -146,14 +120,20 @@ private Stream findConnectionInTrip(TripUpdateDTO trip, String } StopTimeUpdateDTO fromStop = stops.get(fromStopSequence.get()); - LocalDateTime departureTime = parseTimeWithDelay(fromStop.getDepartureTime(), travelDate, fromStop.getDepartureDelay()); + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(fromStop.getDepartureTime(), travelDate, fromStop.getDepartureDelay()); log.debug(" [7g] Departure time: {}", departureTime); - if (!isConnectionNotInPast(departureTime, travelDate)) { + // Create a LocalDateTime from travelDate and travelTime for comparison + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + log.debug(" [7g1] Travel date and time: {}", travelDateTime); + + // Filter out connections that depart before the specified travel time + if (!connectionServiceHelper.isConnectionNotInPast(departureTime, travelDateTime)) { + log.debug(" [7h] Skipping - departure time {} is before travel time {}", departureTime, travelDateTime); return Stream.empty(); } - String routeName = getRealtimeRouteName(trip); + String routeName = connectionServiceHelper.getRealtimeRouteName(trip); return findAllToStopsInTrip(stops, fromStopSequence.get(), toStopCandidates, fromStopId, routeName, departureTime, travelDate); } @@ -163,7 +143,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,120 +174,36 @@ private boolean isRealtimeDestinationCandidate(StopTimeUpdateDTO stop, List route = routeRepository.findById(trip.getRouteId()); - routeName = route.map(Route::getRouteShortName).orElse("N/A"); - } - - if ("N/A".equals(routeName)) { - log.debug(" [7f-2] Route not found in realtime, checking static trip: {}", trip.getTripId()); - Optional staticTrip = staticTripRepository.findById(trip.getTripId()); - - if (staticTrip.isPresent()) { - String staticRouteId = staticTrip.get().getRouteId(); - log.debug(" [7f-3] Static trip has route ID: {}", staticRouteId); - routeName = routeRepository.findById(staticRouteId) - .map(Route::getRouteShortName) - .orElse("N/A"); - } - } - - log.debug(" [7f] Final route name: {}", routeName); - return routeName; - } - - private boolean isConnectionNotInPast(LocalDateTime departureTime, LocalDate travelDate) { - LocalDateTime referenceTime = travelDate.equals(LocalDate.now()) ? LocalDateTime.now() : null; - if (referenceTime != null && (departureTime.isBefore(referenceTime) || departureTime.equals(referenceTime))) { - log.debug(" [7g3] Skipping connection - departure time {} is in the past (reference: {})", departureTime, referenceTime); - return false; - } - return true; - } - - - /** - * Parse time from GTFS Realtime or Static format and add delay - * Supports both formats: "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" - * For realtime data: adds delay if present - * For static data: returns time in seconds from midnight - */ - private LocalDateTime parseTimeWithDelay(String timeStr, LocalDate travelDate, Integer delay) { - try { - if (timeStr == null) { - return LocalDateTime.now(); - } - - timeStr = timeStr.trim(); - LocalDateTime parsedTime; - - // Check if it contains date (space separator indicates datetime format) - if (timeStr.contains(" ")) { - // Format: "YYYY-MM-DD HH:MM:SS" - parsedTime = LocalDateTime.parse(timeStr, - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); - } else { - // Format: "HH:MM:SS" (time from midnight) - String[] parts = timeStr.split(":"); - if (parts.length != 3) { - throw new IllegalArgumentException("Invalid time format: " + timeStr); - } - int hours = Integer.parseInt(parts[0]); - int minutes = Integer.parseInt(parts[1]); - int seconds = Integer.parseInt(parts[2]); - long timeInSeconds = hours * 3600L + minutes * 60L + seconds; - parsedTime = travelDate.atStartOfDay().plusSeconds(timeInSeconds); - } - - // Add delay if present - if (delay != null) { - parsedTime = parsedTime.plusSeconds(delay); - } - - return parsedTime; - } catch (Exception e) { - log.error("Failed to parse time '{}'. Falling back to current time.", timeStr, e); - return LocalDateTime.now(); - } - } - - /** * Fallback to static GTFS data if no realtime data available - * Filters by service calendar for the given date + * Filters by service calendar for the given date and by travel time */ - private Optional findStaticConnection(String fromStopId, List toStopCandidates, LocalDate travelDate) { - log.debug("[9a] Checking static data from: {} for date: {}", fromStopId, travelDate); + private List findStaticConnection(String fromStopId, List toStopCandidates, LocalDate travelDate, LocalTime travelTime) { + log.debug("[9a] Checking static data from: {} for date: {} at time: {}", fromStopId, travelDate, travelTime); List fromStopTimes = stopTimeRepository.findByStopId(fromStopId); log.debug("[9b] Found {} stop times for from stop", fromStopTimes.size()); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + return fromStopTimes.stream() .filter(fromStopTime -> isStaticServiceActiveForTrip(fromStopTime, travelDate)) + .filter(fromStopTime -> { + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(fromStopTime.getDepartureTime(), travelDate, null); + boolean isNotInPast = !departureTime.isBefore(travelDateTime); + if (!isNotInPast) { + log.debug("[9b1] Skipping fromStopTime - departure time {} is before travel time {}", departureTime, travelDateTime); + } + return isNotInPast; + }) .flatMap(fromStopTime -> findStaticDestinationStop(fromStopTime, toStopCandidates, fromStopId)) .map(toStopTime -> buildStaticTransportDTO(toStopTime, fromStopId, travelDate)) - .filter(transport -> transport != null) - .min(Comparator.comparing(TransportTypeDTO::getArrivalTime)); + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TransportTypeDTO::getDepartureTime)) + .toList(); } private boolean isStaticServiceActiveForTrip(StopTime stopTime, LocalDate travelDate) { - Optional trip = staticTripRepository.findById(stopTime.getTripId()); - - if (trip.isEmpty()) { - return false; - } - - String serviceId = trip.get().getServiceId(); - boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); - - if (!isActive) { - log.debug("[9-CAL] Trip {} (service: {}) is NOT active on {}", stopTime.getTripId(), serviceId, travelDate); - } - - return isActive; + return connectionServiceHelper.isStaticServiceActiveForTrip(stopTime, travelDate); } private Stream findStaticDestinationStop(StopTime fromStopTime, List toStopCandidates, String fromStopId) { @@ -336,10 +232,10 @@ private boolean isStaticSequenceAfterFrom(StopTime toStopTime, StopTime fromStop private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fromStopId, LocalDate travelDate) { log.debug("[9g] Creating result from static data"); - String routeName = getStaticRouteNameForTrip(toStopTime.getTripId()); + String routeName = connectionServiceHelper.getStaticRouteNameForTrip(toStopTime.getTripId()); log.debug("[9i] Route name: {}", routeName); - LocalDateTime arrivalTime = parseTimeWithDelay(toStopTime.getArrivalTime(), travelDate, null); + LocalDateTime arrivalTime = connectionServiceHelper.parseTimeWithDelay(toStopTime.getArrivalTime(), travelDate, null); log.debug("[9j] Arrival time: {}", arrivalTime); // Cache stop times to avoid multiple repository calls @@ -353,13 +249,9 @@ private TransportTypeDTO buildStaticTransportDTO(StopTime toStopTime, String fro return null; } - LocalDateTime departureTime = parseTimeWithDelay(staticFromStop.get().getDepartureTime(), travelDate, null); + LocalDateTime departureTime = connectionServiceHelper.parseTimeWithDelay(staticFromStop.get().getDepartureTime(), travelDate, null); log.debug("[9k] Departure time: {}", departureTime); - if (departureTime.isBefore(LocalDateTime.now()) || departureTime.equals(LocalDateTime.now())) { - log.debug("[9l] Skipping static connection - departure time {} is in the past", departureTime); - return null; - } return TransportTypeDTO.builder() .routeName(routeName) @@ -368,17 +260,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() { log.debug("[6a] getTripUpdates START"); List entities = tripRepository.getEntitiesOrEmpty(); @@ -389,4 +270,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/TripController.java b/src/main/java/pl/agh/transit/trip/TripController.java similarity index 56% rename from src/main/java/pl/agh/transit/TripController.java rename to src/main/java/pl/agh/transit/trip/TripController.java index d84e002..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; @@ -10,8 +10,8 @@ import pl.agh.transit.dto.TransportTypeDTO; import pl.agh.transit.dto.TripUpdateDTO; import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; -import java.util.Optional; @RestController @AllArgsConstructor @@ -25,7 +25,7 @@ public List getTrips() { return tripService.getTripUpdates(); } - @GetMapping(value = "/trips/random-stop-time", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/random-stop-time", produces = MediaType.APPLICATION_JSON_VALUE) public StopTimeUpdateDTO getRandomStopTime() { return tripService.getRandomStopTime(); } @@ -35,8 +35,18 @@ public TransportTypeDTO getDirectConnection(@RequestParam String from, @RequestP return tripService.getDirectConnection(from, to); } - @GetMapping(value = "/fastest-direct-connection/with-date", produces = MediaType.APPLICATION_JSON_VALUE) - public Optional getDirectConnectionWithDate(@RequestParam String from, @RequestParam String to, @RequestParam LocalDate travelDate) { - return directConnectionService.findFastestDirectConnection(from, to, travelDate); + @GetMapping(value = "/fastest-direct-connections", produces = MediaType.APPLICATION_JSON_VALUE) + public List getFastestConnections( + @RequestParam String from, + @RequestParam String to, + @RequestParam(required = false) LocalDate travelDate, // (YYYY-MM-DD) + @RequestParam(required = false) LocalTime travelTime, // (HH:mm) + @RequestParam(defaultValue = "3") int count + ) { + if (travelDate != null && travelTime != null) { + return directConnectionService.findFastestDirectConnections(from, to, travelDate, travelTime, count); + } else { + return directConnectionService.findFastestDirectConnections(from, to, count); + } } } \ No newline at end of file diff --git a/src/main/java/pl/agh/transit/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 c443a28..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; @@ -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/TripService.java b/src/main/java/pl/agh/transit/trip/TripService.java similarity index 89% rename from src/main/java/pl/agh/transit/TripService.java rename to src/main/java/pl/agh/transit/trip/TripService.java index a9f879f..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; @@ -39,6 +40,14 @@ public StopTimeUpdateDTO getRandomStopTime(){ .orElseThrow(() -> new ServiceUnavailableException("No stop time updates available")); } + public TransportTypeDTO getDirectConnection(String from , String to){ + List connection = directConnectionService.findFastestDirectConnections(from, to, 1); + if (connection.isEmpty()) { + throw new ServiceUnavailableException("No connection available from " + from + " to " + to); + } + return connection.getFirst(); + } + private Optional pickRandom(List list){ if (list.isEmpty()){ return Optional.empty(); @@ -46,13 +55,4 @@ private Optional pickRandom(List list){ int randomIndex = random.nextInt(list.size()); return Optional.of(list.get(randomIndex)); } - - public TransportTypeDTO getDirectConnection(String from , String to){ - Optional connection = directConnectionService.findFastestDirectConnection(from, to); - if (connection.isEmpty()) { - throw new ServiceUnavailableException("No connection available from " + from + " to " + to); - } - return connection.get(); - } - } diff --git a/src/main/java/pl/agh/transit/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/util/ConnectionServiceHelper.java b/src/main/java/pl/agh/transit/util/ConnectionServiceHelper.java new file mode 100644 index 0000000..36898da --- /dev/null +++ b/src/main/java/pl/agh/transit/util/ConnectionServiceHelper.java @@ -0,0 +1,167 @@ +package pl.agh.transit.util; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import pl.agh.transit.dto.TripUpdateDTO; +import pl.agh.transit.gtfs_static.model.Route; +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 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 StaticTripRepository staticTripRepository; + private final RouteRepository routeRepository; + private final CalendarService calendarService; + + /** + * Filters trips based on service calendar for the given date + */ + public List filterTripsByServiceCalendar(List trips, LocalDate travelDate) { + return trips.stream() + .filter(trip -> { + // Get the trip from static data to access serviceId + Optional staticTrip = + staticTripRepository.findById(trip.getTripId()); + + if (staticTrip.isEmpty()) { + log.debug("[CAL-FILTER] Trip {} not found in static data", trip.getTripId()); + return false; + } + + String serviceId = staticTrip.get().getServiceId(); + boolean isActive = calendarService.isServiceActiveOnDate(serviceId, travelDate); + + if (!isActive) { + log.debug("[CAL-FILTER] Trip {} (service: {}) is NOT active on {}", trip.getTripId(), serviceId, travelDate); + } + + return isActive; + }) + .toList(); + } + + /** + * Parse time from GTFS Realtime or Static format and add delay + * Supports both formats: "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" + * For realtime data: adds delay if present + * For static data: returns time in seconds from midnight + */ + public LocalDateTime parseTimeWithDelay(String timeStr, LocalDate travelDate, Integer delay) { + try { + if (timeStr == null) { + return LocalDateTime.now(); + } + + timeStr = timeStr.trim(); + LocalDateTime parsedTime; + + // Check if it contains date (space separator indicates datetime format) + if (timeStr.contains(" ")) { + // Format: "YYYY-MM-DD HH:MM:SS" + parsedTime = LocalDateTime.parse(timeStr, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } else { + // Format: "HH:MM:SS" (time from midnight) + String[] parts = timeStr.split(":"); + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid time format: " + timeStr); + } + int hours = Integer.parseInt(parts[0]); + int minutes = Integer.parseInt(parts[1]); + int seconds = Integer.parseInt(parts[2]); + long timeInSeconds = hours * 3600L + minutes * 60L + seconds; + parsedTime = travelDate.atStartOfDay().plusSeconds(timeInSeconds); + } + + // Add delay if present + if (delay != null) { + parsedTime = parsedTime.plusSeconds(delay); + } + + return parsedTime; + } catch (Exception e) { + log.error("Failed to parse time '{}'. Falling back to current time.", timeStr, e); + return LocalDateTime.now(); + } + } + + /** + * Gets route name from realtime trip data, with fallback to static data + */ + public String getRealtimeRouteName(TripUpdateDTO trip) { + String routeName = "N/A"; + + if (trip.getRouteId() != null && !trip.getRouteId().isEmpty()) { + log.debug(" [7f-1] Trying route from realtime: {}", trip.getRouteId()); + Optional route = routeRepository.findById(trip.getRouteId()); + routeName = route.map(Route::getRouteShortName).orElse("N/A"); + } + + if ("N/A".equals(routeName)) { + log.debug(" [7f-2] Route not found in realtime, checking static trip: {}", trip.getTripId()); + routeName = getStaticRouteNameForTrip(trip.getTripId()); + } + + log.debug(" [7f] Final route name: {}", routeName); + return routeName; + } + + /** + * Gets route name for a trip from static GTFS data + */ + public String getStaticRouteNameForTrip(String tripId) { + Optional trip = staticTripRepository.findById(tripId); + return trip.flatMap(t -> { + log.debug("[9h] Trip route ID: {}", t.getRouteId()); + return routeRepository.findById(t.getRouteId()); + }) + .map(Route::getRouteShortName) + .orElse("N/A"); + } + + /** + * Checks if a connection departure time is not in the past + */ + public boolean isConnectionNotInPast(LocalDateTime departureTime, LocalDateTime travelDateTime) { + if (departureTime.isBefore(travelDateTime) || departureTime.isEqual(travelDateTime)) { + log.debug(" [7g3] Skipping connection - departure time {} is in the past (reference: {})", departureTime, travelDateTime); + return false; + } + return true; + } + + /** + * 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; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5eb76ff..052e79a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,4 +25,12 @@ 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 + +#uncomment to enable debug logs, disabled for use with MCP Client +#logging.level.pl.agh.transit=DEBUG + +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 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 new file mode 100644 index 0000000..7e5a918 --- /dev/null +++ b/src/test/java/pl/agh/transit/ConnectionServiceHelperTest.java @@ -0,0 +1,292 @@ +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.util.CalendarService; +import pl.agh.transit.util.ConnectionServiceHelper; + +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.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ConnectionServiceHelper Unit Tests") +class ConnectionServiceHelperTest { + + @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 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.getFirst().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 tripDto = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .build(); + + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getRealtimeRouteName(tripDto); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should fallback to static data when realtime route not found") + void getRealtimeRouteName_FallbackToStaticData() { + TripUpdateDTO tripDto = 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(tripDto); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should return N/A when route not found anywhere") + void getRealtimeRouteName_NotFound() { + TripUpdateDTO tripDto = 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(tripDto); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return N/A when routeId is null") + void getRealtimeRouteName_NullRouteId() { + TripUpdateDTO tripDto = 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(tripDto); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should get route name from static trip data") + void getStaticRouteNameForTrip_Success() { + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-1"); + + assertThat(result).isEqualTo("1A"); + } + + @Test + @DisplayName("Should return N/A when trip not found in static data") + void getStaticRouteNameForTrip_TripNotFound() { + when(staticTripRepository.findById("trip-unknown")).thenReturn(Optional.empty()); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-unknown"); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return N/A when route not found in static data") + void getStaticRouteNameForTrip_RouteNotFound() { + when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); + when(routeRepository.findById("route-1")).thenReturn(Optional.empty()); + + String result = connectionServiceHelper.getStaticRouteNameForTrip("trip-1"); + + assertThat(result).isEqualTo("N/A"); + } + + @Test + @DisplayName("Should return false when departure time is in the past (same date)") + void isConnectionNotInPast_TimeInPast() { + LocalDateTime today = LocalDateTime.now(); + LocalDateTime pastTime = today.minusHours(1); + + boolean result = connectionServiceHelper.isConnectionNotInPast(pastTime, today); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Should return true when departure time is in the future") + void isConnectionNotInPast_TimeFuture() { + LocalDateTime today = LocalDateTime.now(); + LocalDateTime futureTime = today.plusHours(20); + + boolean result = connectionServiceHelper.isConnectionNotInPast(futureTime, today); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Should return true when date is after travel date") + void isConnectionNotInPast_DifferentDate() { + LocalDateTime travelDate = LocalDateTime.now(); + LocalDateTime time = travelDate.plusHours(1); + + boolean result = connectionServiceHelper.isConnectionNotInPast(time, travelDate); + + assertThat(result).isTrue(); + } +} \ 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 1c04551..4b13e5a 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceIntegrationTest.java @@ -13,19 +13,25 @@ import pl.agh.transit.gtfs_static.model.StopTime; import pl.agh.transit.gtfs_static.model.Trip; import pl.agh.transit.gtfs_static.repository.CalendarRepository; -import pl.agh.transit.gtfs_static.repository.RouteRepository; -import pl.agh.transit.gtfs_static.repository.StaticTripRepository; -import pl.agh.transit.gtfs_static.repository.StopRepository; -import pl.agh.transit.gtfs_static.repository.StopTimeRepository; import java.time.LocalDate; -import java.util.Optional; +import java.time.LocalTime; +import java.util.List; +import 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; +import pl.agh.transit.gtfs_static.repository.StopRepository; +import pl.agh.transit.gtfs_static.repository.StopTimeRepository; +import pl.agh.transit.trip.DirectConnectionService; @SpringBootTest @Transactional @DisplayName("DirectConnectionService Integration Tests") +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) class DirectConnectionServiceIntegrationTest { @Autowired @@ -46,6 +52,7 @@ class DirectConnectionServiceIntegrationTest { @Autowired private CalendarRepository calendarRepository; + private static final LocalTime TEST_TIME = LocalTime.of(22, 0); @BeforeEach void setUp() { @@ -100,7 +107,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 +130,7 @@ private void setupTestData() { stopTime1To.setStopSequence(2); stopTimeRepository.save(stopTime1To); - // Create trip 2 (faster: 10:05 - 10:10 = 5 minutes) + // Create trip 2 (later departure: 23:06:00, arrives 23:10:00) Trip trip2 = new Trip(); trip2.setId("trip-2"); trip2.setRouteId("route-2"); @@ -150,89 +157,89 @@ private void setupTestData() { @Test @DisplayName("Should find fastest direct connection from static data") void findFastestDirectConnection_staticData_returnsFastestRoute() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return empty when no direct connection exists") - void findFastestDirectConnection_noDirectConnection_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Final Stop", LocalDate.now()); + void findFastestDirectConnection_noDirectConnections_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Final Stop", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when source stop does not exist") - void findFastestDirectConnection_sourceStopNotFound_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Unknown Station", "City Center", LocalDate.now()); + void findFastestDirectConnections_sourceStopNotFound_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Unknown Station", "City Center", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when destination stop does not exist") - void findFastestDirectConnection_destinationStopNotFound_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Unknown Station", LocalDate.now()); + void findFastestDirectConnections_destinationStopNotFound_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Unknown Station", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when both stops are the same") - void findFastestDirectConnection_sameSourceAndDestination_returnsEmpty() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "Main Station", LocalDate.now()); + void findFastestDirectConnections_sameSourceAndDestination_returnsEmpty() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "Main Station", LocalDate.now(), TEST_TIME, 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when travel date is outside calendar range") - void findFastestDirectConnection_travelDateOutsideCalendar_returnsEmpty() { + void findFastestDirectConnections_travelDateOutsideCalendar_returnsEmpty() { LocalDate futureDate = LocalDate.now().plusYears(1); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", futureDate); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", futureDate, TEST_TIME, 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should find connection when travel date is within calendar range") - void findFastestDirectConnection_validTravelDate_returnsConnection() { + void findFastestDirectConnection_validTravelDate_returnsConnections() { LocalDate validDate = LocalDate.now(); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", validDate); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", validDate, TEST_TIME, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test - @DisplayName("Should compare travel times and return fastest route") - void findFastestDirectConnection_comparesTravelTimes_returnsFastestRoute() { - // Trip 1: 10:00 - 10:20 = 20 minutes - // Trip 2: 10:05 - 10:10 = 5 minutes - // Should select trip 2 because it's faster + @DisplayName("Should compare departure times and return earliest departure route") + void findFastestDirectConnections_comparesDepartureTimes_returnsEarliestDepartureRoute() { + // Trip 1: Departure 23:01:00, Arrival 23:20:00 + // Trip 2: Departure 23:06:00, Arrival 23:10:00 + // Should select trip 1 because it departs earliest - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("2B"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should ignore trips not active on given date") - void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { + void findFastestDirectConnections_inactiveServicesIgnored_returnsActiveRoute() { // Create inactive calendar for today Calendar inactiveCalendar = new Calendar(); inactiveCalendar.setId("service-inactive"); @@ -276,17 +283,17 @@ void findFastestDirectConnection_inactiveServicesIgnored_returnsActiveRoute() { stopTime3To.setStopSequence(2); stopTimeRepository.save(stopTime3To); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 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.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should handle multiple stops with same name") - void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { + void findFastestDirectConnections_multipleStopsSameName_handlesCorrectly() { // Create second stop with same name Stop duplicateStop = new Stop(); duplicateStop.setId("stop-main-2"); @@ -295,22 +302,22 @@ void findFastestDirectConnection_multipleStopsSameName_handlesCorrectly() { duplicateStop.setStopLon(19.05); stopRepository.save(duplicateStop); - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 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.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return valid arrival time for connection") - void findFastestDirectConnection_returnsNonNullArrivalTime() { - Optional result = directConnectionService - .findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + void findFastestDirectConnections_returnsNonNullArrivalTime() { + List result = directConnectionService + .findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), TEST_TIME, 1); - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + 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 112d450..9e369e8 100644 --- a/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java +++ b/src/test/java/pl/agh/transit/DirectConnectionServiceTest.java @@ -8,53 +8,45 @@ 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; -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.trip.DirectConnectionService; +import pl.agh.transit.trip.TripMapper; +import pl.agh.transit.util.ConnectionServiceHelper; 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.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @DisplayName("DirectConnectionService Unit Tests") class DirectConnectionServiceTest { - @Mock - private StopRepository stopRepository; - @Mock private StopTimeRepository stopTimeRepository; - @Mock - private RouteRepository routeRepository; - - @Mock - private StaticTripRepository staticTripRepository; - @Mock private TripRepository tripRepository; @Mock - private CalendarService calendarService; + private ConnectionServiceHelper connectionServiceHelper; + + @Mock + private StopRepository stopRepository; @Mock TripMapper tripMapper; @@ -64,11 +56,6 @@ class DirectConnectionServiceTest { private Stop fromStop; private Stop toStop; - private Route route; - private Trip trip; - private StopTime stopTimeFrom; - private StopTime stopTimeTo; - private TripUpdateDTO tripUpdateDTO; @BeforeEach void setUp() { @@ -83,56 +70,11 @@ void setUp() { toStop.setStopName("City Center"); toStop.setStopLat(50.1); toStop.setStopLon(19.1); - - route = new Route(); - route.setId("route-1"); - route.setRouteShortName("1A"); - - trip = new Trip(); - trip.setId("trip-1"); - trip.setRouteId("route-1"); - trip.setServiceId("service-1"); - - stopTimeFrom = new StopTime(); - stopTimeFrom.setTripId("trip-1"); - stopTimeFrom.setStopId("stop-1"); - stopTimeFrom.setArrivalTime("23:00:00"); - stopTimeFrom.setDepartureTime("23:01:00"); - stopTimeFrom.setStopSequence(1); - - stopTimeTo = new StopTime(); - stopTimeTo.setTripId("trip-1"); - stopTimeTo.setStopId("stop-2"); - stopTimeTo.setArrivalTime("23:15:00"); - stopTimeTo.setDepartureTime("23:16:00"); - stopTimeTo.setStopSequence(2); - - tripUpdateDTO = TripUpdateDTO.builder() - .tripId("trip-1") - .routeId("route-1") - .tripStartDate("20260106") - .stopTimeUpdates(List.of( - StopTimeUpdateDTO.builder() - .stopId("stop-1") - .arrivalTime("23:00:00") - .departureTime("23:01:00") - .arrivalDelay(0) - .departureDelay(0) - .build(), - StopTimeUpdateDTO.builder() - .stopId("stop-2") - .arrivalTime("23:15:00") - .departureTime("23:16:00") - .arrivalDelay(0) - .departureDelay(0) - .build() - )) - .build(); } @Test @DisplayName("Should find fastest direct connection using realtime data") - void findFastestDirectConnection_WithRealtimeData() { + void findFastestDirectConnections_WithRealtimeData() { TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") .routeId("route-1") @@ -150,79 +92,77 @@ void findFastestDirectConnection_WithRealtimeData() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("Should return empty when no stops found") - void findFastestDirectConnection_NoStopsFound() { + void findFastestDirectConnections_NoStopsFound() { when(stopRepository.findByStopName("Main Station")).thenReturn(List.of()); when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } @Test @DisplayName("Should return empty when destination stops not found") - void findFastestDirectConnection_NoDestinationStopsFound() { + void findFastestDirectConnections_NoDestinationStopsFound() { when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); when(stopRepository.findByStopName("Unknown Destination")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "Unknown Destination", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "Unknown Destination", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); } + @Test @DisplayName("Should filter trips by service calendar") void filterTripsByServiceCalendar_ShouldRemoveInactiveServices() { - TripUpdateDTO activeTrip = tripUpdateDTO; + TripUpdateDTO activeTrip = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .stopTimeUpdates(List.of()) + .build(); TripUpdateDTO inactiveTrip = TripUpdateDTO.builder() .tripId("trip-2") .routeId("route-2") .stopTimeUpdates(List.of()) .build(); - Trip trip2 = new Trip(); - trip2.setId("trip-2"); - trip2.setRouteId("route-2"); - trip2.setServiceId("service-2"); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(staticTripRepository.findById("trip-2")).thenReturn(Optional.of(trip2)); - when(calendarService.isServiceActiveOnDate("service-1", LocalDate.now())).thenReturn(true); - when(calendarService.isServiceActiveOnDate("service-2", LocalDate.now())).thenReturn(false); - - List trips = List.of(activeTrip, inactiveTrip); - List result = trips.stream() - .filter(t -> { - Optional staticTrip = staticTripRepository.findById(t.getTripId()); - if (staticTrip.isEmpty()) { - return false; - } - return calendarService.isServiceActiveOnDate(staticTrip.get().getServiceId(), LocalDate.now()); - }) - .toList(); + List filteredTrips = List.of(activeTrip); + when(connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now())) + .thenReturn(filteredTrips); + + List result = connectionServiceHelper.filterTripsByServiceCalendar(trips, LocalDate.now()); assertThat(result).hasSize(1); assertThat(result.getFirst().getTripId()).isEqualTo("trip-1"); @@ -248,55 +188,60 @@ void findConnectionInTrip_WithMultipleStops() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isNotEqualTo("N/A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isNotEqualTo("N/A"); } + @Test @DisplayName("Should fallback to static data when no realtime data available") - void findFastestDirectConnection_FallbackToStaticData() { - LocalDate travelDate = LocalDate.now(); // Use tomorrow to avoid current time filtering + void findFastestDirectConnections_FallbackToStaticData() { + LocalDate travelDate = LocalDate.now(); when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of()); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate("service-1", travelDate)).thenReturn(true); - when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(stopTimeFrom)); - when(stopTimeRepository.findByTripId("trip-1")).thenReturn(List.of(stopTimeFrom, stopTimeTo)); + when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + // When no realtime and no static data, should return empty + assertThat(result).isEmpty(); } @Test @DisplayName("Should filter trips inactive on specific date") - void findFastestDirectConnection_WithSpecificDate() { + void findFastestDirectConnections_WithSpecificDate() { LocalDate travelDate = LocalDate.of(2026, 1, 6); when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of(mock(GtfsRealtime.FeedEntity.class))); when(tripMapper.toDtoList(anyList())).thenReturn(List.of()); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate("service-1", travelDate)).thenReturn(false); - when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of(stopTimeFrom)); + when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", travelDate); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -322,18 +267,26 @@ void findConnectionInTrip_RouteNameFromRealtime() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @@ -356,19 +309,27 @@ void findConnectionInTrip_RouteNotFound() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.empty()); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("N/A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("N/A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("N/A"); } @Test @@ -395,66 +356,39 @@ void findConnectionInTrip_SkipSameStop() { )) .build(); - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop, sameStop)); - when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); - 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); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); - - assertThat(result).isPresent(); - } - - @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(); - - when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); + 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)); - 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); - - - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + 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).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + assertThat(result).hasSize(1); } @Test @DisplayName("Should handle empty trip updates list") - void findFastestDirectConnection_EmptyTripUpdates() { + void findFastestDirectConnections_EmptyTripUpdates() { when(stopRepository.findByStopName("Main Station")).thenReturn(List.of(fromStop)); when(stopRepository.findByStopName("City Center")).thenReturn(List.of(toStop)); + when(connectionServiceHelper.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of()); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -480,22 +414,30 @@ void findFastestDirectConnection_SelectsFastest() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById(anyString())).thenReturn(Optional.of(trip)); - when(routeRepository.findById(anyString())).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @DisplayName("Should handle multiple destination stops with same name") - void findFastestDirectConnection_MultipleDestinationStops() { + void findFastestDirectConnections_MultipleDestinationStops() { Stop toStop2 = new Stop(); toStop2.setId("stop-3"); toStop2.setStopName("City Center"); @@ -517,17 +459,25 @@ void findFastestDirectConnection_MultipleDestinationStops() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); + assertThat(result).hasSize(1); } @Test @@ -536,12 +486,13 @@ void findStaticConnection_NoMatchingStopTimes() { 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()); when(stopTimeRepository.findByStopId("stop-1")).thenReturn(List.of()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); assertThat(result).isEmpty(); @@ -567,18 +518,26 @@ void parseArrivalTimeWithDelay() { )) .build(); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + 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)); - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + when(connectionServiceHelper.parseTimeWithDelay("23:01:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(1)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getArrivalTime()).isNotNull(); } @Test @@ -587,11 +546,12 @@ void findConnectionInTrip_FromStopNotInTrip() { 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()); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center"); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center" ,1); assertThat(result).isEmpty(); @@ -624,23 +584,19 @@ void findStaticConnection_WithComplexTripSequence() { 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(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)); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", LocalDate.now(), LocalTime.now(), 1); - 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 @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") @@ -661,27 +617,30 @@ void findFastestDirectConnection_routeNameFallbackToStaticTrip_whenRouteIdMissin 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.filterTripsByServiceCalendar(anyList(), any(LocalDate.class))).thenReturn(List.of(dtoWithoutRouteId)); when(tripRepository.getEntitiesOrEmpty()).thenReturn(List.of()); when(tripMapper.toDtoList(any())).thenReturn(List.of(dtoWithoutRouteId)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - // static fallback for routeName: - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dtoWithoutRouteId)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("findConnectionInTrip should use realtime routeId when present") - void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { + void findFastestDirectConnections_routeNameFromRealtime_whenPresent() { // given TripUpdateDTO dtoWithRouteId = TripUpdateDTO.builder() .tripId("trip-1") @@ -702,25 +661,30 @@ void findFastestDirectConnection_routeNameFromRealtime_whenPresent() { 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)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dtoWithRouteId)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("findConnectionInTrip should pick destination stop that appears after fromStop") - void findFastestDirectConnection_destinationMustBeAfterFromStop() { + void findFastestDirectConnections_destinationMustBeAfterFromStop() { // given: destination first, fromStop later, destination again after -> should pick the later one TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -746,26 +710,31 @@ void findFastestDirectConnection_destinationMustBeAfterFromStop() { 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)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(15)); // when - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); // then: arrivalTime is computed from *fromStop* arrivalTime in current implementation // so here we just assert connection exists and route resolved. - assertThat(result).isPresent(); - assertThat(result.get().getRouteName()).isEqualTo("1A"); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRouteName()).isEqualTo("1A"); } @Test @DisplayName("parseArrivalTimeWithDelay should apply arrivalDelay in seconds") - void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_appliesDelay() { // given TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -786,27 +755,31 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_appliesDelay() { 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)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate today = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(today, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", today, 0)) + .thenReturn(today.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime expectedArrival = today.atStartOfDay().plusHours(23).plusMinutes(15).plusSeconds(75); + when(connectionServiceHelper.parseTimeWithDelay("23:15:00", today, 75)) + .thenReturn(expectedArrival); // when - LocalDate today = LocalDate.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", today); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", today, travelTime, 1); // then - assertThat(result).isPresent(); - LocalDateTime expected = today.atStartOfDay().plusHours(23).plusMinutes(15).plusSeconds(75); - assertThat(result.get().getArrivalTime()).isEqualTo(expected); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getArrivalTime()).isEqualTo(expectedArrival); } @Test @DisplayName("parseArrivalTimeWithDelay should fallback to now when arrivalTime is null") - void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallbacksToNow() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_nullArrivalTime_fallbacksToNow() { // given TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -827,28 +800,34 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_nullArrivalTime_fallb 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)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay(null, travelDate, 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then - assertThat(result).isPresent(); + assertThat(result).hasSize(1); // allow small timing window - assertThat(result.get().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result.getFirst().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); } @Test @DisplayName("parseArrivalTimeWithDelay should fallback to now when arrivalTime has invalid format") - void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbacksToNow() { + void findFastestDirectConnections_parseArrivalTimeWithDelay_invalidFormat_fallbacksToNow() { // given: NumberFormatException from parsing hours TripUpdateDTO dto = TripUpdateDTO.builder() .tripId("trip-1") @@ -869,23 +848,108 @@ void findFastestDirectConnection_parseArrivalTimeWithDelay_invalidFormat_fallbac 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)); - - when(staticTripRepository.findById("trip-1")).thenReturn(Optional.of(trip)); - when(calendarService.isServiceActiveOnDate(anyString(), any(LocalDate.class))).thenReturn(true); - when(routeRepository.findById("route-1")).thenReturn(Optional.of(route)); + when(connectionServiceHelper.getRealtimeRouteName(dto)).thenReturn("1A"); + LocalDate travelDate = LocalDate.now(); + LocalTime travelTime = LocalTime.now(); + LocalDateTime travelDateTime = LocalDateTime.of(travelDate, travelTime); + when(connectionServiceHelper.parseTimeWithDelay("23:00:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23)); + when(connectionServiceHelper.isConnectionNotInPast(any(LocalDateTime.class), eq(travelDateTime))).thenReturn(true); + LocalDateTime nowish = LocalDateTime.now(); + when(connectionServiceHelper.parseTimeWithDelay("AA:BB:CC", travelDate, 10)) + .thenReturn(nowish); // when LocalDateTime before = LocalDateTime.now(); - Optional result = directConnectionService.findFastestDirectConnection("Main Station", "City Center", LocalDate.now()); + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); LocalDateTime after = LocalDateTime.now(); // then - assertThat(result).isPresent(); - assertThat(result.get().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + assertThat(result).hasSize(1); + assertThat(result.getFirst().getArrivalTime()).isBetween(before.minusSeconds(1), after.plusSeconds(1)); + } + + @Test + @DisplayName("Should select earliest departure time when multiple connections available") + void findFastestDirectConnections_selectsEarliestDeparture() { + // given: Trip 1 departs at 23:01, Trip 2 departs at 23:06 + // Even though Trip 2 arrives earlier (23:10 vs 23:20), should select Trip 1 (earlier departure) + TripUpdateDTO trip1 = TripUpdateDTO.builder() + .tripId("trip-1") + .routeId("route-1") + .stopTimeUpdates(List.of( + StopTimeUpdateDTO.builder() + .stopId("stop-1") + .departureTime("23:01:00") + .departureDelay(0) + .build(), + StopTimeUpdateDTO.builder() + .stopId("stop-2") + .arrivalTime("23:20:00") + .arrivalDelay(0) + .build() + )) + .build(); + + TripUpdateDTO trip2 = TripUpdateDTO.builder() + .tripId("trip-2") + .routeId("route-2") + .stopTimeUpdates(List.of( + StopTimeUpdateDTO.builder() + .stopId("stop-1") + .departureTime("23:06:00") + .departureDelay(0) + .build(), + StopTimeUpdateDTO.builder() + .stopId("stop-2") + .arrivalTime("23:10:00") + .arrivalDelay(0) + .build() + )) + .build(); + + when(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)); + 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(trip1Departure, 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(trip2Departure, travelDateTime)) + .thenReturn(true); + when(connectionServiceHelper.parseTimeWithDelay("23:10:00", travelDate, 0)) + .thenReturn(travelDate.atStartOfDay().plusHours(23).plusMinutes(10)); + + // when + List result = directConnectionService.findFastestDirectConnections("Main Station", "City Center", travelDate, travelTime, 1); + + // then - should select Trip 1 because it has earliest departure (23:01 < 23:06) + assertThat(result).hasSize(1); + assertThat(result.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..31265d3 --- /dev/null +++ b/src/test/java/pl/agh/transit/StopMapperTest.java @@ -0,0 +1,150 @@ +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 pl.agh.transit.stop.StopMapper; + +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 new file mode 100644 index 0000000..5ccb8a4 --- /dev/null +++ b/src/test/java/pl/agh/transit/StopServiceTest.java @@ -0,0 +1,521 @@ +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.NullAndEmptySource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +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; +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.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; +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.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 StopTimeRepository stopTimeRepository; + + @Mock + private ConnectionServiceHelper connectionServiceHelper; + + @Mock + private StaticTripRepository staticTripRepository; + + @Mock + private RouteRepository routeRepository; + + @Mock + private TripRepository tripRepository; + + @Mock + private TripMapper tripMapper; + + @Mock + private StopMapper stopMapper; + + @InjectMocks + private StopService stopService; + + private Stop stop1; + private Stop stop2; + private Route route1; + private Trip trip1; + private StopTime stopTime1; + private StopDTO stopDTO; + + @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"); + + stopDTO = StopDTO.builder() + .id("stop-1") + .name("Main Station") + .latitude(50.0) + .longitude(19.0) + .build(); + } + + @Test + @DisplayName("Should convert Stop to StopDTO correctly and return paginated result") + void getAllStops_Success() { + // Arrange + int page = 0; + int size = 10; + String sortBy = "stopName"; + Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy)); + + 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.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 pass correct PageRequest to repository") + void getAllStops_ShouldPassCorrectPageable() { + // Arrange + int page = 2; + int size = 15; + String sortBy = "id"; + + when(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); + + // Act + stopService.getAllStops(page, size, sortBy); + + // Assert + 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(stopRepository.findAll(any(Pageable.class))).thenReturn(Page.empty()); + + // Act + PagedResponseDTO result = stopService.getAllStops(0, 10, "stopName"); + + // Assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0L); + assertThat(result.getTotalPages()).isEqualTo(1); + } + + @Test + @DisplayName("Should set hasNext correctly on middle page") + void getAllStops_HasNextOnMiddlePage() { + // Arrange + 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, "stopName"); + + // Assert + assertThat(result.isHasNext()).isTrue(); + assertThat(result.isHasPrevious()).isTrue(); + } + + @Test + @DisplayName("Should set hasNext false on last page") + void getAllStops_NoNextOnLastPage() { + // Arrange + 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, "stopName"); + + // Assert + assertThat(result.isHasNext()).isFalse(); + assertThat(result.isHasPrevious()).isTrue(); + } + + @ParameterizedTest + @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()); + + // Act + stopService.getAllStops(0, 10, sortBy); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(stopRepository).findAll(captor.capture()); + + Pageable captured = captor.getValue(); + assertThat(captured.getSort().isSorted()).isFalse(); // Sort.unsorted() + } + + @Test + @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()); + + // Act + stopService.getAllStops(0, 10, sortBy); + + // Assert + ArgumentCaptor captor = ArgumentCaptor.forClass(Pageable.class); + verify(stopRepository).findAll(captor.capture()); + + Pageable captured = captor.getValue(); + assertThat(captured.getSort().getOrderFor("stopLat")).isNotNull(); + } + + @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(stopRepository.findByStopName("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(stopTimeRepository.findByStopId("stop-1")) + .thenReturn(List.of()); + + // Act + List result = stopService.getNextDeparturesForStopAndRoute( + "Main Station", "1A", travelDate, travelTime, limit + ); + + // Assert + assertThat(result) + .isNotNull() + .hasSizeLessThanOrEqualTo(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(stopRepository.findByStopName("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(stopRepository.findByStopName("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(stopRepository.findByStopName("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(stopTimeRepository.findByStopId("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); + + when(stopRepository.findByStopName("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(stopTimeRepository.findByStopId("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(stopRepository.findByStopName("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 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(stopRepository.findByStopName("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(stopTimeRepository.findByStopId("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(stopRepository.findByStopName("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); + } +} \ No newline at end of file diff --git a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java index 94d89c0..8ac05e4 100644 --- a/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java +++ b/src/test/java/pl/agh/transit/TripControllerIntegrationTest.java @@ -7,12 +7,18 @@ 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 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 { @@ -46,7 +52,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 +69,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()); } } 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; 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