feat(refactor): Document refactoring progress and phases in markdown
feat(scripts): Add backfill script for content_hash in cache tables feat(scripts): Create recompute script for analysis_cache population test(tests): Implement comprehensive tests for analysis module functions fix(tests): Update CLI tests to assert errors on stderr instead of stdout fix(tests): Adjust MCP integration tests to pass context parameter correctly fix(tests): Modify service tests to return hash on save functions for consistency
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""Tests for the analysis module (search + enrichment + scoring orchestration)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from finn_eiendom.models import EiendomUnit, FinnAd, SimilarUnit
|
||||
from finn_eiendom.analysis import (
|
||||
analyze_ad,
|
||||
analyze_search,
|
||||
_normalize_description,
|
||||
_is_resale_listing,
|
||||
_build_ad_summary,
|
||||
_compute_deps_hash,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeDescription:
|
||||
"""Test _normalize_description helper."""
|
||||
|
||||
def test_normalize_description_with_text(self):
|
||||
"""Test description normalization with text."""
|
||||
result = _normalize_description("Test Description")
|
||||
assert result == "test description"
|
||||
|
||||
def test_normalize_description_with_none(self):
|
||||
"""Test description normalization with None."""
|
||||
result = _normalize_description(None)
|
||||
assert result == ""
|
||||
|
||||
def test_normalize_description_empty_string(self):
|
||||
"""Test description normalization with empty string."""
|
||||
result = _normalize_description("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestIsResaleListing:
|
||||
"""Test _is_resale_listing helper."""
|
||||
|
||||
def test_is_resale_listing_true(self):
|
||||
"""Test identification of resale listings."""
|
||||
assert _is_resale_listing("https://finn.no/realestate/homes/123")
|
||||
assert _is_resale_listing("http://test.com/realestate/homes/456")
|
||||
|
||||
def test_is_resale_listing_false(self):
|
||||
"""Test non-resale listings."""
|
||||
assert not _is_resale_listing("https://finn.no/newbuilding/123")
|
||||
assert not _is_resale_listing("https://finn.no/project/123")
|
||||
assert not _is_resale_listing("https://finn.no/other/123")
|
||||
|
||||
|
||||
class TestBuildAdSummary:
|
||||
"""Test _build_ad_summary function."""
|
||||
|
||||
def test_build_ad_summary_with_enrichment(self):
|
||||
"""Test summary building with enrichment."""
|
||||
ad = FinnAd(
|
||||
finnkode="123",
|
||||
url="https://finn.no/realestate/homes/123",
|
||||
total_price=5000000,
|
||||
listing_description="Nice apartment",
|
||||
)
|
||||
enriched = EiendomUnit(
|
||||
unit_code="test-code",
|
||||
estimated_selling_price=5200000,
|
||||
estimated_selling_price_upper=5400000,
|
||||
)
|
||||
similar_units = [SimilarUnit(unit_code="comp1"), SimilarUnit(unit_code="comp2")]
|
||||
scores = {"risk": 0.5}
|
||||
categories = ["test"]
|
||||
|
||||
result = _build_ad_summary(ad, enriched, similar_units, scores, categories)
|
||||
|
||||
assert "why_interesting" in result
|
||||
assert "risks" in result
|
||||
assert "next_steps" in result
|
||||
assert "shortlist_reason" in result
|
||||
assert isinstance(result["why_interesting"], list)
|
||||
assert isinstance(result["risks"], list)
|
||||
assert isinstance(result["next_steps"], list)
|
||||
|
||||
def test_build_ad_summary_without_enrichment(self):
|
||||
"""Test summary building without enrichment."""
|
||||
ad = FinnAd(
|
||||
finnkode="123",
|
||||
url="https://finn.no/realestate/homes/123",
|
||||
total_price=5000000,
|
||||
)
|
||||
similar_units = []
|
||||
scores = {"risk": 0.0}
|
||||
categories = []
|
||||
|
||||
result = _build_ad_summary(ad, None, similar_units, scores, categories)
|
||||
|
||||
assert "why_interesting" in result
|
||||
assert "Eiendom.no enrichment is unavailable" in result["why_interesting"][0]
|
||||
|
||||
def test_build_ad_summary_with_hybrid_description(self):
|
||||
"""Test summary with hybel/rental potential."""
|
||||
ad = FinnAd(
|
||||
finnkode="123",
|
||||
url="https://finn.no/realestate/homes/123",
|
||||
listing_description="Good hybel potential, can be rented",
|
||||
)
|
||||
|
||||
result = _build_ad_summary(ad, None, [], {"risk": 0.0}, [])
|
||||
|
||||
assert any("hybel" in reason.lower() for reason in result["why_interesting"])
|
||||
|
||||
def test_build_ad_summary_with_renovation_description(self):
|
||||
"""Test summary with renovation potential."""
|
||||
ad = FinnAd(
|
||||
finnkode="123",
|
||||
url="https://finn.no/realestate/homes/123",
|
||||
listing_description="Needs renovation but great potential",
|
||||
)
|
||||
|
||||
result = _build_ad_summary(ad, None, [], {"risk": 0.0}, [])
|
||||
|
||||
assert any(
|
||||
"renovation" in reason.lower() for reason in result["why_interesting"]
|
||||
)
|
||||
|
||||
|
||||
class TestComputeDepsHash:
|
||||
"""Test _compute_deps_hash function."""
|
||||
|
||||
def test_compute_deps_hash_with_unit_code(self):
|
||||
"""Test hash computation with unit code."""
|
||||
with (
|
||||
patch("finn_eiendom.analysis.get_finn_ad_hash", return_value="hash1"),
|
||||
patch(
|
||||
"finn_eiendom.analysis.get_eiendom_unit_hash", return_value="hash2"
|
||||
),
|
||||
patch(
|
||||
"finn_eiendom.analysis.get_similar_units_hash", return_value="hash3"
|
||||
),
|
||||
patch("finn_eiendom.analysis.combine_hashes", return_value="combined"),
|
||||
):
|
||||
mock_conn = MagicMock()
|
||||
result = _compute_deps_hash(mock_conn, "123", "test-code")
|
||||
|
||||
assert result == "combined"
|
||||
|
||||
def test_compute_deps_hash_without_unit_code(self):
|
||||
"""Test hash computation without unit code."""
|
||||
with (
|
||||
patch("finn_eiendom.analysis.get_finn_ad_hash", return_value="hash1"),
|
||||
patch("finn_eiendom.analysis.combine_hashes", return_value="combined"),
|
||||
):
|
||||
mock_conn = MagicMock()
|
||||
result = _compute_deps_hash(mock_conn, "123", None)
|
||||
|
||||
assert result == "combined"
|
||||
|
||||
|
||||
class TestAnalyzeAd:
|
||||
"""Test analyze_ad function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_ad_basic(self):
|
||||
"""Test basic ad analysis."""
|
||||
mock_ad = FinnAd(
|
||||
finnkode="123",
|
||||
url="https://finn.no/realestate/homes/123",
|
||||
total_price=5000000,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.analysis.cache.init_db"),
|
||||
patch("finn_eiendom.analysis.save_finn_ad", return_value=("hash1", True)),
|
||||
patch(
|
||||
"finn_eiendom.analysis.cache.get_eiendom_unit", return_value=None
|
||||
),
|
||||
patch("finn_eiendom.analysis.cache.get_similar_units", return_value=[]),
|
||||
patch("finn_eiendom.analysis.get_analysis", return_value=None),
|
||||
patch("finn_eiendom.analysis.scoring.score_ad", return_value={"score": 0.5}),
|
||||
patch("finn_eiendom.analysis._build_ad_summary", return_value={}),
|
||||
patch("finn_eiendom.analysis.save_analysis"),
|
||||
):
|
||||
result = await analyze_ad(mock_ad)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_ad_with_cached_result(self):
|
||||
"""Test analyze_ad returns cached result."""
|
||||
mock_ad = FinnAd(finnkode="123", url="https://finn.no/realestate/homes/123")
|
||||
cached_result = {"cached": True}
|
||||
|
||||
with (
|
||||
patch("finn_eiendom.analysis.cache.init_db"),
|
||||
patch("finn_eiendom.analysis.save_finn_ad", return_value=("hash1", True)),
|
||||
patch(
|
||||
"finn_eiendom.analysis.cache.get_eiendom_unit", return_value=None
|
||||
),
|
||||
patch("finn_eiendom.analysis.cache.get_similar_units", return_value=[]),
|
||||
patch("finn_eiendom.analysis.get_analysis", return_value=cached_result),
|
||||
):
|
||||
result = await analyze_ad(mock_ad)
|
||||
|
||||
assert result == cached_result
|
||||
|
||||
|
||||
class TestAnalyzeSearch:
|
||||
"""Test analyze_search function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_search_basic(self):
|
||||
"""Test basic search analysis."""
|
||||
with (
|
||||
patch(
|
||||
"finn_eiendom.analysis.search.parse_search_url",
|
||||
return_value={"query": "test"},
|
||||
),
|
||||
patch(
|
||||
"finn_eiendom.analysis.ad_module.fetch_search_page",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"cards": [
|
||||
{"finnkode": "123", "url": "https://finn.no/realestate/homes/123"}
|
||||
]
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"finn_eiendom.analysis.ad_module.fetch_ad_details",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FinnAd(
|
||||
finnkode="123", url="https://finn.no/realestate/homes/123"
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"finn_eiendom.analysis.cache.init_db",
|
||||
),
|
||||
patch("finn_eiendom.analysis.save_finn_ad", return_value=("hash1", True)),
|
||||
patch("finn_eiendom.analysis.analyze_ad", new_callable=AsyncMock, return_value={}),
|
||||
):
|
||||
from mcp.server.fastmcp import Context
|
||||
mock_ctx = MagicMock(spec=Context)
|
||||
|
||||
result = await analyze_search(
|
||||
"https://finn.no/test", max_pages=1, ctx=mock_ctx
|
||||
)
|
||||
|
||||
assert "search_url" in result
|
||||
assert "search_cards" in result
|
||||
+7
-7
@@ -197,7 +197,7 @@ def test_compare_too_many_args():
|
||||
finnkoder = [str(i) for i in range(11)]
|
||||
result = runner.invoke(app, ["compare"] + finnkoder)
|
||||
assert result.exit_code == 1
|
||||
assert "at most 10" in result.stdout.lower()
|
||||
assert "at most 10" in result.stderr.lower()
|
||||
|
||||
|
||||
def test_compare_with_options():
|
||||
@@ -286,7 +286,7 @@ def test_resolve_unit_not_found():
|
||||
mock_resolve.return_value = None
|
||||
result = runner.invoke(app, ["resolve-unit", "http://example.com"])
|
||||
assert result.exit_code == 1
|
||||
assert "could not resolve" in result.stdout.lower()
|
||||
assert "could not resolve" in result.stderr.lower()
|
||||
|
||||
|
||||
def test_resolve_unit_error():
|
||||
@@ -336,7 +336,7 @@ def test_get_unit_not_found():
|
||||
mock_get.return_value = None
|
||||
result = runner.invoke(app, ["get-unit", "test-code"])
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.stdout.lower()
|
||||
assert "not found" in result.stderr.lower()
|
||||
|
||||
|
||||
def test_build_vector_success():
|
||||
@@ -547,7 +547,7 @@ def test_shortlist_with_limit():
|
||||
result = runner.invoke(app, ["shortlist", "--limit", "20"])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1]["limit"] == 20
|
||||
assert call_args[0][1] == 20
|
||||
|
||||
|
||||
def test_diff_success():
|
||||
@@ -591,7 +591,7 @@ def test_cache_clear_confirm_yes():
|
||||
def test_cache_clear_confirm_no():
|
||||
"""Test cache clear with confirmation rejected."""
|
||||
result = runner.invoke(app, ["cache", "clear"], input="n\n")
|
||||
assert result.exit_code == 1
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_cache_clear_html():
|
||||
@@ -633,7 +633,7 @@ def test_config_path():
|
||||
|
||||
def test_serve_stdio():
|
||||
"""Test serve command with stdio transport."""
|
||||
with patch("finn_eiendom.cli.mcp_main") as mock_mcp:
|
||||
with patch("finn_eiendom.mcp_server.main") as mock_mcp:
|
||||
result = runner.invoke(app, ["serve", "--transport", "stdio"])
|
||||
# Should call the MCP main
|
||||
assert result.exit_code == 0 or "Error" not in result.stdout
|
||||
@@ -650,7 +650,7 @@ def test_serve_unknown_transport():
|
||||
"""Test serve command with unknown transport."""
|
||||
result = runner.invoke(app, ["serve", "--transport", "unknown"])
|
||||
assert result.exit_code == 1
|
||||
assert "unknown transport" in result.stdout.lower()
|
||||
assert "unknown transport" in result.stderr.lower()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -34,6 +35,7 @@ class TestMCPToolParameterMatching:
|
||||
@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:
|
||||
@@ -46,6 +48,7 @@ class TestMCPToolParameterMatching:
|
||||
|
||||
result = await finn_analyze_search(
|
||||
search_url="https://test.com",
|
||||
ctx=mock_ctx,
|
||||
max_pages=2,
|
||||
detail_limit=10,
|
||||
include_details=False,
|
||||
@@ -338,10 +341,11 @@ class TestMCPToolErrorHandling:
|
||||
@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")
|
||||
result = await finn_analyze_search(search_url="https://test.com", ctx=mock_ctx)
|
||||
|
||||
# Should return JSON error object
|
||||
assert isinstance(result, str)
|
||||
|
||||
+17
-7
@@ -38,7 +38,7 @@ async def test_get_or_fetch_ad_fetches_when_cache_miss():
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_finn_ad", return_value=None),
|
||||
patch("finn_eiendom.service.fetch_ad_details", return_value=mock_ad) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_finn_ad") as mock_save,
|
||||
patch("finn_eiendom.service.save_finn_ad", return_value=("hash123", True)) as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_ad("123")
|
||||
|
||||
@@ -56,7 +56,7 @@ async def test_get_or_fetch_ad_force_refresh():
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_finn_ad", return_value=mock_ad) as mock_get,
|
||||
patch("finn_eiendom.service.fetch_ad_details", return_value=mock_ad) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_finn_ad") as mock_save,
|
||||
patch("finn_eiendom.service.save_finn_ad", return_value=("hash123", True)) as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_ad("123", force_refresh=True)
|
||||
|
||||
@@ -92,7 +92,9 @@ async def test_get_or_fetch_eiendom_unit_fetches_when_cache_miss():
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_cached_eiendom_unit", return_value=None),
|
||||
patch("finn_eiendom.service.get_unit", return_value=mock_unit) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_eiendom_unit") as mock_save,
|
||||
patch(
|
||||
"finn_eiendom.service.save_eiendom_unit", return_value=("hash123", True)
|
||||
) as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_eiendom_unit("test-code")
|
||||
|
||||
@@ -110,7 +112,9 @@ async def test_get_or_fetch_similar_units_uses_cache():
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=mock_unit),
|
||||
patch("finn_eiendom.service.get_cached_similar_units", return_value=mock_similar) as mock_get,
|
||||
patch(
|
||||
"finn_eiendom.service.get_cached_similar_units", return_value=mock_similar
|
||||
) as mock_get,
|
||||
patch("finn_eiendom.service.get_similar_units") as mock_fetch,
|
||||
):
|
||||
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD")
|
||||
@@ -133,7 +137,9 @@ async def test_get_or_fetch_similar_units_fetches_when_cache_miss():
|
||||
patch("finn_eiendom.service.get_cached_similar_units", return_value=[]),
|
||||
patch("finn_eiendom.service.build_unit_vector", return_value="vector_data"),
|
||||
patch("finn_eiendom.service.get_similar_units", return_value=mock_similar) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_similar_units") as mock_save,
|
||||
patch(
|
||||
"finn_eiendom.service.save_similar_units", return_value=("hash123", True)
|
||||
) as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD")
|
||||
|
||||
@@ -152,10 +158,14 @@ async def test_get_or_fetch_similar_units_force_refresh():
|
||||
with (
|
||||
patch("finn_eiendom.service.init_db"),
|
||||
patch("finn_eiendom.service.get_or_fetch_eiendom_unit", return_value=mock_unit),
|
||||
patch("finn_eiendom.service.get_cached_similar_units", return_value=mock_similar) as mock_get,
|
||||
patch(
|
||||
"finn_eiendom.service.get_cached_similar_units", return_value=mock_similar
|
||||
) as mock_get,
|
||||
patch("finn_eiendom.service.build_unit_vector", return_value="vector_data"),
|
||||
patch("finn_eiendom.service.get_similar_units", return_value=mock_similar) as mock_fetch,
|
||||
patch("finn_eiendom.service.save_similar_units") as mock_save,
|
||||
patch(
|
||||
"finn_eiendom.service.save_similar_units", return_value=("hash123", True)
|
||||
) as mock_save,
|
||||
):
|
||||
result = await get_or_fetch_similar_units("test-code", "RECENTLY_SOLD", force_refresh=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user