diff --git a/app/api/v1/endpoints/routes.py b/app/api/v1/endpoints/routes.py index d114447..8e14d4b 100644 --- a/app/api/v1/endpoints/routes.py +++ b/app/api/v1/endpoints/routes.py @@ -19,6 +19,7 @@ from app.services.ai_agents_service import ai_agents_service from app.services.auth_service import auth_service from app.services.global_preference_service import global_preference_service +from app.services.route_preference_service import route_preference_service from app.services.routing_service import ( RoutingAPIError, RoutingDataError, @@ -78,13 +79,28 @@ async def search_routes( # 2. Add stored global preferences from authenticated user try: - stored_prefs = global_preference_service.get_user_preferences( + global_prefs = global_preference_service.get_user_preferences( db, int(current_user.id) ) - for pref in stored_prefs: + for pref in global_prefs: user_preferences.append({"prompt": pref.prompt}) except Exception as e: # pylint: disable=broad-except - logger.warning("Failed to fetch user preferences: %s", str(e)) + logger.warning("Failed to fetch global preferences: %s", str(e)) + + # 3. Add route-specific preferences that match the coordinates + try: + route_prefs = route_preference_service.get_preferences_by_coordinates( + db, + int(current_user.id), + request.origin.latitude, + request.origin.longitude, + request.destination.latitude, + request.destination.longitude, + ) + for pref in route_prefs: + user_preferences.append({"prompt": pref.prompt}) + except Exception as e: # pylint: disable=broad-except + logger.warning("Failed to fetch route-specific preferences: %s", str(e)) logger.info( "Using %d user preferences for route insights", len(user_preferences) diff --git a/app/services/route_preference_service.py b/app/services/route_preference_service.py index b736f2d..ff28c31 100644 --- a/app/services/route_preference_service.py +++ b/app/services/route_preference_service.py @@ -34,6 +34,41 @@ def get_user_preferences( .all() ) + @staticmethod + def get_preferences_by_coordinates( + db: Session, + user_id: int, + from_latitude: float, + from_longitude: float, + to_latitude: float, + to_longitude: float, + ) -> List[RoutePreference]: + """ + Get route preferences for a user that match specific coordinates. + + Args: + db: Database session + user_id: User ID to get preferences for + from_latitude: Origin latitude + from_longitude: Origin longitude + to_latitude: Destination latitude + to_longitude: Destination longitude + + Returns: + List of RoutePreference objects matching the coordinates + """ + return ( + db.query(RoutePreference) + .filter( + RoutePreference.user_id == user_id, + RoutePreference.from_latitude == from_latitude, + RoutePreference.from_longitude == from_longitude, + RoutePreference.to_latitude == to_latitude, + RoutePreference.to_longitude == to_longitude, + ) + .all() + ) + @staticmethod def get_preference_by_id( db: Session, preference_id: int diff --git a/tests/endpoints/test_routes_with_preferences.py b/tests/endpoints/test_routes_with_preferences.py index 420856d..25c670a 100644 --- a/tests/endpoints/test_routes_with_preferences.py +++ b/tests/endpoints/test_routes_with_preferences.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app.models.global_preference import GlobalPreference +from app.models.route_preference import RoutePreference from app.models.user import User from app.schemas.geo import Coordinates from app.schemas.itinary import Itinerary, Leg, Route, TransportMode @@ -378,3 +379,421 @@ def mock_get_itineraries_with_insights( assert ( captured_preferences[0] is None or captured_preferences[0] == [] ) + + +def test_search_routes_with_route_specific_preferences( + db: Session, client: TestClient, sample_itineraries +): + """Test route search with route-specific preferences matching coordinates.""" + user = create_test_user(db) + headers = get_auth_header(user.id) + + with patch( + "app.api.v1.endpoints.routes.routing_service" + ) as mock_routing_service: + with patch( + "app.api.v1.endpoints.routes.ai_agents_service" + ) as mock_ai_service: + with patch( + "app.api.v1.endpoints.routes.route_preference_service" + ) as mock_route_pref_service: + mock_routing_service.get_itinaries = AsyncMock( + return_value=sample_itineraries + ) + + # Mock route-specific preferences matching the coordinates + route_prefs = [ + RoutePreference( + user_id=1, + prompt="Prefer scenic routes in this area", + from_latitude=60.1699, + from_longitude=24.9384, + to_latitude=60.2055, + to_longitude=24.6559, + ), + ] + mock_route_pref_service.get_preferences_by_coordinates = ( + MagicMock(return_value=route_prefs) + ) + + # Track the call to verify preferences are passed + captured_preferences = [] + + def mock_get_itineraries_with_insights( + itineraries, user_preferences=None + ): + captured_preferences.append(user_preferences) + + mock_ai_service.get_itineraries_with_insights = AsyncMock( + side_effect=mock_get_itineraries_with_insights + ) + + response = client.post( + "/api/v1/routes/search", + json={ + "origin": {"latitude": 60.1699, "longitude": 24.9384}, + "destination": { + "latitude": 60.2055, + "longitude": 24.6559, + }, + }, + headers=headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["itineraries"]) == 1 + + # Verify route preference service was called with correct coordinates + mock_route_pref_service.get_preferences_by_coordinates.assert_called_once_with( + db, user.id, 60.1699, 24.9384, 60.2055, 24.6559 + ) + + # Verify AI service was called with route-specific preferences + assert len(captured_preferences) == 1 + assert { + "prompt": "Prefer scenic routes in this area" + } in captured_preferences[0] + + +def test_search_routes_with_global_and_route_specific_preferences( + db: Session, client: TestClient, sample_itineraries +): + """Test route search combining global and route-specific preferences.""" + user = create_test_user(db) + headers = get_auth_header(user.id) + + with patch( + "app.api.v1.endpoints.routes.routing_service" + ) as mock_routing_service: + with patch( + "app.api.v1.endpoints.routes.ai_agents_service" + ) as mock_ai_service: + with patch( + "app.api.v1.endpoints.routes.global_preference_service" + ) as mock_global_pref_service: + with patch( + "app.api.v1.endpoints.routes.route_preference_service" + ) as mock_route_pref_service: + mock_routing_service.get_itinaries = AsyncMock( + return_value=sample_itineraries + ) + + # Mock global preferences + global_prefs = [ + GlobalPreference( + user_id=1, prompt="I prefer eco-friendly routes" + ), + ] + mock_global_pref_service.get_user_preferences = MagicMock( + return_value=global_prefs + ) + + # Mock route-specific preferences + route_prefs = [ + RoutePreference( + user_id=1, + prompt="Avoid construction on this route", + from_latitude=60.1699, + from_longitude=24.9384, + to_latitude=60.2055, + to_longitude=24.6559, + ), + ] + mock_route_pref_service.get_preferences_by_coordinates = ( + MagicMock(return_value=route_prefs) + ) + + # Track the call to verify preferences are passed + captured_preferences = [] + + def mock_get_itineraries_with_insights( + itineraries, user_preferences=None + ): + captured_preferences.append(user_preferences) + + mock_ai_service.get_itineraries_with_insights = AsyncMock( + side_effect=mock_get_itineraries_with_insights + ) + + response = client.post( + "/api/v1/routes/search", + json={ + "origin": { + "latitude": 60.1699, + "longitude": 24.9384, + }, + "destination": { + "latitude": 60.2055, + "longitude": 24.6559, + }, + }, + headers=headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["itineraries"]) == 1 + + # Verify AI service was called with both types of preferences + assert len(captured_preferences) == 1 + # Should include both global and route-specific preferences + assert {"prompt": "I prefer eco-friendly routes"} in ( + captured_preferences[0] + ) + assert { + "prompt": "Avoid construction on this route" + } in captured_preferences[0] + assert len(captured_preferences[0]) == 2 + + +def test_search_routes_with_all_preference_types( + db: Session, client: TestClient, sample_itineraries +): + """Test route search combining request, global, and route-specific preferences.""" + user = create_test_user(db) + headers = get_auth_header(user.id) + + with patch( + "app.api.v1.endpoints.routes.routing_service" + ) as mock_routing_service: + with patch( + "app.api.v1.endpoints.routes.ai_agents_service" + ) as mock_ai_service: + with patch( + "app.api.v1.endpoints.routes.global_preference_service" + ) as mock_global_pref_service: + with patch( + "app.api.v1.endpoints.routes.route_preference_service" + ) as mock_route_pref_service: + mock_routing_service.get_itinaries = AsyncMock( + return_value=sample_itineraries + ) + + # Mock global preferences + global_prefs = [ + GlobalPreference( + user_id=1, prompt="I prefer eco-friendly routes" + ), + ] + mock_global_pref_service.get_user_preferences = MagicMock( + return_value=global_prefs + ) + + # Mock route-specific preferences + route_prefs = [ + RoutePreference( + user_id=1, + prompt="Avoid construction on this route", + from_latitude=60.1699, + from_longitude=24.9384, + to_latitude=60.2055, + to_longitude=24.6559, + ), + ] + mock_route_pref_service.get_preferences_by_coordinates = ( + MagicMock(return_value=route_prefs) + ) + + # Track the call to verify preferences are passed + captured_preferences = [] + + def mock_get_itineraries_with_insights( + itineraries, user_preferences=None + ): + captured_preferences.append(user_preferences) + + mock_ai_service.get_itineraries_with_insights = AsyncMock( + side_effect=mock_get_itineraries_with_insights + ) + + response = client.post( + "/api/v1/routes/search", + json={ + "origin": { + "latitude": 60.1699, + "longitude": 24.9384, + }, + "destination": { + "latitude": 60.2055, + "longitude": 24.6559, + }, + "preferences": ["prefer faster routes"], + }, + headers=headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["itineraries"]) == 1 + + # Verify AI service was called with all three types of preferences + assert len(captured_preferences) == 1 + # Should include request, global, and route-specific preferences + assert {"prompt": "prefer faster routes"} in ( + captured_preferences[0] + ) + assert {"prompt": "I prefer eco-friendly routes"} in ( + captured_preferences[0] + ) + assert { + "prompt": "Avoid construction on this route" + } in captured_preferences[0] + assert len(captured_preferences[0]) == 3 + + +def test_search_routes_with_no_matching_route_preferences( + db: Session, client: TestClient, sample_itineraries +): + """Test route search when no route preferences match the coordinates.""" + user = create_test_user(db) + headers = get_auth_header(user.id) + + with patch( + "app.api.v1.endpoints.routes.routing_service" + ) as mock_routing_service: + with patch( + "app.api.v1.endpoints.routes.ai_agents_service" + ) as mock_ai_service: + with patch( + "app.api.v1.endpoints.routes.global_preference_service" + ) as mock_global_pref_service: + with patch( + "app.api.v1.endpoints.routes.route_preference_service" + ) as mock_route_pref_service: + mock_routing_service.get_itinaries = AsyncMock( + return_value=sample_itineraries + ) + + # Mock global preferences + global_prefs = [ + GlobalPreference( + user_id=1, prompt="I prefer eco-friendly routes" + ), + ] + mock_global_pref_service.get_user_preferences = MagicMock( + return_value=global_prefs + ) + + # No route-specific preferences matching coordinates + mock_route_pref_service.get_preferences_by_coordinates = ( + MagicMock(return_value=[]) + ) + + # Track the call to verify preferences are passed + captured_preferences = [] + + def mock_get_itineraries_with_insights( + itineraries, user_preferences=None + ): + captured_preferences.append(user_preferences) + + mock_ai_service.get_itineraries_with_insights = AsyncMock( + side_effect=mock_get_itineraries_with_insights + ) + + response = client.post( + "/api/v1/routes/search", + json={ + "origin": { + "latitude": 60.1699, + "longitude": 24.9384, + }, + "destination": { + "latitude": 60.2055, + "longitude": 24.6559, + }, + }, + headers=headers, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["itineraries"]) == 1 + + # Verify AI service was called with only global preferences + assert len(captured_preferences) == 1 + # Should only include global preferences (no route-specific) + assert {"prompt": "I prefer eco-friendly routes"} in ( + captured_preferences[0] + ) + assert len(captured_preferences[0]) == 1 + + +def test_search_routes_route_preference_service_failure( + db: Session, client: TestClient, sample_itineraries +): + """Test graceful degradation when route preference service fails.""" + user = create_test_user(db) + headers = get_auth_header(user.id) + + with patch( + "app.api.v1.endpoints.routes.routing_service" + ) as mock_routing_service: + with patch( + "app.api.v1.endpoints.routes.ai_agents_service" + ) as mock_ai_service: + with patch( + "app.api.v1.endpoints.routes.global_preference_service" + ) as mock_global_pref_service: + with patch( + "app.api.v1.endpoints.routes.route_preference_service" + ) as mock_route_pref_service: + mock_routing_service.get_itinaries = AsyncMock( + return_value=sample_itineraries + ) + + # Mock global preferences + global_prefs = [ + GlobalPreference( + user_id=1, prompt="I prefer eco-friendly routes" + ), + ] + mock_global_pref_service.get_user_preferences = MagicMock( + return_value=global_prefs + ) + + # Route preference service fails + mock_route_pref_service.get_preferences_by_coordinates = ( + MagicMock(side_effect=Exception("Database error")) + ) + + # Track the call to verify preferences are passed + captured_preferences = [] + + def mock_get_itineraries_with_insights( + itineraries, user_preferences=None + ): + captured_preferences.append(user_preferences) + + mock_ai_service.get_itineraries_with_insights = AsyncMock( + side_effect=mock_get_itineraries_with_insights + ) + + response = client.post( + "/api/v1/routes/search", + json={ + "origin": { + "latitude": 60.1699, + "longitude": 24.9384, + }, + "destination": { + "latitude": 60.2055, + "longitude": 24.6559, + }, + }, + headers=headers, + ) + + # Should still succeed despite route preference service failure + assert response.status_code == 200 + data = response.json() + assert len(data["itineraries"]) == 1 + + # Verify AI service was called with only global preferences + assert len(captured_preferences) == 1 + # Should still include global preferences + assert {"prompt": "I prefer eco-friendly routes"} in ( + captured_preferences[0] + ) + assert len(captured_preferences[0]) == 1 diff --git a/tests/services/test_route_preference_service.py b/tests/services/test_route_preference_service.py index bcb90f0..af5b402 100644 --- a/tests/services/test_route_preference_service.py +++ b/tests/services/test_route_preference_service.py @@ -265,3 +265,120 @@ def test_multiple_users_preferences_isolated(db: Session): user2_prefs = route_preference_service.get_user_preferences(db, user2.id) assert len(user2_prefs) == 1 assert all(pref.user_id == user2.id for pref in user2_prefs) + + +def test_get_preferences_by_coordinates_matching(db: Session): + """Test getting preferences by matching coordinates""" + user = create_test_user(db) + + # Create preferences with different coordinates + pref1 = create_test_route_preference( + db, user.id, "Prefer scenic route", 60.1699, 24.9384, 60.2055, 24.6559 + ) + create_test_route_preference( + db, user.id, "Different route", 60.1700, 24.9400, 60.2060, 24.6600 + ) + + # Search for preferences matching specific coordinates + matching_prefs = route_preference_service.get_preferences_by_coordinates( + db, user.id, 60.1699, 24.9384, 60.2055, 24.6559 + ) + + assert len(matching_prefs) == 1 + assert matching_prefs[0].id == pref1.id + assert matching_prefs[0].prompt == "Prefer scenic route" + + +def test_get_preferences_by_coordinates_no_match(db: Session): + """Test getting preferences when coordinates don't match""" + user = create_test_user(db) + + # Create preference with specific coordinates + create_test_route_preference( + db, user.id, "Prefer scenic route", 60.1699, 24.9384, 60.2055, 24.6559 + ) + + # Search with different coordinates + matching_prefs = route_preference_service.get_preferences_by_coordinates( + db, user.id, 60.1700, 24.9400, 60.2060, 24.6600 + ) + + assert len(matching_prefs) == 0 + + +def test_get_preferences_by_coordinates_multiple_matches(db: Session): + """Test getting multiple preferences matching the same coordinates""" + user = create_test_user(db) + + # Create multiple preferences with same coordinates + pref1 = create_test_route_preference( + db, user.id, "Prefer scenic route", 60.1699, 24.9384, 60.2055, 24.6559 + ) + pref2 = create_test_route_preference( + db, user.id, "Avoid construction", 60.1699, 24.9384, 60.2055, 24.6559 + ) + + # Search for preferences matching coordinates + matching_prefs = route_preference_service.get_preferences_by_coordinates( + db, user.id, 60.1699, 24.9384, 60.2055, 24.6559 + ) + + assert len(matching_prefs) == 2 + assert matching_prefs[0].id == pref1.id + assert matching_prefs[1].id == pref2.id + + +def test_get_preferences_by_coordinates_user_isolation(db: Session): + """Test that coordinate search is isolated per user""" + user1 = create_test_user(db) + user2 = User( + username="otheruser", + hashed_password=auth_service.get_password_hash("password"), + ) + db.add(user2) + db.commit() + db.refresh(user2) + + # Create preferences for both users with same coordinates + create_test_route_preference( + db, user1.id, "User1 preference", 60.1699, 24.9384, 60.2055, 24.6559 + ) + create_test_route_preference( + db, user2.id, "User2 preference", 60.1699, 24.9384, 60.2055, 24.6559 + ) + + # User1 should only see their preference + user1_prefs = route_preference_service.get_preferences_by_coordinates( + db, user1.id, 60.1699, 24.9384, 60.2055, 24.6559 + ) + assert len(user1_prefs) == 1 + assert user1_prefs[0].prompt == "User1 preference" + + # User2 should only see their preference + user2_prefs = route_preference_service.get_preferences_by_coordinates( + db, user2.id, 60.1699, 24.9384, 60.2055, 24.6559 + ) + assert len(user2_prefs) == 1 + assert user2_prefs[0].prompt == "User2 preference" + + +def test_get_preferences_by_coordinates_partial_match(db: Session): + """Test that partial coordinate matches don't return results""" + user = create_test_user(db) + + # Create preference with specific coordinates + create_test_route_preference( + db, user.id, "Prefer scenic route", 60.1699, 24.9384, 60.2055, 24.6559 + ) + + # Try matching with only origin coordinates correct + matching_prefs = route_preference_service.get_preferences_by_coordinates( + db, user.id, 60.1699, 24.9384, 60.9999, 24.9999 + ) + assert len(matching_prefs) == 0 + + # Try matching with only destination coordinates correct + matching_prefs = route_preference_service.get_preferences_by_coordinates( + db, user.id, 60.9999, 24.9999, 60.2055, 24.6559 + ) + assert len(matching_prefs) == 0