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