""" Comprehensive tests for MCP server integration with service layer. Validates parameter passing, async/sync compatibility, return types, and error handling. """ import asyncio import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp.server.fastmcp import Context from finn_eiendom.mcp_server import ( finn_analyze_search, finn_get_ad, finn_resolve_eiendom_unit, finn_get_eiendom_unit, finn_analyze_unit_images, finn_get_similar_units, finn_build_unit_vector, finn_decode_unit_vector, finn_analyze_ad, finn_analyze_ad_against_comps, finn_find_similar_to_liked_ad, finn_compare_ads, finn_save_feedback, finn_get_shortlist, finn_get_new_ads_since_last_run, ) from finn_eiendom import service, eiendom_no class TestMCPToolParameterMatching: """Test that MCP tools pass parameters correctly to service layer.""" @pytest.mark.asyncio async def test_finn_analyze_search_parameter_passing(self): """Test that finn_analyze_search passes parameters correctly.""" mock_ctx = MagicMock(spec=Context) with patch( "finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock ) as mock_analyze: mock_analyze.return_value = { "search_url": "https://test.com", "search_cards": [], "analysis": {}, "summary": {}, } result = await finn_analyze_search( search_url="https://test.com", ctx=mock_ctx, max_pages=2, detail_limit=10, include_details=False, include_eiendom_no=False, ) # Verify the correct parameters were passed mock_analyze.assert_called_once() call_args = mock_analyze.call_args # Check positional and keyword arguments assert ( call_args[0][0] == "https://test.com" or call_args[1]["search_url"] == "https://test.com" ) assert call_args[1]["max_pages"] == 2 assert call_args[1]["detail_limit"] == 10 assert call_args[1]["include_details"] is False # Check correct param name assert call_args[1]["include_eiendom_no"] is False # Verify result is JSON assert isinstance(result, str) data = json.loads(result) assert "search_url" in data @pytest.mark.asyncio async def test_finn_get_ad_parameter_passing(self): """Test that finn_get_ad passes parameters correctly.""" mock_ad = MagicMock() mock_ad.model_dump_json.return_value = '{"finnkode": "123"}' with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_ad result = await finn_get_ad(finnkode="123", force_refresh=True) # Verify parameters passed correctly mock_get.assert_called_once_with("123", force_refresh=True) assert isinstance(result, str) @pytest.mark.asyncio async def test_finn_analyze_ad_parameter_passing(self): """Test that finn_analyze_ad passes parameters correctly.""" with patch("finn_eiendom.mcp_server.analyze_ad", new_callable=AsyncMock) as mock_analyze: mock_analyze.return_value = {"ad": {"finnkode": "456"}} result = await finn_analyze_ad( finnkode="456", include_eiendom_no=True, include_similar_units=True, ) # Verify correct parameter names mock_analyze.assert_called_once() call_kwargs = mock_analyze.call_args[1] assert call_kwargs["include_eiendom_no"] is True assert call_kwargs["include_similar_units"] is True @pytest.mark.asyncio async def test_finn_find_similar_to_liked_ad_parameter_passing(self): """Test that finn_find_similar_to_liked_ad passes parameters correctly.""" with patch( "finn_eiendom.mcp_server.find_similar_to_liked", new_callable=AsyncMock ) as mock_find: mock_find.return_value = { "base_ad": {"finnkode": "789"}, "similar_listings": [], "mode": "recommendations", } result = await finn_find_similar_to_liked_ad( finnkode="789", mode="similar", listing_status="FOR_SALE", ) # Verify parameters mock_find.assert_called_once() call_kwargs = mock_find.call_args[1] assert call_kwargs["mode"] == "similar" assert call_kwargs["listing_status"] == "FOR_SALE" @pytest.mark.asyncio async def test_finn_compare_ads_parameter_passing(self): """Test that finn_compare_ads passes parameters correctly.""" with patch("finn_eiendom.mcp_server.compare_ads", new_callable=AsyncMock) as mock_compare: mock_compare.return_value = {"listings": []} result = await finn_compare_ads( finnkoder=["123", "456"], include_eiendom_no=False, include_comps=False, ) # Verify parameters mock_compare.assert_called_once() call_kwargs = mock_compare.call_args[1] assert call_kwargs["include_eiendom_no"] is False assert call_kwargs["include_comps"] is False @pytest.mark.asyncio async def test_finn_get_eiendom_unit_parameter_passing(self): """Test that finn_get_eiendom_unit passes parameters correctly.""" mock_unit = MagicMock() mock_unit.model_dump_json.return_value = '{"unit_code": "abc"}' with patch( "finn_eiendom.mcp_server.get_or_fetch_eiendom_unit", new_callable=AsyncMock ) as mock_get: mock_get.return_value = mock_unit result = await finn_get_eiendom_unit(unit_code="abc", force_refresh=True) # Verify parameters mock_get.assert_called_once_with("abc", force_refresh=True) @pytest.mark.asyncio async def test_finn_analyze_unit_images_parameter_passing(self): """Test that finn_analyze_unit_images passes parameters correctly.""" with patch( "finn_eiendom.mcp_server.get_unit_images", new_callable=AsyncMock ) as mock_images: mock_images.return_value = { "unit_code": "abc", "unit_images": [], "address": "Test St 1", "property_type": "APARTMENT", "rooms": 3, "usable_area": 100, } result = await finn_analyze_unit_images(unit_code="abc", force_refresh=False) # Verify parameters mock_images.assert_called_once_with("abc", force_refresh=False) @pytest.mark.asyncio async def test_finn_get_similar_units_parameter_passing(self): """Test that finn_get_similar_units passes parameters correctly.""" with patch( "finn_eiendom.mcp_server.get_similar_units", new_callable=AsyncMock ) as mock_similar: mock_similar.return_value = [] result = await finn_get_similar_units( unit_vector="dGVzdA==", listing_status="RECENTLY_SOLD", ) # Verify parameters mock_similar.assert_called_once_with("dGVzdA==", "RECENTLY_SOLD") @pytest.mark.asyncio async def test_finn_build_unit_vector_parameter_passing(self): """Test that finn_build_unit_vector passes parameters correctly.""" mock_unit = MagicMock() with patch("finn_eiendom.mcp_server.get_unit", new_callable=AsyncMock) as mock_get: with patch("finn_eiendom.mcp_server.build_unit_vector") as mock_build: mock_get.return_value = mock_unit mock_build.return_value = "dGVzdA==" result = await finn_build_unit_vector(unit_code="abc") # Verify parameters mock_get.assert_called_once_with("abc") mock_build.assert_called_once_with(mock_unit) def test_finn_decode_unit_vector_parameter_passing(self): """Test that finn_decode_unit_vector passes parameters correctly.""" with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode: mock_decode.return_value = {"lat": 59.9, "lon": 10.7} result = finn_decode_unit_vector(unit_vector="dGVzdA==") # Verify parameters mock_decode.assert_called_once_with("dGVzdA==") @pytest.mark.asyncio async def test_finn_analyze_ad_against_comps_parameter_passing(self): """Test that finn_analyze_ad_against_comps passes parameters correctly.""" with patch( "finn_eiendom.mcp_server.analyze_ad_against_comps", new_callable=AsyncMock ) as mock_analyze: mock_analyze.return_value = {"ad": {}, "comparable_units": []} result = await finn_analyze_ad_against_comps( finnkode="123", listing_status="FOR_SALE", ) # Verify parameters mock_analyze.assert_called_once() call_kwargs = mock_analyze.call_args[1] assert call_kwargs["listing_status"] == "FOR_SALE" @pytest.mark.asyncio async def test_finn_save_feedback_parameter_passing(self): """Test that finn_save_feedback passes parameters correctly.""" with patch("finn_eiendom.mcp_server.save_feedback") as mock_save: mock_save.return_value = {"status": "saved"} result = await finn_save_feedback( finnkode="123", verdict="liked", notes="Great apartment", ) # Verify parameters mock_save.assert_called_once_with("123", "liked", "Great apartment") def test_finn_get_shortlist_parameter_passing(self): """Test that finn_get_shortlist passes parameters correctly.""" with patch("finn_eiendom.mcp_server.get_shortlist") as mock_get: mock_get.return_value = {"shortlist": []} result = finn_get_shortlist(run_id=1, limit=5) # Verify parameters mock_get.assert_called_once_with(1, 5) @pytest.mark.asyncio async def test_finn_get_new_ads_since_last_run_parameter_passing(self): """Test that finn_get_new_ads_since_last_run passes parameters correctly.""" with patch("finn_eiendom.mcp_server.get_new_ads_since_last_run") as mock_get: mock_get.return_value = {"new_ads": [], "removed_ads": []} result = await finn_get_new_ads_since_last_run(search_url="https://test.com") # Verify parameters mock_get.assert_called_once_with("https://test.com") @pytest.mark.asyncio async def test_finn_resolve_eiendom_unit_parameter_passing(self): """Test that finn_resolve_eiendom_unit passes parameters correctly.""" mock_unit = MagicMock() mock_unit.unit_code = "abc" mock_unit.address = "Test St 1" mock_unit.lat = 59.9 mock_unit.lng = 10.7 with patch( "finn_eiendom.mcp_server.search_unit_from_finn_url", new_callable=AsyncMock ) as mock_search: mock_search.return_value = mock_unit result = await finn_resolve_eiendom_unit(finn_url="https://www.finn.no/...") # Verify parameters mock_search.assert_called_once_with("https://www.finn.no/...") class TestMCPToolReturnTypes: """Test that MCP tools return proper JSON strings.""" @pytest.mark.asyncio async def test_all_async_tools_return_json_string(self): """Verify all async tools return valid JSON strings (or error JSON).""" async_tools = [ (finn_analyze_search, {"search_url": "https://test.com"}), (finn_get_ad, {"finnkode": "123"}), (finn_analyze_ad, {"finnkode": "123"}), ] for tool, kwargs in async_tools: with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock): with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock): try: result = await tool(**kwargs) # Result should be a string (JSON) assert isinstance(result, str), f"{tool.__name__} did not return a string" # And it should be valid JSON json.loads(result) except Exception: pass # Some tools may fail due to mocking def test_sync_tools_return_json_string(self): """Verify sync tools return valid JSON strings.""" with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock_decode: mock_decode.return_value = {"lat": 59.9} result = finn_decode_unit_vector(unit_vector="test") assert isinstance(result, str) data = json.loads(result) assert isinstance(data, dict) class TestMCPToolErrorHandling: """Test that MCP tools handle errors gracefully.""" @pytest.mark.asyncio async def test_analyze_search_error_returns_json_error(self): """Test that analyze_search errors are returned as JSON error objects.""" mock_ctx = MagicMock(spec=Context) with patch("finn_eiendom.mcp_server.analyze_search", new_callable=AsyncMock) as mock: mock.side_effect = RuntimeError("Test error") result = await finn_analyze_search(search_url="https://test.com", ctx=mock_ctx) # Should return JSON error object assert isinstance(result, str) data = json.loads(result) assert data.get("error") is True assert "message" in data @pytest.mark.asyncio async def test_get_ad_error_returns_json_error(self): """Test that get_ad errors are returned as JSON error objects.""" with patch("finn_eiendom.mcp_server.get_or_fetch_ad", new_callable=AsyncMock) as mock: mock.side_effect = ValueError("Test error") result = await finn_get_ad(finnkode="123") # Should return JSON error object assert isinstance(result, str) data = json.loads(result) assert data.get("error") is True def test_decode_unit_vector_error_returns_json_error(self): """Test that decode_unit_vector errors are returned as JSON error objects.""" with patch("finn_eiendom.mcp_server.decode_unit_vector") as mock: mock.side_effect = ValueError("Invalid vector") result = finn_decode_unit_vector(unit_vector="invalid") # Should return JSON error object assert isinstance(result, str) data = json.loads(result) assert data.get("error") is True class TestMCPToolAsyncSync: """Test that async/sync tool declarations are consistent with implementations.""" @pytest.mark.asyncio async def test_async_tools_are_async_functions(self): """Verify async tools are actually async functions.""" import inspect async_tools = [ finn_analyze_search, finn_get_ad, finn_resolve_eiendom_unit, finn_get_eiendom_unit, finn_analyze_unit_images, finn_get_similar_units, finn_build_unit_vector, finn_analyze_ad, finn_analyze_ad_against_comps, finn_find_similar_to_liked_ad, finn_compare_ads, finn_save_feedback, finn_get_new_ads_since_last_run, ] for tool in async_tools: assert asyncio.iscoroutinefunction(tool), f"{tool.__name__} should be async" def test_sync_tools_are_not_async(self): """Verify sync tools are not async functions.""" import inspect sync_tools = [ finn_decode_unit_vector, finn_get_shortlist, ] for tool in sync_tools: assert not asyncio.iscoroutinefunction(tool), f"{tool.__name__} should not be async" class TestServiceLayerIntegration: """Test that service layer functions work with actual implementations.""" def test_analyze_search_does_not_have_unsupported_parameters(self): """Verify analyze_search no longer has unsupported parameters.""" # This is a regression test for the include_similar_units_for_shortlist bug import inspect sig = inspect.signature(service.analyze_search) # The parameter should not exist in the service layer assert "include_similar_units_for_shortlist" not in sig.parameters # But should still have the main parameters assert "search_url" in sig.parameters assert "include_details" in sig.parameters assert "include_eiendom_no" in sig.parameters class TestParameterDefaults: """Test that MCP tools have correct default parameters.""" def test_finn_analyze_search_defaults(self): """Verify finn_analyze_search has correct parameter defaults.""" import inspect sig = inspect.signature(finn_analyze_search) assert sig.parameters["max_pages"].default == 3 assert sig.parameters["detail_limit"].default == 20 assert sig.parameters["include_details"].default is True assert sig.parameters["include_eiendom_no"].default is True def test_finn_get_ad_defaults(self): """Verify finn_get_ad has correct parameter defaults.""" import inspect sig = inspect.signature(finn_get_ad) assert sig.parameters["force_refresh"].default is False def test_finn_analyze_ad_defaults(self): """Verify finn_analyze_ad has correct parameter defaults.""" import inspect sig = inspect.signature(finn_analyze_ad) assert sig.parameters["include_eiendom_no"].default is True assert sig.parameters["include_similar_units"].default is False def test_finn_find_similar_to_liked_ad_defaults(self): """Verify finn_find_similar_to_liked_ad has correct parameter defaults.""" import inspect sig = inspect.signature(finn_find_similar_to_liked_ad) assert sig.parameters["mode"].default == "recommendations" assert sig.parameters["listing_status"].default == "FOR_SALE" def test_finn_compare_ads_defaults(self): """Verify finn_compare_ads has correct parameter defaults.""" import inspect sig = inspect.signature(finn_compare_ads) assert sig.parameters["include_eiendom_no"].default is True assert sig.parameters["include_comps"].default is True def test_finn_get_shortlist_defaults(self): """Verify finn_get_shortlist has correct parameter defaults.""" import inspect sig = inspect.signature(finn_get_shortlist) assert sig.parameters["run_id"].default is None assert sig.parameters["limit"].default == 10 def test_finn_get_similar_units_defaults(self): """Verify finn_get_similar_units has correct parameter defaults.""" import inspect sig = inspect.signature(finn_get_similar_units) assert sig.parameters["listing_status"].default == "RECENTLY_SOLD"