@@ -3,7 +3,6 @@ from finn_eiendom.eiendom_no import (
|
||||
decode_unit_vector,
|
||||
parse_eiendom_unit_json,
|
||||
parse_similar_units_json,
|
||||
resolve_unit_from_finn_url,
|
||||
)
|
||||
from tests.fixtures import (
|
||||
SAMPLE_EIENDOM_SIMILAR_UNITS_JSON,
|
||||
@@ -35,10 +34,3 @@ def test_parse_similar_units_json():
|
||||
assert len(comps) == 2
|
||||
assert comps[0].unit_code == "c-recent-1"
|
||||
assert comps[1].selling_price == 7350000
|
||||
|
||||
|
||||
def test_resolve_unit_from_finn_url():
|
||||
unit_code = resolve_unit_from_finn_url(
|
||||
"https://www.finn.no/realestate/homes/ad.html?finnkode=462400360"
|
||||
)
|
||||
assert unit_code == "462400360"
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
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 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."""
|
||||
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",
|
||||
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."""
|
||||
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")
|
||||
|
||||
# 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"
|
||||
Reference in New Issue
Block a user