gendesign/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py
bot-backend c9daf8e268
All checks were successful
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein): yandex sweep — guard ext_id=NULL перед fetch_jk + cleanup httpx (#974 review M1/M3)
M1: добавлен guard перед вызовом fetch_jk — если ext_id пустой/None при наличии
jk_slug, логируем warning, incrementим failed_fetch и пропускаем (continue).
Пустой jk_id формирует битый URL /{slug}-/ и коллизию на UNIQUE ext_id=''.

M3: в admin.py /scrape/yandex-newbuilding заменён `async with YandexNewbuildingScraper()`
на прямое `scraper = YandexNewbuildingScraper()` — fetch_jk использует внутренний
BrowserFetcher, httpx-клиент BaseScraper открывался вхолостую.

Тесты: добавлен test_sweep_slug_present_but_ext_id_null_skips_fetch.
2026-06-15 18:41:08 +03:00

397 lines
13 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_newbuilding_sweep task (#974).
No real network, no real Postgres. BrowserFetcher, resolve_yandex_jk_slug and
fetch_jk are mocked. The DB session is an in-memory fake that models
market.yandex_jk_enrichment + houses.yandex_jk_slug persistence assertions.
"""
from __future__ import annotations
import os
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.tasks.yandex_newbuilding_sweep import ( # noqa: E402
YandexNewbuildingSweepResult,
count_yandex_newbuilding_houses,
enrich_yandex_newbuilding_sweep,
)
# ── In-memory fake DB ─────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, *, scalar=None, rows=None, fetchone=None):
self._scalar = scalar
self._rows = rows or []
self._fetchone = fetchone
def scalar_one(self):
return self._scalar
def mappings(self):
return self
def all(self):
return self._rows
def fetchone(self):
return self._fetchone
class FakeDB:
"""Minimal SQLAlchemy-Session fake for yandex_newbuilding_sweep.
Tracks:
- enrichment: set of ext_id strings (inserted via UPSERT)
- slug_updates: dict house_id → slug (persisted via UPDATE houses)
- savepoints: int (# of begin_nested calls)
"""
def __init__(self, pending_rows: list[dict]):
self._pending_rows = pending_rows
# market.yandex_jk_enrichment keyed by ext_id
self.enrichment: dict[str, dict] = {}
# houses.yandex_jk_slug written during resolve
self.slug_updates: dict[int, str] = {}
self.commits = 0
self.rollbacks = 0
self._sp_stack: list[_FakeSavepoint] = []
def execute(self, statement, params=None):
sql = str(statement)
params = params or {}
# COUNT queries
if "COUNT(DISTINCT h.id)" in sql and "yandex_realty_nb" in sql:
if "NOT EXISTS" in sql:
# _COUNT_PENDING
pending = sum(
1 for r in self._pending_rows if r.get("ext_id") not in self.enrichment
)
return _FakeResult(scalar=pending)
if "yandex_jk_slug IS NOT NULL" in sql:
# _COUNT_FETCHABLE
return _FakeResult(scalar=len(self._pending_rows))
# _COUNT_TOTAL
return _FakeResult(scalar=len(self._pending_rows))
# SELECT pending houses
if "SELECT DISTINCT ON (h.id)" in sql and "yandex_realty_nb" in sql:
lim = params.get("lim", len(self._pending_rows))
force = params.get("force", False)
out = []
for r in self._pending_rows[:lim]:
ext_id = r.get("ext_id")
if not force and ext_id in self.enrichment:
continue
# Reflect persisted slug
slug = self.slug_updates.get(r["house_id"]) or r.get("yandex_jk_slug")
out.append({**r, "yandex_jk_slug": slug})
return _FakeResult(rows=out)
# UPDATE houses SET yandex_jk_slug
if "UPDATE houses" in sql and "yandex_jk_slug" in sql:
self.slug_updates[params["hid"]] = params["slug"]
return _FakeResult()
# Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment
if "SELECT 1 FROM market.yandex_jk_enrichment" in sql:
ext_id = params.get("ext_id")
if ext_id in self.enrichment:
return _FakeResult(fetchone=object())
return _FakeResult(fetchone=None)
# UPSERT market.yandex_jk_enrichment
if "INSERT INTO market.yandex_jk_enrichment" in sql:
self.enrichment[params["ext_id"]] = dict(params)
return _FakeResult()
return _FakeResult()
def begin_nested(self):
sp = _FakeSavepoint(self)
self._sp_stack.append(sp)
return sp
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
class _FakeSavepoint:
def __init__(self, db: FakeDB):
self._db = db
def commit(self):
pass
def rollback(self):
pass
# ── Tests: count_yandex_newbuilding_houses ───────────────────────────────────
def test_count_returns_sizing_dict():
db = FakeDB([{"house_id": 1, "ext_id": "100", "yandex_jk_slug": None}])
sizing = count_yandex_newbuilding_houses(db)
assert "total" in sizing
assert "fetchable" in sizing
assert "pending" in sizing
assert sizing["total"] >= 0
# ── Tests: enrich_yandex_newbuilding_sweep ───────────────────────────────────
@pytest.mark.asyncio
async def test_dry_run_returns_counts_without_fetch():
"""dry_run: популяция заполнена, fetch не вызван."""
db = FakeDB(
[
{"house_id": 1, "ext_id": "111", "yandex_jk_slug": None, "yandex_jk_id": None},
]
)
result = await enrich_yandex_newbuilding_sweep(db, limit=5, dry_run=True)
assert isinstance(result, YandexNewbuildingSweepResult)
assert result.processed == 0 # dry_run = no processing
assert result.rows_inserted == 0
assert result.total >= 0
@pytest.mark.asyncio
async def test_sweep_with_existing_slug_upserts_enrichment():
"""House с уже известным slug: fetch_jk вызван, UPSERT market + счётчик."""
db = FakeDB(
[
{
"house_id": 10,
"ext_id": "999",
"yandex_jk_slug": "tatlin",
"yandex_jk_id": "999",
}
]
)
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo
fake_info = YandexNewbuildingInfo(
ext_id="999",
ext_slug="tatlin",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-999/",
name="ЖК Татлин",
developer_name="ПРИНЦИПЗ",
lat=56.855,
lon=60.576,
rating=4.3,
ratings_count=100,
text_reviews_count=25,
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=fake_info)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.processed == 1
assert result.succeeded == 1
assert result.rows_inserted == 1
assert result.failed_fetch == 0
assert result.failed_resolve == 0
assert "999" in db.enrichment
assert db.enrichment["999"]["name"] == "ЖК Татлин"
@pytest.mark.asyncio
async def test_sweep_resolves_slug_when_missing():
"""House без slug: resolve_yandex_jk_slug вызван, slug сохранён, fetch_jk вызван."""
db = FakeDB(
[
{
"house_id": 20,
"ext_id": "888",
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo
fake_info = YandexNewbuildingInfo(
ext_id="888",
ext_slug="testjk",
source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/testjk-888/",
name="ЖК Тест",
lat=56.8,
lon=60.5,
)
with (
patch(
"app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug",
new_callable=AsyncMock,
return_value="testjk",
) as mock_resolve,
patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper,
):
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=fake_info)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
mock_resolve.assert_called_once_with("888", city="ekaterinburg")
assert result.resolved_slug == 1
assert result.succeeded == 1
assert result.rows_inserted == 1
# Slug persisted to houses
assert db.slug_updates.get(20) == "testjk"
assert "888" in db.enrichment
@pytest.mark.asyncio
async def test_sweep_failed_resolve_counts():
"""resolve_yandex_jk_slug returns None → failed_resolve += 1, no upsert."""
db = FakeDB(
[
{
"house_id": 30,
"ext_id": "777",
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
with patch(
"app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug",
new_callable=AsyncMock,
return_value=None,
):
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_resolve == 1
assert result.succeeded == 0
assert result.rows_inserted == 0
assert "777" not in db.enrichment
@pytest.mark.asyncio
async def test_sweep_failed_fetch_counts():
"""fetch_jk returns None → failed_fetch += 1, no upsert."""
db = FakeDB(
[
{
"house_id": 40,
"ext_id": "666",
"yandex_jk_slug": "existingslug",
"yandex_jk_id": "666",
}
]
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=None)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_fetch == 1
assert result.succeeded == 0
assert "666" not in db.enrichment
@pytest.mark.asyncio
async def test_sweep_idempotent_skip_already_enriched():
"""force=False: дом уже в enrichment → пропускается без fetch."""
db = FakeDB(
[
{
"house_id": 50,
"ext_id": "555",
"yandex_jk_slug": "existingslug",
"yandex_jk_id": "555",
}
]
)
# Pre-populate enrichment
db.enrichment["555"] = {"ext_id": "555", "name": "Already enriched"}
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock(return_value=None)
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
# The SELECT filters out already-enriched rows (force=False in SQL),
# so processed=0 (SELECT returns empty)
assert result.succeeded == 0
scraper_instance.fetch_jk.assert_not_called()
@pytest.mark.asyncio
async def test_sweep_no_ext_id_fails_resolve():
"""House без ext_id и без slug → failed_resolve (нечего резолвить)."""
db = FakeDB(
[
{
"house_id": 60,
"ext_id": None,
"yandex_jk_slug": None,
"yandex_jk_id": None,
}
]
)
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
assert result.failed_resolve == 1
assert result.succeeded == 0
@pytest.mark.asyncio
async def test_sweep_slug_present_but_ext_id_null_skips_fetch():
"""House с jk_slug но ext_id=NULL → guard срабатывает, fetch_jk НЕ вызван.
M1 review: пустой jk_id даёт URL /{slug}-/, который является битым.
Ожидаем failed_fetch += 1 и scraper.fetch_jk не вызван.
"""
db = FakeDB(
[
{
"house_id": 70,
"ext_id": None,
"yandex_jk_slug": "tatlin",
"yandex_jk_id": None,
}
]
)
with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper:
scraper_instance = MagicMock()
scraper_instance.fetch_jk = AsyncMock()
mock_scraper.return_value = scraper_instance
result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0)
scraper_instance.fetch_jk.assert_not_called()
assert result.failed_fetch == 1
assert result.succeeded == 0
assert result.rows_inserted == 0