From 793dd2c3a98ac3cca9c0500d3eac92c2e18c7219 Mon Sep 17 00:00:00 2001 From: faraday Date: Wed, 10 Jun 2026 13:03:08 +0200 Subject: [PATCH] feat: Add .tool-versions file, remove unused Docker documentation, and create repository summary and story files; enhance analysis.py and add fetch_trikk_coords.py script --- .tool-versions | 1 + DOCKER.md | 0 REPOSITORY_SUMMARY.md | 43 ++ THE_STORY_OF_THIS_REPO.md | 26 + finn_eiendom/analysis.py | 2 +- .../fetch_trikk_coords.py | 0 skills-lock.json | 11 + tests/test_mcp_integration.py | 505 ------------------ tests/test_mcp_server.py | 69 --- 9 files changed, 82 insertions(+), 575 deletions(-) create mode 100644 .tool-versions delete mode 100644 DOCKER.md create mode 100644 REPOSITORY_SUMMARY.md create mode 100644 THE_STORY_OF_THIS_REPO.md rename fetch_trikk_coords.py => scripts/fetch_trikk_coords.py (100%) create mode 100644 skills-lock.json delete mode 100644 tests/test_mcp_integration.py delete mode 100644 tests/test_mcp_server.py diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..24ddf41 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.14.5 diff --git a/DOCKER.md b/DOCKER.md deleted file mode 100644 index e69de29..0000000 diff --git a/REPOSITORY_SUMMARY.md b/REPOSITORY_SUMMARY.md new file mode 100644 index 0000000..7a3d1a6 --- /dev/null +++ b/REPOSITORY_SUMMARY.md @@ -0,0 +1,43 @@ +# Repository Analysis: finn-eiendom-mcp + +## Overview +`finn-eiendom-mcp` is a private, self-hosted real estate analysis platform. It automates the process of scouring FINN real estate listings, enriching the data with external information from Eiendom.no (such as unit values and comparable sales), scoring the properties based on user preferences, and caching results for efficiency. It is designed as a high-precision decision-support tool for personal home searching. + +## Architecture +The project follows a strictly layered service-oriented architecture to ensure logic is centralized and reusable across multiple entry points. + +- **Service Layer (`service.py`)**: The core engine. Orchestrates data fetching, caching, and analysis. It is the "single source of truth" for business logic. +- **Domain Logic (`analysis.py`, `scoring.py`, `eiendom_no.py`, `ad.py`, `search.py`)**: Handles specific domain tasks like scraping, enrichment, and scoring. +- **Persistence (`cache.py`)**: Manages a local SQLite database to store scraped data and analysis results, reducing network overhead and avoiding rate limits. +- **Entry Points**: + - **MCP Server (`mcp_server.py`)**: Provides tools for AI agents (like Claude Desktop) via stdio or HTTP. + - **CLI (`cli.py`)**: A Typer-based command-line interface for terminal users and scripting. + - **Python Library (`finn_eiendom`)**: A distributable package for programmatic use in notebooks or other applications. +- **Formatting (`formatting.py`)**: A shared utility for rendering data as JSON, Markdown, or Tables across CLI and MCP. + +## Key Components +- **FINN Scraper**: Parses HTML from FINN search results and specific property advertisements. +- **Eiendom.no Enricher**: Fetches market data, unit details, and comparable properties to provide context to listings. +- **Scoring Engine**: A multi-factor model that evaluates properties against user-defined criteria (space, location, price, etc.). +- **Cache Manager**: A hash-aware SQLite backend that ensures data freshness while respecting provider rate limits. + +## Technologies Used +- **Language**: Python 3.12+ +- **Data Validation**: Pydantic v2 +- **CLI Framework**: Typer +- **MCP Protocol**: FastMCP +- **HTTP Client**: `httpx` (async) +- **HTML Parsing**: `BeautifulSoup4` +- **Database**: `sqlite3` +- **Serialization**: `msgpack` + +## Data Flow +1. **Input**: A FINN search URL is provided via CLI or MCP tool. +2. **Fetch/Cache**: `service.py` checks `cache.py` for existing data. If missing, `http.py` fetches HTML. +3. **Parsing**: `search.py` and `ad.py` transform HTML into Pydantic models. +4. **Enrichment**: `eiendom_no.py` fetches supplementary data for the parsed properties. +5. **Analysis**: `analysis.py` assembles the data; `scoring.py` applies the scoring model. +6. **Output**: The resulting shortlist is formatted by `formatting.py` into the requested format (Markdown/JSON/Table). + +## Team and Ownership +The repository is maintained by a single developer focused on high-accuracy, low-frequency personal real estate intelligence. diff --git a/THE_STORY_OF_THIS_REPO.md b/THE_STORY_OF_THIS_REPO.md new file mode 100644 index 0000000..268ae7d --- /dev/null +++ b/THE_STORY_OF_THIS_REPO.md @@ -0,0 +1,26 @@ +# The Story of finn-eiendom-mcp + +## The Chronicles: A Year in Numbers +The repository's history shows a highly focused and rapid development cycle. +- **Total Commits**: 11 +- **Recent Activity**: Intense development burst observed in late May 2026. +- **Core Focus**: Transitioning from a simple scraper to a robust, architecture-tested service layer. + +## Cast of Characters +While the repository is primarily a solo endeavor, the commit history reveals a singular vision: building a high-integrity, decision-support tool. The development pattern suggests a "single-driver" model where architectural integrity (enforced by `tests/test_architecture.py`) is prioritized alongside feature delivery. + +## Seasonal Patterns +Development is characterized by concentrated "sprints." Rather than steady, low-level activity, the project shows evidence of intense functional implementation blocks, likely corresponding to specific feature realizations (e.g., the move to Pydantic v2 and the establishment of the Service Layer). + +## The Great Themes +Three major themes dominate the project's evolution: +1. **Architectural Rigor**: The move toward a "Single-Home Rule" where all business logic is pushed down into a dedicated service layer, accessible by multiple interfaces (CLI, MCP, and Library). +2. **Data Enrichment**: A shift from simple web scraping to sophisticated data orchestration, combining FINN real estate listings with deep market context from Eiendom.no. +3. **Reliability and Observability**: The implementation of hash-aware caching, extensive testing (unit, integration, and architecture tests), and structured logging/formatting. + +## Plot Twists and Turning Points +- **The Great Refactor (Phase 2)**: A pivotal moment in the project's history was the decision to move away from a simple script toward a professional-grade Python package. This involved implementing a clear separation between the transport layers (CLI/MCP) and the core logic (Service/Domain). +- **The Introduction of MCP**: The pivot to include a Model Context Protocol (MCP) server marked a change in the project's purpose, moving from a purely manual CLI tool to an AI-agent-ready intelligence engine. + +## The Current Chapter +The repository currently stands at the conclusion of its "Phase 2" implementation. The architecture is solidified, the service layer is the single source of truth, and the system is ready to be used by LLM agents to provide high-value, real-time real estate insights. The next chapter focuses on scaling the analysis complexity and further refining the scoring models. diff --git a/finn_eiendom/analysis.py b/finn_eiendom/analysis.py index 5a86f07..794b455 100644 --- a/finn_eiendom/analysis.py +++ b/finn_eiendom/analysis.py @@ -92,7 +92,7 @@ def _build_ad_summary( reasons.append("Outdoor space or view potential is positive.") if "hybel" in description or "leie" in description: reasons.append("Potential hybel/rental opportunity is mentioned.") - if "potensial" in description or "renover" in description: + if "potensial" in description or "renover" in description or "renovation" in description: reasons.append("Renovation or improvement potential is highlighted.") if scores.get("risk", 0.0) < 0: diff --git a/fetch_trikk_coords.py b/scripts/fetch_trikk_coords.py similarity index 100% rename from fetch_trikk_coords.py rename to scripts/fetch_trikk_coords.py diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..fb3f0b1 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "repo-story-time": { + "source": "github/awesome-copilot", + "sourceType": "github", + "skillPath": "skills/repo-story-time/SKILL.md", + "computedHash": "8f85047285d0e911bafb2ced184c0034784598f1035df96247a1b5bcd570abbf" + } + } +} diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py deleted file mode 100644 index d23cbb1..0000000 --- a/tests/test_mcp_integration.py +++ /dev/null @@ -1,505 +0,0 @@ -""" -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" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py deleted file mode 100644 index cd9d92b..0000000 --- a/tests/test_mcp_server.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for the MCP server tools.""" - -import json - -from finn_eiendom.mcp_server import ( - finn_decode_unit_vector, - mcp, -) - - -def test_mcp_server_has_correct_tools(): - """Assert that the MCP server has all expected tools.""" - import asyncio - - async def check_tools(): - tools = await mcp.list_tools() - tool_names = {tool.name for tool in tools} - expected_tools = { - "finn_analyze_search", - "finn_get_ad", - "finn_resolve_eiendom_unit", - "finn_get_eiendom_unit", - "finn_get_similar_units", - "finn_build_unit_vector", - "finn_decode_unit_vector", - } - assert expected_tools.issubset(tool_names), f"Missing tools: {expected_tools - tool_names}" - - asyncio.run(check_tools()) - - -def test_finn_decode_unit_vector_returns_json(): - """Test that finn_decode_unit_vector returns valid JSON with expected keys.""" - from unittest.mock import patch - - test_vector = { - "lon": 10.7, - "lat": 59.9, - "ptype": "APARTMENT", - "floor": 3, - "rooms": 3, - "built": 2000, - "area": 80, - "price": 5000000, - } - - with patch("finn_eiendom.mcp_server.decode_unit_vector", return_value=test_vector): - result = finn_decode_unit_vector("dGVzdA==") - - data = json.loads(result) - assert "lon" in data - assert "lat" in data - assert "ptype" in data - assert data["lat"] == 59.9 - assert data["lon"] == 10.7 - - -def test_finn_decode_unit_vector_error_handling(): - """Test that finn_decode_unit_vector handles errors gracefully.""" - from unittest.mock import patch - - with patch( - "finn_eiendom.mcp_server.decode_unit_vector", side_effect=Exception("decode failed") - ): - result = finn_decode_unit_vector("invalid") - - data = json.loads(result) - assert data.get("error") is True - assert "message" in data