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..48858c1 100644 --- a/src/core/services/route_service.py +++ b/src/core/services/route_service.py @@ -60,14 +60,19 @@ 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_shapes(self, routes: list[RouteIdentifier]) -> list[RouteShape]: """ - Get the geographic shape coordinates of a route from GTFS data. + Get the geographic shape coordinates for multiple routes from GTFS data. Args: - route_id: Route identifier (e.g., "1012-10") + routes: List of route identifiers with bus_line and direction Returns: - RouteShape with ordered coordinates, or None if route not found + List of RouteShapes with ordered coordinates (excludes routes not found) """ - return self.gtfs_repository.get_route_shape(route_id) + 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..85eba5b 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"]) @@ -144,40 +145,37 @@ 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..84f3af2 100644 --- a/src/web/mappers.py +++ b/src/web/mappers.py @@ -154,12 +154,25 @@ 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], ) +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 ===== diff --git a/src/web/schemas.py b/src/web/schemas.py index 6c11c25..32b918b 100644 --- a/src/web/schemas.py +++ b/src/web/schemas.py @@ -138,11 +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") +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..70633d3 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() @@ -53,12 +54,13 @@ def test_get_route_shape_found() -> None: 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 +78,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() @@ -91,16 +93,16 @@ def test_get_route_shape_route_not_found() -> None: 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() @@ -118,7 +120,7 @@ def test_get_route_shape_no_shape_points() -> None: 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 +130,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() @@ -150,7 +153,7 @@ def test_get_route_shape_single_point() -> None: 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 +166,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() @@ -191,7 +195,7 @@ def test_get_route_shape_null_distance_traveled() -> None: 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..8b62587 100644 --- a/tests/core/test_route_service.py +++ b/tests/core/test_route_service.py @@ -154,9 +154,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 +179,16 @@ 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_shapes([route]) # Assert assert result is not None - assert result.route_id == "1012-10" - assert result.shape_id == "84609" - assert len(result.points) == 2 - gtfs_repo.get_route_shape.assert_called_once_with("1012-10") + 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) def test_get_route_shape_not_found() -> None: @@ -192,16 +196,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_shapes([route]) # Assert - assert result is None - gtfs_repo.get_route_shape.assert_called_once_with("nonexistent-route") + assert result == [] + gtfs_repo.get_route_shape.assert_called_once_with(route) def test_get_route_shape_with_many_points() -> None: @@ -209,6 +215,8 @@ 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( @@ -219,21 +227,22 @@ def test_get_route_shape_with_many_points() -> None: 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_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 - gtfs_repo.get_route_shape.assert_called_once_with("long-route") + 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) def test_get_route_shape_with_special_characters() -> None: @@ -241,8 +250,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 +269,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_shapes([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[0].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 +282,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,11 +301,136 @@ 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_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() 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..6b6a267 100644 --- a/tests/integration/test_route.py +++ b/tests/integration/test_route.py @@ -592,9 +592,9 @@ async def test_get_bus_position_without_auth_fails( 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 +605,60 @@ 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_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 = data["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 +669,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 +700,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..31619bc 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,166 @@ 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"] diff --git a/tests/web/test_user_controller.py b/tests/web/test_user_controller.py index 5d33e07..d914897 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 @@ -67,7 +67,7 @@ def test_create_user_already_exists(client: TestClient, mock_user_service: Magic 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 +97,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 @@ -123,7 +123,7 @@ def test_login_user_invalid_credentials(client: TestClient, mock_user_service: M 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 @@ -141,49 +141,6 @@ def test_login_user_invalid_credentials(client: TestClient, mock_user_service: M 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: """Test accessing /me endpoint with valid JWT token.""" @@ -195,12 +152,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 +188,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",