From 66f306f11e07c02fc6d6c24cb541a605912bdbcd Mon Sep 17 00:00:00 2001 From: Kim Kakeya Date: Wed, 3 Dec 2025 22:25:56 -0300 Subject: [PATCH 1/5] feat: make gtfs adapter consider route direction and accepts multiple routes --- .../repositories/gtfs_repository_adapter.py | 16 +- src/core/models/route_shape.py | 5 +- src/core/ports/gtfs_repository.py | 5 +- src/core/services/route_service.py | 27 ++- src/web/controllers/route_controller.py | 46 ++--- src/web/mappers.py | 23 ++- src/web/schemas.py | 20 +- .../adapters/test_gtfs_repository_adapter.py | 42 ++-- tests/core/test_route_service.py | 185 ++++++++++++++++-- tests/integration/test_route.py | 134 ++++++++++--- tests/web/test_route_controller.py | 172 +++++++++++++++- 11 files changed, 577 insertions(+), 98 deletions(-) diff --git a/src/adapters/repositories/gtfs_repository_adapter.py b/src/adapters/repositories/gtfs_repository_adapter.py index 17a9ccb..0ca250e 100644 --- a/src/adapters/repositories/gtfs_repository_adapter.py +++ b/src/adapters/repositories/gtfs_repository_adapter.py @@ -1,6 +1,7 @@ """GTFS repository adapter - SQLite implementation.""" from ...adapters.database.gtfs_connection import get_gtfs_db +from ...core.models.bus import RouteIdentifier from ...core.models.coordinate import Coordinate from ...core.models.route_shape import RouteShape, RouteShapePoint from ...core.ports.gtfs_repository import GTFSRepositoryPort @@ -13,26 +14,29 @@ class GTFSRepositoryAdapter(GTFSRepositoryPort): Implements the GTFS repository port using a SQLite database. """ - def get_route_shape(self, route_id: str) -> RouteShape | None: + def get_route_shape(self, route: RouteIdentifier) -> RouteShape | None: """ Get the geographic shape of a route from GTFS database. Args: - route_id: Route identifier + route: Route identifier with bus_line and direction Returns: RouteShape with ordered coordinates, or None if route not found """ with get_gtfs_db() as conn: - # First, get the shape_id for this route + # First, get the shape_id for this route filtering by route_id and direction_id + # In GTFS, direction_id is 0 or 1, while our RouteIdentifier uses 1 or 2 + direction_id = route.bus_direction - 1 # Convert: 1->0, 2->1 + cursor = conn.execute( """ SELECT DISTINCT shape_id FROM trips - WHERE route_id = ? + WHERE route_id = ? AND direction_id = ? LIMIT 1 """, - (route_id,), + (route.bus_line, direction_id), ) row = cursor.fetchone() @@ -68,7 +72,7 @@ def get_route_shape(self, route_id: str) -> RouteShape | None: return None return RouteShape( - route_id=route_id, + route=route, shape_id=shape_id, points=points, ) diff --git a/src/core/models/route_shape.py b/src/core/models/route_shape.py index 300f328..2239e8b 100644 --- a/src/core/models/route_shape.py +++ b/src/core/models/route_shape.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from .bus import RouteIdentifier from .coordinate import Coordinate @@ -27,11 +28,11 @@ class RouteShape: Complete shape of a route with ordered coordinates. Attributes: - route_id: Route identifier + route: Route identifier (bus_line and direction) shape_id: Shape identifier from GTFS points: List of points defining the route shape, ordered by sequence """ - route_id: str + route: RouteIdentifier shape_id: str points: list[RouteShapePoint] diff --git a/src/core/ports/gtfs_repository.py b/src/core/ports/gtfs_repository.py index c6acf46..0cec9fc 100644 --- a/src/core/ports/gtfs_repository.py +++ b/src/core/ports/gtfs_repository.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod +from ..models.bus import RouteIdentifier from ..models.route_shape import RouteShape @@ -14,12 +15,12 @@ class GTFSRepositoryPort(ABC): """ @abstractmethod - def get_route_shape(self, route_id: str) -> RouteShape | None: + def get_route_shape(self, route: RouteIdentifier) -> RouteShape | None: """ Get the geographic shape of a route. Args: - route_id: Route identifier + route: Route identifier with bus_line and direction Returns: RouteShape with ordered coordinates, or None if route not found diff --git a/src/core/services/route_service.py b/src/core/services/route_service.py index 6052879..7ea3b83 100644 --- a/src/core/services/route_service.py +++ b/src/core/services/route_service.py @@ -14,7 +14,9 @@ class RouteService: real-time bus information and GTFS data for route shapes. """ - def __init__(self, bus_provider: BusProviderPort, gtfs_repository: GTFSRepositoryPort): + def __init__( + self, bus_provider: BusProviderPort, gtfs_repository: GTFSRepositoryPort + ): """ Initialize the route service. @@ -60,14 +62,31 @@ async def get_route_details(self, route: RouteIdentifier) -> list[BusRoute]: await self.bus_provider.authenticate() return await self.bus_provider.get_route_details(route) - def get_route_shape(self, route_id: str) -> RouteShape | None: + def get_route_shape(self, route: RouteIdentifier) -> RouteShape | None: """ Get the geographic shape coordinates of a route from GTFS data. Args: - route_id: Route identifier (e.g., "1012-10") + route: Route identifier with bus_line and direction Returns: RouteShape with ordered coordinates, or None if route not found """ - return self.gtfs_repository.get_route_shape(route_id) + return self.gtfs_repository.get_route_shape(route) + + def get_route_shapes(self, routes: list[RouteIdentifier]) -> list[RouteShape]: + """ + Get the geographic shape coordinates for multiple routes from GTFS data. + + Args: + routes: List of route identifiers with bus_line and direction + + Returns: + List of RouteShapes with ordered coordinates (excludes routes not found) + """ + shapes: list[RouteShape] = [] + for route in routes: + shape = self.gtfs_repository.get_route_shape(route) + if shape is not None: + shapes.append(shape) + return shapes diff --git a/src/web/controllers/route_controller.py b/src/web/controllers/route_controller.py index 0d6d784..40d46f4 100644 --- a/src/web/controllers/route_controller.py +++ b/src/web/controllers/route_controller.py @@ -16,7 +16,7 @@ from ..mappers import ( map_bus_position_list_to_schema, map_route_identifier_schema_to_domain, - map_route_shape_to_response, + map_route_shapes_to_response, ) from ..schemas import ( BusPositionsRequest, @@ -25,7 +25,8 @@ BusRoutesDetailsRequest, BusRoutesDetailsResponse, RouteIdentifierSchema, - RouteShapeResponse, + RouteShapesRequest, + RouteShapesResponse, ) router = APIRouter(prefix="/routes", tags=["routes"]) @@ -64,7 +65,8 @@ async def get_route_details_endpoint( try: # Schemas -> domínio (RouteIdentifier) route_identifiers: list[RouteIdentifier] = [ - map_route_identifier_schema_to_domain(route_schema) for route_schema in request.routes + map_route_identifier_schema_to_domain(route_schema) + for route_schema in request.routes ] bus_routes: list[BusRoute] = [] @@ -127,7 +129,9 @@ async def get_bus_positions( route=route_identifier, ) - route_positions: list[BusPosition] = await route_service.get_bus_positions(bus_route) + route_positions: list[BusPosition] = await route_service.get_bus_positions( + bus_route + ) all_positions.extend(route_positions) # Domínio -> schemas @@ -144,40 +148,38 @@ async def get_bus_positions( # NOTE: Having `current_user: User = Depends(get_current_user)` as a dependency # makes this endpoint only accessible to authenticated users (requires valid JWT token). -@router.get("/shape/{route_id}", response_model=RouteShapeResponse) -async def get_route_shape( - route_id: str, +@router.post("/shapes", response_model=RouteShapesResponse) +async def get_route_shapes( + request: RouteShapesRequest, route_service: RouteService = Depends(get_route_service), current_user: User = Depends(get_current_user), -) -> RouteShapeResponse: +) -> RouteShapesResponse: """ - Get the geographic shape (coordinates) of a route from GTFS data. + Get the geographic shapes (coordinates) for multiple routes from GTFS data. Args: - route_id: Route identifier (e.g., "1012-10") + request: Request containing list of route identifiers (bus_line and direction) route_service: Injected route service Returns: - Ordered list of coordinates defining the route shape + List of route shapes with ordered coordinates Raises: - HTTPException: If route not found or database error occurs + HTTPException: If database error occurs """ try: - shape = route_service.get_route_shape(route_id) + route_identifiers = [ + map_route_identifier_schema_to_domain(route_schema) + for route_schema in request.routes + ] - if shape is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Route '{route_id}' not found in GTFS database", - ) + shapes = route_service.get_route_shapes(route_identifiers) - return map_route_shape_to_response(shape) + shape_responses = map_route_shapes_to_response(shapes) + return RouteShapesResponse(shapes=shape_responses) - except HTTPException: - raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to retrieve route shape: {str(e)}", + detail=f"Failed to retrieve route shapes: {str(e)}", ) from e diff --git a/src/web/mappers.py b/src/web/mappers.py index d299049..38871e3 100644 --- a/src/web/mappers.py +++ b/src/web/mappers.py @@ -154,12 +154,27 @@ def map_route_shape_to_response(shape: RouteShape) -> RouteShapeResponse: RouteShapeResponse for API """ return RouteShapeResponse( - route_id=shape.route_id, + route=map_route_identifier_domain_to_schema(shape.route), shape_id=shape.shape_id, - points=[map_coordinate_domain_to_schema(point.coordinate) for point in shape.points], + points=[ + map_coordinate_domain_to_schema(point.coordinate) for point in shape.points + ], ) +def map_route_shapes_to_response(shapes: list[RouteShape]) -> list[RouteShapeResponse]: + """ + Map a list of RouteShape domain models to RouteShapeResponse list. + + Args: + shapes: List of RouteShape domain models + + Returns: + List of RouteShapeResponse for API + """ + return [map_route_shape_to_response(shape) for shape in shapes] + + # ===== History Mappers ===== @@ -190,4 +205,6 @@ def map_history_entries_to_response(entries: list[HistoryEntry]) -> HistoryRespo Returns: HistoryResponse for API """ - return HistoryResponse(trips=[map_history_entry_to_schema(entry) for entry in entries]) + return HistoryResponse( + trips=[map_history_entry_to_schema(entry) for entry in entries] + ) diff --git a/src/web/schemas.py b/src/web/schemas.py index 6c11c25..25a6cfc 100644 --- a/src/web/schemas.py +++ b/src/web/schemas.py @@ -138,9 +138,25 @@ class BusRoutesDetailsResponse(BaseModel): class RouteShapeResponse(BaseModel): """Response schema for route shape coordinates.""" - route_id: str = Field(..., description="Route identifier") + route: RouteIdentifierSchema = Field(..., description="Route identifier") shape_id: str = Field(..., description="GTFS shape identifier") - points: list[CoordinateSchema] = Field(..., description="Ordered list of coordinates") + points: list[CoordinateSchema] = Field( + ..., description="Ordered list of coordinates" + ) + + +class RouteShapesRequest(BaseModel): + """Request schema for querying multiple route shapes.""" + + routes: list[RouteIdentifierSchema] = Field( + ..., description="List of route identifiers to query shapes for" + ) + + +class RouteShapesResponse(BaseModel): + """Response schema for multiple route shapes.""" + + shapes: list[RouteShapeResponse] = Field(..., description="List of route shapes") # ===== Ranking Schemas ===== diff --git a/tests/adapters/test_gtfs_repository_adapter.py b/tests/adapters/test_gtfs_repository_adapter.py index dac41ed..31d5b30 100644 --- a/tests/adapters/test_gtfs_repository_adapter.py +++ b/tests/adapters/test_gtfs_repository_adapter.py @@ -7,13 +7,14 @@ from unittest.mock import MagicMock, patch from src.adapters.repositories.gtfs_repository_adapter import GTFSRepositoryAdapter +from src.core.models.bus import RouteIdentifier from src.core.models.route_shape import RouteShape def test_get_route_shape_found() -> None: - """Test getting a route shape when the route exists in the database.""" # Arrange adapter = GTFSRepositoryAdapter() + route = RouteIdentifier(bus_line="test_route_1", bus_direction=1) # Mock the database connection and cursors mock_conn = MagicMock() @@ -49,16 +50,19 @@ def test_get_route_shape_found() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] # Patch get_gtfs_db to return our mock connection - with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: + with patch( + "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" + ) as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act - result = adapter.get_route_shape("test_route_1") + result = adapter.get_route_shape(route) # Assert assert result is not None assert isinstance(result, RouteShape) - assert result.route_id == "test_route_1" + assert result.route.bus_line == "test_route_1" + assert result.route.bus_direction == 1 assert result.shape_id == "test_shape_123" assert len(result.points) == 3 @@ -76,9 +80,9 @@ def test_get_route_shape_found() -> None: def test_get_route_shape_route_not_found() -> None: - """Test getting a route shape when the route doesn't exist.""" # Arrange adapter = GTFSRepositoryAdapter() + route = RouteIdentifier(bus_line="nonexistent_route", bus_direction=1) mock_conn = MagicMock() mock_cursor = MagicMock() @@ -87,20 +91,22 @@ def test_get_route_shape_route_not_found() -> None: mock_cursor.fetchone.return_value = None mock_conn.execute.return_value = mock_cursor - with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: + with patch( + "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" + ) as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act - result = adapter.get_route_shape("nonexistent_route") + result = adapter.get_route_shape(route) # Assert assert result is None def test_get_route_shape_no_shape_points() -> None: - """Test when route exists but has no shape points.""" # Arrange adapter = GTFSRepositoryAdapter() + route = RouteIdentifier(bus_line="route_without_points", bus_direction=1) mock_conn = MagicMock() mock_cursor1 = MagicMock() @@ -114,11 +120,13 @@ def test_get_route_shape_no_shape_points() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: + with patch( + "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" + ) as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act - result = adapter.get_route_shape("route_without_points") + result = adapter.get_route_shape(route) # Assert assert result is None @@ -128,6 +136,7 @@ def test_get_route_shape_single_point() -> None: """Test getting a route shape with only one point.""" # Arrange adapter = GTFSRepositoryAdapter() + route = RouteIdentifier(bus_line="single_point_route", bus_direction=1) mock_conn = MagicMock() mock_cursor1 = MagicMock() @@ -146,11 +155,13 @@ def test_get_route_shape_single_point() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: + with patch( + "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" + ) as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act - result = adapter.get_route_shape("single_point_route") + result = adapter.get_route_shape(route) # Assert assert result is not None @@ -163,6 +174,7 @@ def test_get_route_shape_null_distance_traveled() -> None: """Test getting a route shape with NULL distance_traveled values.""" # Arrange adapter = GTFSRepositoryAdapter() + route = RouteIdentifier(bus_line="route_no_distance", bus_direction=1) mock_conn = MagicMock() mock_cursor1 = MagicMock() @@ -187,11 +199,13 @@ def test_get_route_shape_null_distance_traveled() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: + with patch( + "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" + ) as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act - result = adapter.get_route_shape("route_no_distance") + result = adapter.get_route_shape(route) # Assert assert result is not None diff --git a/tests/core/test_route_service.py b/tests/core/test_route_service.py index 61ea801..3fdcd0b 100644 --- a/tests/core/test_route_service.py +++ b/tests/core/test_route_service.py @@ -45,7 +45,9 @@ async def test_get_bus_positions_calls_auth_and_provider() -> None: raw_provider.get_bus_positions.return_value = expected_positions gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) + service: RouteService = RouteService( + bus_provider=bus_provider, gtfs_repository=gtfs_repo + ) # Act result: list[BusPosition] = await service.get_bus_positions(bus_route) @@ -81,7 +83,9 @@ async def test_get_route_details_calls_auth_and_provider() -> None: raw_provider.get_route_details.return_value = expected_routes gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) + service: RouteService = RouteService( + bus_provider=bus_provider, gtfs_repository=gtfs_repo + ) # Act result: list[BusRoute] = await service.get_route_details(route_identifier) @@ -112,7 +116,9 @@ async def test_get_bus_positions_propagates_exception_from_provider() -> None: ) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) + service: RouteService = RouteService( + bus_provider=bus_provider, gtfs_repository=gtfs_repo + ) # Act / Assert with pytest.raises(RuntimeError, match="boom"): @@ -139,7 +145,9 @@ async def test_get_route_details_propagates_exception_from_authenticate() -> Non ) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) + service: RouteService = RouteService( + bus_provider=bus_provider, gtfs_repository=gtfs_repo + ) # Act / Assert with pytest.raises(RuntimeError, match="auth failed"): @@ -154,9 +162,11 @@ def test_get_route_shape_found() -> None: bus_provider = create_autospec(BusProviderPort, instance=True) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + route = RouteIdentifier(bus_line="1012-10", bus_direction=1) + # Create a mock route shape mock_shape = RouteShape( - route_id="1012-10", + route=route, shape_id="84609", points=[ RouteShapePoint( @@ -177,14 +187,15 @@ def test_get_route_shape_found() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape("1012-10") + result = service.get_route_shape(route) # Assert assert result is not None - assert result.route_id == "1012-10" + assert result.route.bus_line == "1012-10" + assert result.route.bus_direction == 1 assert result.shape_id == "84609" assert len(result.points) == 2 - gtfs_repo.get_route_shape.assert_called_once_with("1012-10") + gtfs_repo.get_route_shape.assert_called_once_with(route) def test_get_route_shape_not_found() -> None: @@ -192,16 +203,18 @@ def test_get_route_shape_not_found() -> None: bus_provider = create_autospec(BusProviderPort, instance=True) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + route = RouteIdentifier(bus_line="nonexistent-route", bus_direction=1) + gtfs_repo.get_route_shape.return_value = None service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape("nonexistent-route") + result = service.get_route_shape(route) # Assert assert result is None - gtfs_repo.get_route_shape.assert_called_once_with("nonexistent-route") + gtfs_repo.get_route_shape.assert_called_once_with(route) def test_get_route_shape_with_many_points() -> None: @@ -209,31 +222,35 @@ def test_get_route_shape_with_many_points() -> None: bus_provider = create_autospec(BusProviderPort, instance=True) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + route = RouteIdentifier(bus_line="long-route", bus_direction=1) + # Create a shape with many points points = [ RouteShapePoint( - coordinate=Coordinate(latitude=-23.5505 + i * 0.001, longitude=-46.6333 + i * 0.001), + coordinate=Coordinate( + latitude=-23.5505 + i * 0.001, longitude=-46.6333 + i * 0.001 + ), sequence=i + 1, distance_traveled=float(i * 10), ) for i in range(100) ] - mock_shape = RouteShape(route_id="long-route", shape_id="shape_long", points=points) + mock_shape = RouteShape(route=route, shape_id="shape_long", points=points) gtfs_repo.get_route_shape.return_value = mock_shape service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape("long-route") + result = service.get_route_shape(route) # Assert assert result is not None assert len(result.points) == 100 assert result.points[0].sequence == 1 assert result.points[99].sequence == 100 - gtfs_repo.get_route_shape.assert_called_once_with("long-route") + gtfs_repo.get_route_shape.assert_called_once_with(route) def test_get_route_shape_with_special_characters() -> None: @@ -241,8 +258,10 @@ def test_get_route_shape_with_special_characters() -> None: bus_provider = create_autospec(BusProviderPort, instance=True) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + route = RouteIdentifier(bus_line="route-with-special_chars@123", bus_direction=1) + mock_shape = RouteShape( - route_id="route-with-special_chars@123", + route=route, shape_id="shape_special", points=[ RouteShapePoint( @@ -258,12 +277,12 @@ def test_get_route_shape_with_special_characters() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape("route-with-special_chars@123") + result = service.get_route_shape(route) # Assert assert result is not None - assert result.route_id == "route-with-special_chars@123" - gtfs_repo.get_route_shape.assert_called_once_with("route-with-special_chars@123") + assert result.route.bus_line == "route-with-special_chars@123" + gtfs_repo.get_route_shape.assert_called_once_with(route) def test_get_route_shape_independent_of_bus_provider() -> None: @@ -271,8 +290,10 @@ def test_get_route_shape_independent_of_bus_provider() -> None: bus_provider = create_autospec(BusProviderPort, instance=True) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + route = RouteIdentifier(bus_line="test-route", bus_direction=1) + mock_shape = RouteShape( - route_id="test-route", + route=route, shape_id="test-shape", points=[ RouteShapePoint( @@ -288,7 +309,7 @@ def test_get_route_shape_independent_of_bus_provider() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape("test-route") + result = service.get_route_shape(route) # Assert assert result is not None @@ -296,3 +317,127 @@ def test_get_route_shape_independent_of_bus_provider() -> None: bus_provider.authenticate.assert_not_called() bus_provider.get_bus_positions.assert_not_called() bus_provider.get_route_details.assert_not_called() + + +def test_get_route_shapes_multiple_routes() -> None: + # Arrange + bus_provider = create_autospec(BusProviderPort, instance=True) + gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + + route1 = RouteIdentifier(bus_line="8075", bus_direction=1) + route2 = RouteIdentifier(bus_line="8075", bus_direction=2) + route3 = RouteIdentifier(bus_line="1012", bus_direction=1) + + mock_shape1 = RouteShape( + route=route1, + shape_id="shape_8075_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5505, longitude=-46.6333), + sequence=1, + distance_traveled=0.0, + ) + ], + ) + + mock_shape2 = RouteShape( + route=route2, + shape_id="shape_8075_2", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5510, longitude=-46.6340), + sequence=1, + distance_traveled=0.0, + ) + ], + ) + + mock_shape3 = RouteShape( + route=route3, + shape_id="shape_1012_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5515, longitude=-46.6345), + sequence=1, + distance_traveled=0.0, + ) + ], + ) + + gtfs_repo.get_route_shape.side_effect = [mock_shape1, mock_shape2, mock_shape3] + + service = RouteService(bus_provider, gtfs_repo) + + # Act + result = service.get_route_shapes([route1, route2, route3]) + + # Assert + assert len(result) == 3 + assert result[0].route.bus_line == "8075" + assert result[0].route.bus_direction == 1 + assert result[1].route.bus_line == "8075" + assert result[1].route.bus_direction == 2 + assert result[2].route.bus_line == "1012" + assert result[2].route.bus_direction == 1 + + +def test_get_route_shapes_partial_results() -> None: + # Arrange + bus_provider = create_autospec(BusProviderPort, instance=True) + gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + + route1 = RouteIdentifier(bus_line="8075", bus_direction=1) + route2 = RouteIdentifier(bus_line="nonexistent", bus_direction=1) + route3 = RouteIdentifier(bus_line="1012", bus_direction=1) + + mock_shape1 = RouteShape( + route=route1, + shape_id="shape_8075_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5505, longitude=-46.6333), + sequence=1, + distance_traveled=0.0, + ) + ], + ) + + mock_shape3 = RouteShape( + route=route3, + shape_id="shape_1012_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5515, longitude=-46.6345), + sequence=1, + distance_traveled=0.0, + ) + ], + ) + + # Second route returns None (not found) + gtfs_repo.get_route_shape.side_effect = [mock_shape1, None, mock_shape3] + + service = RouteService(bus_provider, gtfs_repo) + + # Act + result = service.get_route_shapes([route1, route2, route3]) + + # Assert - should only return 2 shapes (excluding the not found one) + assert len(result) == 2 + assert result[0].route.bus_line == "8075" + assert result[1].route.bus_line == "1012" + + +def test_get_route_shapes_empty_list() -> None: + # Arrange + bus_provider = create_autospec(BusProviderPort, instance=True) + gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) + + service = RouteService(bus_provider, gtfs_repo) + + # Act + result = service.get_route_shapes([]) + + # Assert + assert result == [] + gtfs_repo.get_route_shape.assert_not_called() diff --git a/tests/integration/test_route.py b/tests/integration/test_route.py index c168893..adedbed 100644 --- a/tests/integration/test_route.py +++ b/tests/integration/test_route.py @@ -587,14 +587,16 @@ async def test_get_bus_position_without_auth_fails( ] ) - response = await client.post("/routes/positions", json=request_data.model_dump()) + response = await client.post( + "/routes/positions", json=request_data.model_dump() + ) assert response.status_code == 401 -class TestRouteShape: +class TestRouteShapes: @pytest.mark.asyncio - async def test_get_route_shape_returns_successfully( + async def test_get_route_shapes_returns_successfully( self, client: AsyncClient, ) -> None: @@ -605,24 +607,62 @@ async def test_get_route_shape_returns_successfully( } auth = await create_user_and_login(client, user_data) - response = await client.get("/routes/shape/1012-10", headers=auth["headers"]) + request_data = {"routes": [{"bus_line": "1012-10", "bus_direction": 1}]} + + response = await client.post( + "/routes/shapes", + json=request_data, + headers=auth["headers"], + ) assert response.status_code == 200 data = response.json() - assert data["route_id"] == "1012-10" - assert "shape_id" in data - assert "points" in data - assert len(data["points"]) > 0 + assert "shapes" in data + assert len(data["shapes"]) > 0 - first_point = data["points"][0] + first_shape = data["shapes"][0] + assert "route" in first_shape + assert first_shape["route"]["bus_line"] == "1012-10" + assert first_shape["route"]["bus_direction"] == 1 + assert "shape_id" in first_shape + assert "points" in first_shape + assert len(first_shape["points"]) > 0 + + first_point = first_shape["points"][0] assert "latitude" in first_point assert "longitude" in first_point assert isinstance(first_point["latitude"], float) assert isinstance(first_point["longitude"], float) @pytest.mark.asyncio - async def test_get_route_shape_returns_404_when_not_found( + async def test_get_route_shapes_returns_empty_when_not_found( + self, + client: AsyncClient, + ) -> None: + user_data = { + "name": "Test User", + "email": "test@example.com", + "password": "securepassword123", + } + auth = await create_user_and_login(client, user_data) + + request_data = { + "routes": [{"bus_line": "NONEXISTENT-ROUTE-12345", "bus_direction": 1}] + } + + response = await client.post( + "/routes/shapes", + json=request_data, + headers=auth["headers"], + ) + + assert response.status_code == 200 + data = response.json() + assert data["shapes"] == [] + + @pytest.mark.asyncio + async def test_get_route_shapes_multiple_routes( self, client: AsyncClient, ) -> None: @@ -633,16 +673,27 @@ async def test_get_route_shape_returns_404_when_not_found( } auth = await create_user_and_login(client, user_data) - response = await client.get( - "/routes/shape/NONEXISTENT-ROUTE-12345", + request_data = { + "routes": [ + {"bus_line": "1012-10", "bus_direction": 1}, + {"bus_line": "1012-10", "bus_direction": 2}, + ] + } + + response = await client.post( + "/routes/shapes", + json=request_data, headers=auth["headers"], ) - assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() + assert response.status_code == 200 + data = response.json() + + # Should return shapes for routes that exist + assert "shapes" in data @pytest.mark.asyncio - async def test_get_route_shape_points_have_valid_coordinates( + async def test_get_route_shapes_points_have_valid_coordinates( self, client: AsyncClient, ) -> None: @@ -653,20 +704,59 @@ async def test_get_route_shape_points_have_valid_coordinates( } auth = await create_user_and_login(client, user_data) - response = await client.get("/routes/shape/1012-10", headers=auth["headers"]) + request_data = {"routes": [{"bus_line": "1012-10", "bus_direction": 1}]} + + response = await client.post( + "/routes/shapes", + json=request_data, + headers=auth["headers"], + ) assert response.status_code == 200 - points = response.json()["points"] + data = response.json() - for point in points: - assert -25 <= point["latitude"] <= -22 - assert -48 <= point["longitude"] <= -45 + if data["shapes"]: + points = data["shapes"][0]["points"] + for point in points: + # São Paulo coordinates range + assert -25 <= point["latitude"] <= -22 + assert -48 <= point["longitude"] <= -45 @pytest.mark.asyncio - async def test_get_route_shape_without_auth_fails( + async def test_get_route_shapes_without_auth_fails( self, client: AsyncClient, ) -> None: - response = await client.get("/routes/shape/1012-10") + request_data = {"routes": [{"bus_line": "1012-10", "bus_direction": 1}]} + + response = await client.post("/routes/shapes", json=request_data) assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_get_route_shapes_default_direction( + self, + client: AsyncClient, + ) -> None: + user_data = { + "name": "Test User", + "email": "test@example.com", + "password": "securepassword123", + } + auth = await create_user_and_login(client, user_data) + + # Request without bus_direction (should default to 1) + request_data = {"routes": [{"bus_line": "1012-10"}]} + + response = await client.post( + "/routes/shapes", + json=request_data, + headers=auth["headers"], + ) + + assert response.status_code == 200 + data = response.json() + + if data["shapes"]: + # The returned shape should have direction 1 (default) + assert data["shapes"][0]["route"]["bus_direction"] == 1 diff --git a/tests/web/test_route_controller.py b/tests/web/test_route_controller.py index b6a9b52..c2da4b9 100644 --- a/tests/web/test_route_controller.py +++ b/tests/web/test_route_controller.py @@ -2,13 +2,14 @@ from collections.abc import Generator from datetime import UTC, datetime -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from fastapi.testclient import TestClient from src.core.models.bus import BusPosition, BusRoute, RouteIdentifier from src.core.models.coordinate import Coordinate +from src.core.models.route_shape import RouteShape, RouteShapePoint from src.core.models.user import User from src.core.services.route_service import RouteService from src.main import app @@ -31,6 +32,8 @@ def mock_service() -> RouteService: typed_service: RouteService = service typed_service.get_route_details = AsyncMock() # type: ignore[method-assign] typed_service.get_bus_positions = AsyncMock() # type: ignore[method-assign] + typed_service.get_route_shape = Mock() # type: ignore[method-assign] + typed_service.get_route_shapes = Mock() # type: ignore[method-assign] return typed_service @@ -208,3 +211,170 @@ async def test_positions_endpoint_error_returns_500( assert response.status_code == 500 body = response.json() assert "Failed to retrieve bus positions" in body["detail"] + + +# ========================= +# /routes/shapes +# ========================= + + +@pytest.mark.asyncio +async def test_shapes_endpoint_success(client: TestClient, mock_service: RouteService) -> None: + """ + Testa o endpoint POST /routes/shapes garantindo que: + - Ele chama RouteService.get_route_shapes() + - Ele retorna uma lista de shapes + """ + + # ----- Arrange ----- + route1 = RouteIdentifier(bus_line="8075", bus_direction=1) + route2 = RouteIdentifier(bus_line="8075", bus_direction=2) + + shape1 = RouteShape( + route=route1, + shape_id="shape_8075_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5505, longitude=-46.6333), + sequence=1, + distance_traveled=0.0, + ), + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5510, longitude=-46.6340), + sequence=2, + distance_traveled=10.5, + ), + ], + ) + + shape2 = RouteShape( + route=route2, + shape_id="shape_8075_2", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5515, longitude=-46.6345), + sequence=1, + distance_traveled=0.0, + ), + ], + ) + + mock_service.get_route_shapes.return_value = [shape1, shape2] # type: ignore[attr-defined] + + payload = { + "routes": [ + {"bus_line": "8075", "bus_direction": 1}, + {"bus_line": "8075", "bus_direction": 2}, + ] + } + + # ----- Act ----- + response = client.post("/routes/shapes", json=payload) + + # ----- Assert ----- + assert response.status_code == 200 + data = response.json() + + assert "shapes" in data + assert len(data["shapes"]) == 2 + + # First shape + assert data["shapes"][0]["route"]["bus_line"] == "8075" + assert data["shapes"][0]["route"]["bus_direction"] == 1 + assert data["shapes"][0]["shape_id"] == "shape_8075_1" + assert len(data["shapes"][0]["points"]) == 2 + + # Second shape + assert data["shapes"][1]["route"]["bus_line"] == "8075" + assert data["shapes"][1]["route"]["bus_direction"] == 2 + assert data["shapes"][1]["shape_id"] == "shape_8075_2" + assert len(data["shapes"][1]["points"]) == 1 + + # Verify service was called correctly + mock_service.get_route_shapes.assert_called_once() # type: ignore[attr-defined] + called_args = mock_service.get_route_shapes.call_args.args[0] # type: ignore[attr-defined] + assert len(called_args) == 2 + assert called_args[0].bus_line == "8075" + assert called_args[0].bus_direction == 1 + assert called_args[1].bus_line == "8075" + assert called_args[1].bus_direction == 2 + + +@pytest.mark.asyncio +async def test_shapes_endpoint_single_route( + client: TestClient, mock_service: RouteService +) -> None: + """ + Testa o endpoint POST /routes/shapes com uma única rota. + """ + + # ----- Arrange ----- + route = RouteIdentifier(bus_line="1012", bus_direction=1) + + shape = RouteShape( + route=route, + shape_id="shape_1012_1", + points=[ + RouteShapePoint( + coordinate=Coordinate(latitude=-23.5505, longitude=-46.6333), + sequence=1, + distance_traveled=0.0, + ), + ], + ) + + mock_service.get_route_shapes.return_value = [shape] # type: ignore[attr-defined] + + payload = {"routes": [{"bus_line": "1012", "bus_direction": 1}]} + + # ----- Act ----- + response = client.post("/routes/shapes", json=payload) + + # ----- Assert ----- + assert response.status_code == 200 + data = response.json() + + assert len(data["shapes"]) == 1 + assert data["shapes"][0]["route"]["bus_line"] == "1012" + assert data["shapes"][0]["route"]["bus_direction"] == 1 + + +@pytest.mark.asyncio +async def test_shapes_endpoint_empty_result( + client: TestClient, mock_service: RouteService +) -> None: + """ + Testa o endpoint POST /routes/shapes quando nenhuma rota é encontrada. + """ + + mock_service.get_route_shapes.return_value = [] # type: ignore[attr-defined] + + payload = {"routes": [{"bus_line": "nonexistent", "bus_direction": 1}]} + + # ----- Act ----- + response = client.post("/routes/shapes", json=payload) + + # ----- Assert ----- + assert response.status_code == 200 + data = response.json() + assert data["shapes"] == [] + + +@pytest.mark.asyncio +async def test_shapes_endpoint_error_returns_500( + client: TestClient, mock_service: RouteService +) -> None: + """ + Testa se o controller retorna 500 caso o service levante exception + em /routes/shapes. + """ + + mock_service.get_route_shapes.side_effect = RuntimeError("boom") # type: ignore[attr-defined] + + payload = {"routes": [{"bus_line": "8075", "bus_direction": 1}]} + + response = client.post("/routes/shapes", json=payload) + + assert response.status_code == 500 + body = response.json() + assert "Failed to retrieve route shapes" in body["detail"] From 08eac5b85d2f0300da5d2da87492cc7bfc8f1bf4 Mon Sep 17 00:00:00 2001 From: Kim Kakeya Date: Wed, 3 Dec 2025 22:44:17 -0300 Subject: [PATCH 2/5] chore: removing unnecessary method --- src/core/services/route_service.py | 12 ----------- tests/core/test_route_service.py | 33 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/core/services/route_service.py b/src/core/services/route_service.py index 7ea3b83..a469904 100644 --- a/src/core/services/route_service.py +++ b/src/core/services/route_service.py @@ -62,18 +62,6 @@ async def get_route_details(self, route: RouteIdentifier) -> list[BusRoute]: await self.bus_provider.authenticate() return await self.bus_provider.get_route_details(route) - def get_route_shape(self, route: RouteIdentifier) -> RouteShape | None: - """ - Get the geographic shape coordinates of a route from GTFS data. - - Args: - route: Route identifier with bus_line and direction - - Returns: - RouteShape with ordered coordinates, or None if route not found - """ - return self.gtfs_repository.get_route_shape(route) - def get_route_shapes(self, routes: list[RouteIdentifier]) -> list[RouteShape]: """ Get the geographic shape coordinates for multiple routes from GTFS data. diff --git a/tests/core/test_route_service.py b/tests/core/test_route_service.py index 3fdcd0b..99e63b0 100644 --- a/tests/core/test_route_service.py +++ b/tests/core/test_route_service.py @@ -187,14 +187,15 @@ def test_get_route_shape_found() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape(route) + result = service.get_route_shapes([route]) # Assert assert result is not None - assert result.route.bus_line == "1012-10" - assert result.route.bus_direction == 1 - assert result.shape_id == "84609" - assert len(result.points) == 2 + assert len(result) == 1 + assert result[0].route.bus_line == "1012-10" + assert result[0].route.bus_direction == 1 + assert result[0].shape_id == "84609" + assert len(result[0].points) == 2 gtfs_repo.get_route_shape.assert_called_once_with(route) @@ -210,10 +211,10 @@ def test_get_route_shape_not_found() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape(route) + result = service.get_route_shapes([route]) # Assert - assert result is None + assert result == [] gtfs_repo.get_route_shape.assert_called_once_with(route) @@ -243,13 +244,14 @@ def test_get_route_shape_with_many_points() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape(route) - + result = service.get_route_shapes([route]) + # Assert assert result is not None - assert len(result.points) == 100 - assert result.points[0].sequence == 1 - assert result.points[99].sequence == 100 + assert len(result) == 1 + assert len(result[0].points) == 100 + assert result[0].points[0].sequence == 1 + assert result[0].points[99].sequence == 100 gtfs_repo.get_route_shape.assert_called_once_with(route) @@ -277,11 +279,11 @@ def test_get_route_shape_with_special_characters() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape(route) + result = service.get_route_shapes([route]) # Assert assert result is not None - assert result.route.bus_line == "route-with-special_chars@123" + assert result[0].route.bus_line == "route-with-special_chars@123" gtfs_repo.get_route_shape.assert_called_once_with(route) @@ -309,10 +311,11 @@ def test_get_route_shape_independent_of_bus_provider() -> None: service = RouteService(bus_provider, gtfs_repo) # Act - result = service.get_route_shape(route) + result = service.get_route_shapes([route]) # Assert assert result is not None + assert len(result) == 1 # Verify bus_provider was not called at all bus_provider.authenticate.assert_not_called() bus_provider.get_bus_positions.assert_not_called() From 525ffe1166d247ead5289290d158bf410b706b2d Mon Sep 17 00:00:00 2001 From: Kim Kakeya Date: Wed, 3 Dec 2025 22:44:42 -0300 Subject: [PATCH 3/5] style: ruff --- src/core/services/route_service.py | 4 +--- src/web/controllers/route_controller.py | 10 +++----- src/web/mappers.py | 8 ++----- src/web/schemas.py | 4 +--- .../adapters/test_gtfs_repository_adapter.py | 20 ++++------------ tests/core/test_route_service.py | 24 ++++++------------- tests/integration/test_route.py | 8 ++----- tests/web/test_route_controller.py | 8 ++----- 8 files changed, 23 insertions(+), 63 deletions(-) diff --git a/src/core/services/route_service.py b/src/core/services/route_service.py index a469904..48858c1 100644 --- a/src/core/services/route_service.py +++ b/src/core/services/route_service.py @@ -14,9 +14,7 @@ class RouteService: real-time bus information and GTFS data for route shapes. """ - def __init__( - self, bus_provider: BusProviderPort, gtfs_repository: GTFSRepositoryPort - ): + def __init__(self, bus_provider: BusProviderPort, gtfs_repository: GTFSRepositoryPort): """ Initialize the route service. diff --git a/src/web/controllers/route_controller.py b/src/web/controllers/route_controller.py index 40d46f4..85eba5b 100644 --- a/src/web/controllers/route_controller.py +++ b/src/web/controllers/route_controller.py @@ -65,8 +65,7 @@ async def get_route_details_endpoint( try: # Schemas -> domínio (RouteIdentifier) route_identifiers: list[RouteIdentifier] = [ - map_route_identifier_schema_to_domain(route_schema) - for route_schema in request.routes + map_route_identifier_schema_to_domain(route_schema) for route_schema in request.routes ] bus_routes: list[BusRoute] = [] @@ -129,9 +128,7 @@ async def get_bus_positions( route=route_identifier, ) - route_positions: list[BusPosition] = await route_service.get_bus_positions( - bus_route - ) + route_positions: list[BusPosition] = await route_service.get_bus_positions(bus_route) all_positions.extend(route_positions) # Domínio -> schemas @@ -169,8 +166,7 @@ async def get_route_shapes( """ try: route_identifiers = [ - map_route_identifier_schema_to_domain(route_schema) - for route_schema in request.routes + map_route_identifier_schema_to_domain(route_schema) for route_schema in request.routes ] shapes = route_service.get_route_shapes(route_identifiers) diff --git a/src/web/mappers.py b/src/web/mappers.py index 38871e3..84f3af2 100644 --- a/src/web/mappers.py +++ b/src/web/mappers.py @@ -156,9 +156,7 @@ def map_route_shape_to_response(shape: RouteShape) -> RouteShapeResponse: return RouteShapeResponse( route=map_route_identifier_domain_to_schema(shape.route), shape_id=shape.shape_id, - points=[ - map_coordinate_domain_to_schema(point.coordinate) for point in shape.points - ], + points=[map_coordinate_domain_to_schema(point.coordinate) for point in shape.points], ) @@ -205,6 +203,4 @@ def map_history_entries_to_response(entries: list[HistoryEntry]) -> HistoryRespo Returns: HistoryResponse for API """ - return HistoryResponse( - trips=[map_history_entry_to_schema(entry) for entry in entries] - ) + return HistoryResponse(trips=[map_history_entry_to_schema(entry) for entry in entries]) diff --git a/src/web/schemas.py b/src/web/schemas.py index 25a6cfc..32b918b 100644 --- a/src/web/schemas.py +++ b/src/web/schemas.py @@ -140,9 +140,7 @@ class RouteShapeResponse(BaseModel): route: RouteIdentifierSchema = Field(..., description="Route identifier") shape_id: str = Field(..., description="GTFS shape identifier") - points: list[CoordinateSchema] = Field( - ..., description="Ordered list of coordinates" - ) + points: list[CoordinateSchema] = Field(..., description="Ordered list of coordinates") class RouteShapesRequest(BaseModel): diff --git a/tests/adapters/test_gtfs_repository_adapter.py b/tests/adapters/test_gtfs_repository_adapter.py index 31d5b30..70633d3 100644 --- a/tests/adapters/test_gtfs_repository_adapter.py +++ b/tests/adapters/test_gtfs_repository_adapter.py @@ -50,9 +50,7 @@ def test_get_route_shape_found() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] # Patch get_gtfs_db to return our mock connection - with patch( - "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" - ) as mock_get_db: + with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act @@ -91,9 +89,7 @@ def test_get_route_shape_route_not_found() -> None: mock_cursor.fetchone.return_value = None mock_conn.execute.return_value = mock_cursor - with patch( - "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" - ) as mock_get_db: + with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act @@ -120,9 +116,7 @@ def test_get_route_shape_no_shape_points() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch( - "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" - ) as mock_get_db: + with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act @@ -155,9 +149,7 @@ def test_get_route_shape_single_point() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch( - "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" - ) as mock_get_db: + with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act @@ -199,9 +191,7 @@ def test_get_route_shape_null_distance_traveled() -> None: mock_conn.execute.side_effect = [mock_cursor1, mock_cursor2] - with patch( - "src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db" - ) as mock_get_db: + with patch("src.adapters.repositories.gtfs_repository_adapter.get_gtfs_db") as mock_get_db: mock_get_db.return_value.__enter__.return_value = mock_conn # Act diff --git a/tests/core/test_route_service.py b/tests/core/test_route_service.py index 99e63b0..8b62587 100644 --- a/tests/core/test_route_service.py +++ b/tests/core/test_route_service.py @@ -45,9 +45,7 @@ async def test_get_bus_positions_calls_auth_and_provider() -> None: raw_provider.get_bus_positions.return_value = expected_positions gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService( - bus_provider=bus_provider, gtfs_repository=gtfs_repo - ) + service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) # Act result: list[BusPosition] = await service.get_bus_positions(bus_route) @@ -83,9 +81,7 @@ async def test_get_route_details_calls_auth_and_provider() -> None: raw_provider.get_route_details.return_value = expected_routes gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService( - bus_provider=bus_provider, gtfs_repository=gtfs_repo - ) + service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) # Act result: list[BusRoute] = await service.get_route_details(route_identifier) @@ -116,9 +112,7 @@ async def test_get_bus_positions_propagates_exception_from_provider() -> None: ) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService( - bus_provider=bus_provider, gtfs_repository=gtfs_repo - ) + service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) # Act / Assert with pytest.raises(RuntimeError, match="boom"): @@ -145,9 +139,7 @@ async def test_get_route_details_propagates_exception_from_authenticate() -> Non ) gtfs_repo = create_autospec(GTFSRepositoryPort, instance=True) - service: RouteService = RouteService( - bus_provider=bus_provider, gtfs_repository=gtfs_repo - ) + service: RouteService = RouteService(bus_provider=bus_provider, gtfs_repository=gtfs_repo) # Act / Assert with pytest.raises(RuntimeError, match="auth failed"): @@ -214,7 +206,7 @@ def test_get_route_shape_not_found() -> None: result = service.get_route_shapes([route]) # Assert - assert result == [] + assert result == [] gtfs_repo.get_route_shape.assert_called_once_with(route) @@ -228,9 +220,7 @@ def test_get_route_shape_with_many_points() -> None: # Create a shape with many points points = [ RouteShapePoint( - coordinate=Coordinate( - latitude=-23.5505 + i * 0.001, longitude=-46.6333 + i * 0.001 - ), + coordinate=Coordinate(latitude=-23.5505 + i * 0.001, longitude=-46.6333 + i * 0.001), sequence=i + 1, distance_traveled=float(i * 10), ) @@ -245,7 +235,7 @@ def test_get_route_shape_with_many_points() -> None: # Act result = service.get_route_shapes([route]) - + # Assert assert result is not None assert len(result) == 1 diff --git a/tests/integration/test_route.py b/tests/integration/test_route.py index adedbed..6b6a267 100644 --- a/tests/integration/test_route.py +++ b/tests/integration/test_route.py @@ -587,9 +587,7 @@ async def test_get_bus_position_without_auth_fails( ] ) - response = await client.post( - "/routes/positions", json=request_data.model_dump() - ) + response = await client.post("/routes/positions", json=request_data.model_dump()) assert response.status_code == 401 @@ -647,9 +645,7 @@ async def test_get_route_shapes_returns_empty_when_not_found( } auth = await create_user_and_login(client, user_data) - request_data = { - "routes": [{"bus_line": "NONEXISTENT-ROUTE-12345", "bus_direction": 1}] - } + request_data = {"routes": [{"bus_line": "NONEXISTENT-ROUTE-12345", "bus_direction": 1}]} response = await client.post( "/routes/shapes", diff --git a/tests/web/test_route_controller.py b/tests/web/test_route_controller.py index c2da4b9..31619bc 100644 --- a/tests/web/test_route_controller.py +++ b/tests/web/test_route_controller.py @@ -301,9 +301,7 @@ async def test_shapes_endpoint_success(client: TestClient, mock_service: RouteSe @pytest.mark.asyncio -async def test_shapes_endpoint_single_route( - client: TestClient, mock_service: RouteService -) -> None: +async def test_shapes_endpoint_single_route(client: TestClient, mock_service: RouteService) -> None: """ Testa o endpoint POST /routes/shapes com uma única rota. """ @@ -340,9 +338,7 @@ async def test_shapes_endpoint_single_route( @pytest.mark.asyncio -async def test_shapes_endpoint_empty_result( - client: TestClient, mock_service: RouteService -) -> None: +async def test_shapes_endpoint_empty_result(client: TestClient, mock_service: RouteService) -> None: """ Testa o endpoint POST /routes/shapes quando nenhuma rota é encontrada. """ From 2371f31063dd3f14174f2db66de81eaa4280bf8c Mon Sep 17 00:00:00 2001 From: Kim Kakeya Date: Wed, 3 Dec 2025 23:07:34 -0300 Subject: [PATCH 4/5] fix: adjust test_user_controller to match current test --- tests/web/test_user_controller.py | 74 +++++++------------------------ 1 file changed, 17 insertions(+), 57 deletions(-) diff --git a/tests/web/test_user_controller.py b/tests/web/test_user_controller.py index 5d33e07..d80a750 100644 --- a/tests/web/test_user_controller.py +++ b/tests/web/test_user_controller.py @@ -37,7 +37,7 @@ def test_create_user_success(client: TestClient, mock_user_service: MagicMock) - ) mock_user_service.create_user = AsyncMock(return_value=mock_user) - from src.web.controllers.user_controller import get_user_service + from src.web.auth import get_user_service app.dependency_overrides[get_user_service] = lambda: mock_user_service @@ -60,14 +60,16 @@ def test_create_user_success(client: TestClient, mock_user_service: MagicMock) - assert "password" not in data -def test_create_user_already_exists(client: TestClient, mock_user_service: MagicMock) -> None: +def test_create_user_already_exists( + client: TestClient, mock_user_service: MagicMock +) -> None: """Test user registration fails when email already exists.""" mock_user_service.create_user = AsyncMock( side_effect=ValueError("User with email john@example.com already exists") ) - from src.web.controllers.user_controller import get_user_service + from src.web.auth import get_user_service app.dependency_overrides[get_user_service] = lambda: mock_user_service @@ -97,7 +99,7 @@ def test_login_user_success(client: TestClient, mock_user_service: MagicMock) -> ) mock_user_service.login_user = AsyncMock(return_value=mock_user) - from src.web.controllers.user_controller import get_user_service + from src.web.auth import get_user_service app.dependency_overrides[get_user_service] = lambda: mock_user_service @@ -118,12 +120,14 @@ def test_login_user_success(client: TestClient, mock_user_service: MagicMock) -> assert isinstance(data["access_token"], str) -def test_login_user_invalid_credentials(client: TestClient, mock_user_service: MagicMock) -> None: +def test_login_user_invalid_credentials( + client: TestClient, mock_user_service: MagicMock +) -> None: """Test login fails with invalid credentials.""" mock_user_service.login_user = AsyncMock(return_value=None) - from src.web.controllers.user_controller import get_user_service + from src.web.auth import get_user_service app.dependency_overrides[get_user_service] = lambda: mock_user_service @@ -140,51 +144,9 @@ def test_login_user_invalid_credentials(client: TestClient, mock_user_service: M assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json()["detail"] == "Invalid email or password" - -def test_get_user_by_email_success(client: TestClient, mock_user_service: MagicMock) -> None: - """Test retrieving user information by email.""" - - mock_user = User( - name="Bob", - email="bob@example.com", - password="hashed_password", - score=50, - ) - mock_user_service.get_user = AsyncMock(return_value=mock_user) - - from src.web.controllers.user_controller import get_user_service - - app.dependency_overrides[get_user_service] = lambda: mock_user_service - - response = client.get("/users/bob@example.com") - - app.dependency_overrides.clear() - - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["name"] == "Bob" - assert data["email"] == "bob@example.com" - assert data["score"] == 50 - - -def test_get_user_not_found(client: TestClient, mock_user_service: MagicMock) -> None: - """Test retrieving non-existent user returns 404.""" - - mock_user_service.get_user = AsyncMock(return_value=None) - - from src.web.controllers.user_controller import get_user_service - - app.dependency_overrides[get_user_service] = lambda: mock_user_service - - response = client.get("/users/nonexistent@example.com") - - app.dependency_overrides.clear() - - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["detail"] == "User not found" - - -def test_get_current_user_info_success(client: TestClient, mock_user_service: MagicMock) -> None: +def test_get_current_user_info_success( + client: TestClient, mock_user_service: MagicMock +) -> None: """Test accessing /me endpoint with valid JWT token.""" mock_user = User( @@ -195,12 +157,12 @@ def test_get_current_user_info_success(client: TestClient, mock_user_service: Ma ) mock_user_service.get_user = AsyncMock(return_value=mock_user) - from src.web.controllers.user_controller import get_user_service + from src.web.auth import get_user_service app.dependency_overrides[get_user_service] = lambda: mock_user_service with patch( - "src.web.controllers.user_controller.verify_token", + "src.web.auth.verify_token", return_value={"sub": "charlie@example.com"}, ): response = client.get( @@ -231,10 +193,8 @@ def test_get_current_user_info_invalid_token( """Test accessing /me endpoint with invalid token returns 401.""" with ( - patch( - "src.web.controllers.user_controller.get_user_service", return_value=mock_user_service - ), - patch("src.web.controllers.user_controller.verify_token", return_value=None), + patch("src.web.auth.get_user_service", return_value=mock_user_service), + patch("src.web.auth.verify_token", return_value=None), ): response = client.get( "/users/me", From bd3aa0bbd9c5a6e6c9b7fdb702c02143d1de7001 Mon Sep 17 00:00:00 2001 From: Kim Kakeya Date: Wed, 3 Dec 2025 23:07:55 -0300 Subject: [PATCH 5/5] style: ruff --- tests/web/test_user_controller.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/web/test_user_controller.py b/tests/web/test_user_controller.py index d80a750..d914897 100644 --- a/tests/web/test_user_controller.py +++ b/tests/web/test_user_controller.py @@ -60,9 +60,7 @@ def test_create_user_success(client: TestClient, mock_user_service: MagicMock) - assert "password" not in data -def test_create_user_already_exists( - client: TestClient, mock_user_service: MagicMock -) -> None: +def test_create_user_already_exists(client: TestClient, mock_user_service: MagicMock) -> None: """Test user registration fails when email already exists.""" mock_user_service.create_user = AsyncMock( @@ -120,9 +118,7 @@ def test_login_user_success(client: TestClient, mock_user_service: MagicMock) -> assert isinstance(data["access_token"], str) -def test_login_user_invalid_credentials( - client: TestClient, mock_user_service: MagicMock -) -> None: +def test_login_user_invalid_credentials(client: TestClient, mock_user_service: MagicMock) -> None: """Test login fails with invalid credentials.""" mock_user_service.login_user = AsyncMock(return_value=None) @@ -144,9 +140,8 @@ def test_login_user_invalid_credentials( assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json()["detail"] == "Invalid email or password" -def test_get_current_user_info_success( - client: TestClient, mock_user_service: MagicMock -) -> None: + +def test_get_current_user_info_success(client: TestClient, mock_user_service: MagicMock) -> None: """Test accessing /me endpoint with valid JWT token.""" mock_user = User(