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
|
||||
Reference in New Issue
Block a user