gendesign/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py
bot-backend d9b8047a7d
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 36s
fix(tradein): yandex address-enrich — city-agnostic title regex (спутники ЕКБ) (#875)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-31 09:08:29 +00:00

240 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 = (
"<title>Продажа квартиры — Екатеринбург, улица Горького, 36 "
"— id 7654321 на Яндекс.Недвижимости</title>"
)
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 = (
"<title>Продажа квартиры — ЖК Каменный Ручей, "
"Екатеринбург, проспект Космонавтов, 110А — id 99001 </title>"
)
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 = (
"<title>Купить квартиру 102.4 м²"
" — ЖК «Дуэт», Верхняя Пышма, улица Мальцева, 14"
" — id 7533242317391171630</title>"
)
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 = (
"<title>Купить квартиру 139.2 м²"
" — ЖК «Уют-Сити», Берёзовский, Александровский проспект, 3"
" — id 3988368535316036933</title>"
)
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 = "<title>Продажа квартиры — Екатеринбург, улица Горького, 36" " — id 7654321</title>"
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 = "<title>Продажа квартиры — Екатеринбург, улица Мира — id 999888</title>"
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 = (
"<title>Продажа\xa0квартиры\xa0\xa0Екатеринбург,\xa0улица\xa0Ленина,\xa012"
"\xa0\xa0id\xa0123456</title>"
)
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 <title> 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</title>"
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()