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:
Ole
2026-05-29 15:16:57 +00:00
parent 5b772b2ae5
commit 55d93894ac
18 changed files with 1457 additions and 60 deletions
+246
View File
@@ -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
View File
@@ -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()
# ============================================================================
+5 -1
View File
@@ -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
View File
@@ -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)