"""Tests for yandex_address_backfill — pure regex extraction, no network/DB.""" from __future__ import annotations import os import re import sys from unittest.mock import AsyncMock, MagicMock, patch # DATABASE_URL required by config before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint stub — not installed in CI without GTK. _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from app.services.yandex_address_backfill import ( # noqa: E402 YandexAddressBackfillResult, _extract_address_from_title, ) # Regex used to assert that an extracted address contains a house number. _RE_HAS_HOUSE_NUMBER = re.compile(r",\s*\d+") # --------------------------------------------------------------------------- # Pure regex / extraction tests (no network, no DB) # --------------------------------------------------------------------------- def test_extract_address_standard_title() -> None: """Standard title: «Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321».""" html = ( "Продажа квартиры — Екатеринбург, улица Горького, 36 " "— id 7654321 на Яндекс.Недвижимости" ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address from standard title" assert _RE_HAS_HOUSE_NUMBER.search( addr ), f"Extracted address should contain house number: {addr!r}" assert "Екатеринбург" in addr assert "Горького" in addr assert "36" in addr def test_extract_address_with_zhk_prefix() -> None: """Title with optional ЖК prefix before city: «— ЖК Каменный Ручей, Екатеринбург, …».""" html = ( "Продажа квартиры — ЖК Каменный Ручей, " "Екатеринбург, проспект Космонавтов, 110А — id 99001 " ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address when ЖК prefix is present" assert _RE_HAS_HOUSE_NUMBER.search( addr ), f"Extracted address should contain house number: {addr!r}" assert "Екатеринбург" in addr # --------------------------------------------------------------------------- # City-agnostic tests — satellite towns (T10 live-bug fix) # --------------------------------------------------------------------------- def test_extract_address_verkhnaya_pyshma_with_zhk() -> None: """Real title from sweep #28: ЖК «Дуэт», Верхняя Пышма — was returning None before fix.""" html = ( "Купить квартиру 102.4 м²" " — ЖК «Дуэт», Верхняя Пышма, улица Мальцева, 14" " — id 7533242317391171630" ) addr = _extract_address_from_title(html) assert addr is not None, "Верхняя Пышма title should produce an address" assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}" assert "Верхняя Пышма" in addr assert "Мальцева" in addr assert "14" in addr def test_extract_address_beryozovsky_with_zhk() -> None: """Real title from sweep #28: ЖК «Уют-Сити», Берёзовский — was returning None before fix.""" html = ( "Купить квартиру 139.2 м²" " — ЖК «Уют-Сити», Берёзовский, Александровский проспект, 3" " — id 3988368535316036933" ) addr = _extract_address_from_title(html) assert addr is not None, "Берёзовский title should produce an address" assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}" assert "Берёзовский" in addr assert "Александровский" in addr assert "3" in addr def test_extract_address_ekb_no_zhk_still_matches() -> None: """EKB variant from docstring (no ЖК prefix) still matches after city-agnostic change.""" html = "Продажа квартиры — Екатеринбург, улица Горького, 36" " — id 7654321" addr = _extract_address_from_title(html) assert addr is not None, "EKB no-ЖК title should still match" assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}" assert "Екатеринбург" in addr assert "Горького" in addr assert "36" in addr def test_extract_address_no_house_number_guard() -> None: """Street-only title (no house number): extractor may return value, but guard rejects it. _RE_HAS_HOUSE_NUMBER must NOT match → backfill skips the listing to avoid overwriting with another street-only address. """ html = "Продажа квартиры — Екатеринбург, улица Мира — id 999888" addr = _extract_address_from_title(html) # If the regex matches at all, the extracted value must not contain a house number. if addr is not None: assert not _RE_HAS_HOUSE_NUMBER.search( addr ), f"Street-only addr must fail house-number guard: {addr!r}" def test_extract_address_nbsp_replaced() -> None: """\\xa0 (non-breaking space) in title should be normalised before regex match.""" html = ( "Продажа\xa0квартиры\xa0—\xa0Екатеринбург,\xa0улица\xa0Ленина,\xa012" "\xa0—\xa0id\xa0123456" ) addr = _extract_address_from_title(html) assert addr is not None, "\\xa0 should be replaced before regex" assert _RE_HAS_HOUSE_NUMBER.search( addr ), f"Extracted address should contain house number: {addr!r}" def test_extract_address_no_title_returns_none() -> None: """HTML without a matching returns None.""" html = "<html><body>Объявление снято</body></html>" addr = _extract_address_from_title(html) assert addr is None def test_extract_address_title_without_id_returns_none() -> None: """Title that matches city but has no '— id NNN' sentinel returns None.""" html = "<title>Екатеринбург, улица Горького, 36" addr = _extract_address_from_title(html) assert addr is None # --------------------------------------------------------------------------- # Task-level smoke tests (no real DB, no real HTTP) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_run_yandex_address_backfill_no_eligible_listings() -> None: """No eligible listings → run marked done with zeroed counters, no HTTP calls.""" from app.tasks.yandex_address_backfill import run_yandex_address_backfill db = MagicMock() empty_result = YandexAddressBackfillResult( checked=0, saved=0, skipped=0, errors=0, duration_sec=0.01 ) with ( patch( "app.tasks.yandex_address_backfill.backfill_yandex_addresses", new_callable=AsyncMock, return_value=empty_result, ) as mock_backfill, patch("app.tasks.yandex_address_backfill.runs_mod") as mock_runs, ): result = await run_yandex_address_backfill(db, run_id=42, params={}) mock_backfill.assert_awaited_once() mock_runs.update_heartbeat.assert_called_once_with( db, 42, {"checked": 0, "saved": 0, "skipped": 0, "errors": 0} ) mock_runs.mark_done.assert_called_once() mock_runs.mark_failed.assert_not_called() assert result.checked == 0 assert result.saved == 0 @pytest.mark.asyncio async def test_run_yandex_address_backfill_saves_enriched_count() -> None: """5 checked, 3 saved → result reflects correct counters.""" from app.tasks.yandex_address_backfill import run_yandex_address_backfill db = MagicMock() backfill_result = YandexAddressBackfillResult( checked=5, saved=3, skipped=1, errors=1, duration_sec=15.0 ) with ( patch( "app.tasks.yandex_address_backfill.backfill_yandex_addresses", new_callable=AsyncMock, return_value=backfill_result, ), patch("app.tasks.yandex_address_backfill.runs_mod") as mock_runs, ): result = await run_yandex_address_backfill( db, run_id=99, params={"batch_size": 100, "request_delay_sec": 2.0} ) mock_runs.mark_done.assert_called_once() done_counters = mock_runs.mark_done.call_args[0][2] # positional: (db, run_id, counters) assert done_counters["checked"] == 5 assert done_counters["saved"] == 3 assert done_counters["errors"] == 1 assert result.saved == 3 assert result.errors == 1 @pytest.mark.asyncio async def test_run_yandex_address_backfill_marks_failed_on_exception() -> None: """backfill_yandex_addresses raises → mark_failed called, exception re-raised.""" from app.tasks.yandex_address_backfill import run_yandex_address_backfill db = MagicMock() with ( patch( "app.tasks.yandex_address_backfill.backfill_yandex_addresses", new_callable=AsyncMock, side_effect=RuntimeError("network timeout"), ), patch("app.tasks.yandex_address_backfill.runs_mod") as mock_runs, ): with pytest.raises(RuntimeError, match="network timeout"): await run_yandex_address_backfill(db, run_id=7, params={}) mock_runs.mark_failed.assert_called_once() mock_runs.mark_done.assert_not_called()