Files
finn-mcp/tests/test_analysis.py
ole 55d93894ac 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
2026-05-29 15:16:57 +00:00

247 lines
8.7 KiB
Python

"""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