All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
CI / backend-tests (pull_request) Has been skipped
`run_geocode_missing_listings` рекламирует `budget_sec` как максимальное время прогона, но проверка стояла после возврата из батча. Внутри батча цикл шёл по всем 200 адресам и часов не смотрел, то есть фактический потолок был `budget_sec + один полный батч`. Замер: прогон 5017 (27.08, `budget_sec=1800`) шёл 3150 с — 175 % бюджета, и вышел не по бюджету, а по дренажу: бюджетная ветка за 52 минуты не выполнилась ни разу. Пока адрес стоил ~1.9 с это терялось в шуме. После общего ограничителя темпа Nominatim (#2953) средняя цена 4.5 с, а на трудном хвосте (tier-1 + до 4 typo-вариантов под паузой 1 с, плюс retry×3) — до 33 с. Полный батч из таких адресов уезжает на ~110 минут поверх бюджета, при окне расписания 06:00–09:00. Дедлайн теперь передаётся В батч и проверяется на каждом адресе. Оборванный батч — штатный исход: `geocode_tried_at` проставлен только у обработанных пар, остальные попадут в выборку следующего прогона. Отдельный флаг `budget_exhausted` нужен потому, что `addresses_total` на оборванном батче равен размеру ВЫБОРКИ (== batch_size) — ветка дренажа `addresses_total < batch_size` не сработала бы, и обёртка крутила бы цикл дальше. Тест на это падает без флага (проверено снятием ветки). Тесты: 5 новых, все три несущие проверки падают без соответствующей правки. 37 passed локально. Closes #3151
1025 lines
41 KiB
Python
1025 lines
41 KiB
Python
"""Tests for geocode_missing_listings batch task."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
import sys
|
||
import time
|
||
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.geocoder import GeocodeResult # noqa: E402
|
||
from app.tasks.geocode_missing import ( # noqa: E402
|
||
GeocodeBackfillResult,
|
||
geocode_missing_listings,
|
||
run_geocode_missing_listings,
|
||
)
|
||
|
||
|
||
def _make_geocode_result(provider: str = "nominatim") -> GeocodeResult:
|
||
return GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.605,
|
||
full_address="Екатеринбург, ул. Малышева, 30",
|
||
provider=provider, # type: ignore[arg-type]
|
||
confidence="exact",
|
||
)
|
||
|
||
|
||
def _mock_db_rows(rows: list[dict]) -> MagicMock:
|
||
"""Возвращает mock Session где .execute().mappings().all() → rows."""
|
||
db = MagicMock()
|
||
mappings_mock = MagicMock()
|
||
mappings_mock.all.return_value = rows
|
||
db.execute.return_value.mappings.return_value = mappings_mock
|
||
# execute().rowcount для UPDATE
|
||
db.execute.return_value.rowcount = len(rows)
|
||
return db
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_empty_returns_zero_result() -> None:
|
||
"""Нет адресов с NULL coords → возвращает нулевой результат без ошибок."""
|
||
db = _mock_db_rows([])
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=50)
|
||
|
||
assert isinstance(result, GeocodeBackfillResult)
|
||
assert result.addresses_total == 0
|
||
assert result.addresses_processed == 0
|
||
assert result.listings_updated == 0
|
||
mock_geo.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_processes_pending_addresses() -> None:
|
||
"""2 unique addresses → geocode вызывается 2 раза, listings_updated суммируется."""
|
||
rows = [
|
||
{"address": "ул. Малышева, 30", "listings_count": 3},
|
||
{"address": "ул. Ленина, 10", "listings_count": 1},
|
||
]
|
||
|
||
db = MagicMock()
|
||
# Первый call .execute().mappings().all() → rows (SELECT)
|
||
# Последующие .execute() → UPDATE (rowcount)
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
|
||
update_result_1 = MagicMock()
|
||
update_result_1.rowcount = 3
|
||
|
||
update_result_2 = MagicMock()
|
||
update_result_2.rowcount = 1
|
||
|
||
db.execute.side_effect = [select_result, update_result_1, update_result_2]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("nominatim"),
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.addresses_total == 2
|
||
assert result.addresses_processed == 2
|
||
assert result.addresses_geocoded == 2
|
||
assert result.addresses_failed == 0
|
||
assert result.listings_updated == 4 # 3 + 1
|
||
assert result.cache_misses == 2
|
||
assert result.cache_hits == 0
|
||
assert mock_geo.call_count == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_dedup_addresses_single_geocode_call() -> None:
|
||
"""5 listings с тем же адресом → SELECT группирует → 1 geocode call."""
|
||
rows = [{"address": "ул. Тургенева, 5", "listings_count": 5}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
update_result.rowcount = 5
|
||
db.execute.side_effect = [select_result, update_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("nominatim"),
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert mock_geo.call_count == 1
|
||
assert result.listings_updated == 5
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_cache_hit_counted() -> None:
|
||
"""Адрес из geocode_cache → cache_hits++, cache_misses не растёт."""
|
||
rows = [{"address": "ул. Горького, 7", "listings_count": 2}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
update_result.rowcount = 2
|
||
db.execute.side_effect = [select_result, update_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("cache"),
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.cache_hits == 1
|
||
assert result.cache_misses == 0
|
||
assert result.addresses_geocoded == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_skips_failed_addresses() -> None:
|
||
"""Geocoder возвращает None → failed++, lat UPDATE не вызывается, tried_at помечается."""
|
||
rows = [{"address": "несуществующий адрес", "listings_count": 1}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_update_result = MagicMock()
|
||
tried_at_update_result.rowcount = 1
|
||
# execute side_effect: [SELECT, UPDATE tried_at]
|
||
db.execute.side_effect = [select_result, tried_at_update_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.addresses_failed == 1
|
||
assert result.addresses_geocoded == 0
|
||
assert result.listings_updated == 0
|
||
# commit вызывается для tried_at UPDATE (loop-safe backoff)
|
||
db.commit.assert_called_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_dry_run_does_not_update() -> None:
|
||
"""dry_run=True → addresses_processed > 0 но listings_updated == 0, нет commit."""
|
||
rows = [
|
||
{"address": "ул. Пушкина, 1", "listings_count": 3},
|
||
{"address": "пр. Ленина, 50", "listings_count": 2},
|
||
]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
db.execute.return_value = select_result
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("nominatim"),
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200, dry_run=True)
|
||
|
||
assert result.addresses_processed == 2
|
||
assert result.addresses_geocoded == 2
|
||
assert result.listings_updated == 0
|
||
db.commit.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_batch_size_respected() -> None:
|
||
"""batch_size=3 → SQL LIMIT=3 (проверяем параметр в вызове execute)."""
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = []
|
||
db.execute.return_value = select_result
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
|
||
await geocode_missing_listings(db, batch_size=3)
|
||
|
||
# Первый вызов execute — SELECT с LIMIT
|
||
first_call = db.execute.call_args_list[0]
|
||
params = first_call[0][1] # positional arg[1] = params dict
|
||
assert params["limit"] == 3
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_geocode_exception_counted_as_failed() -> None:
|
||
"""Geocoder raises Exception → failed++, tried_at помечается, обработка продолжается."""
|
||
rows = [
|
||
{"address": "ул. Сломанная, 1", "listings_count": 1},
|
||
{"address": "ул. Рабочая, 5", "listings_count": 2},
|
||
]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
tried_at_result.rowcount = 1
|
||
update_result = MagicMock()
|
||
update_result.rowcount = 2
|
||
# SELECT → tried_at UPDATE (exception path) → lat/lon UPDATE (success path)
|
||
db.execute.side_effect = [select_result, tried_at_result, update_result]
|
||
|
||
geo_side_effects = [RuntimeError("network error"), _make_geocode_result("nominatim")]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
side_effect=geo_side_effects,
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.addresses_failed == 1
|
||
assert result.addresses_geocoded == 1
|
||
assert result.listings_updated == 2
|
||
|
||
|
||
# ── New tests: geocode_tried_at / loop-safe / run_geocode_missing_listings ───
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_failure_marks_tried_at() -> None:
|
||
"""geo=None → UPDATE geocode_tried_at выполняется (loop-safe backoff)."""
|
||
rows = [{"address": "несуществующий адрес", "listings_count": 2}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
db.execute.side_effect = [select_result, tried_at_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.addresses_failed == 1
|
||
assert result.listings_updated == 0
|
||
# Второй execute — UPDATE tried_at
|
||
assert db.execute.call_count == 2
|
||
second_call_sql = str(db.execute.call_args_list[1][0][0])
|
||
assert "geocode_tried_at" in second_call_sql
|
||
db.commit.assert_called_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_exception_marks_tried_at() -> None:
|
||
"""geocode() raises → UPDATE geocode_tried_at выполняется, commit вызывается."""
|
||
rows = [{"address": "ул. Битая, 99", "listings_count": 1}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
db.execute.side_effect = [select_result, tried_at_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
side_effect=RuntimeError("timeout"),
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert result.addresses_failed == 1
|
||
assert db.execute.call_count == 2
|
||
second_call_sql = str(db.execute.call_args_list[1][0][0])
|
||
assert "geocode_tried_at" in second_call_sql
|
||
db.commit.assert_called_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_dry_run_no_tried_at_update() -> None:
|
||
"""dry_run=True + geo=None → tried_at НЕ обновляется, commit не вызывается."""
|
||
rows = [{"address": "несуществующий адрес", "listings_count": 1}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
db.execute.return_value = select_result
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
):
|
||
result = await geocode_missing_listings(db, batch_size=200, dry_run=True)
|
||
|
||
assert result.addresses_failed == 1
|
||
# Только SELECT, никаких UPDATE
|
||
assert db.execute.call_count == 1
|
||
db.commit.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_recent_tried_at_excluded_via_where() -> None:
|
||
"""SELECT содержит geocode_tried_at IS NULL OR tried_at < NOW() - 7d (loop-safe WHERE)."""
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = []
|
||
db.execute.return_value = select_result
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
|
||
await geocode_missing_listings(db, batch_size=10)
|
||
|
||
first_call = db.execute.call_args_list[0]
|
||
sql_text = str(first_call[0][0])
|
||
assert "geocode_tried_at IS NULL" in sql_text
|
||
assert "7 days" in sql_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_select_filters_is_active() -> None:
|
||
"""SELECT содержит `AND is_active` (#2604 п.1).
|
||
|
||
На проде очередь без этого фильтра была на 98.5% забита is_active=false
|
||
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без улицы и дома;
|
||
`ORDER BY listings_count DESC` ставил самый мусорный адрес («Новосибирская
|
||
обл.,Новосибирск», 214 listings) В НАЧАЛО очереди — весь Nominatim-бюджет
|
||
(1 req/sec) съедался мусором, до реальных активных адресов дело не доходило
|
||
(8 ночных прогонов подряд: saved=0). Falsification-проба: на коде ДО фикса
|
||
`"AND is_active" in sql_text` ложно, тест падает; после фикса проходит.
|
||
"""
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = []
|
||
db.execute.return_value = select_result
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
|
||
await geocode_missing_listings(db, batch_size=10)
|
||
|
||
first_call = db.execute.call_args_list[0]
|
||
sql_text = str(first_call[0][0])
|
||
assert "AND is_active" in sql_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_geocode_missing_listings_terminates_on_drained() -> None:
|
||
"""run_geocode_missing_listings завершается когда addresses_total == 0 (ничего pending)."""
|
||
db = MagicMock()
|
||
drained = GeocodeBackfillResult(addresses_total=0)
|
||
|
||
with (
|
||
patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
return_value=drained,
|
||
),
|
||
patch("app.tasks.geocode_missing.runs_mod") as mock_runs,
|
||
):
|
||
result = await run_geocode_missing_listings(db, run_id=42, params={})
|
||
|
||
mock_runs.update_heartbeat.assert_called()
|
||
mock_runs.mark_done.assert_called_once()
|
||
mock_runs.mark_failed.assert_not_called()
|
||
assert result.addresses_total == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_geocode_missing_listings_terminates_on_partial_batch() -> None:
|
||
"""Последний batch меньше batch_size → завершаем (дренаж)."""
|
||
db = MagicMock()
|
||
partial = GeocodeBackfillResult(
|
||
addresses_total=50, # < batch_size=200
|
||
addresses_processed=50,
|
||
listings_updated=30,
|
||
)
|
||
|
||
with (
|
||
patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
return_value=partial,
|
||
),
|
||
patch("app.tasks.geocode_missing.runs_mod") as mock_runs,
|
||
):
|
||
result = await run_geocode_missing_listings(db, run_id=7, params={"batch_size": 200})
|
||
|
||
mock_runs.mark_done.assert_called_once()
|
||
assert result.addresses_processed == 50
|
||
assert result.listings_updated == 30
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_geocode_missing_listings_mark_failed_on_exception() -> None:
|
||
"""При исключении внутри loop: mark_failed вызывается, re-raise."""
|
||
db = MagicMock()
|
||
|
||
with (
|
||
patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
side_effect=RuntimeError("DB down"),
|
||
),
|
||
patch("app.tasks.geocode_missing.runs_mod") as mock_runs,
|
||
):
|
||
with pytest.raises(RuntimeError, match="DB down"):
|
||
await run_geocode_missing_listings(db, run_id=99, params={})
|
||
|
||
mock_runs.mark_failed.assert_called_once()
|
||
mock_runs.mark_done.assert_not_called()
|
||
|
||
|
||
# ── (address, city) pair grouping / city_hint (#2594 шаг 2/3) ────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_same_address_different_city_independent_calls_and_updates() -> None:
|
||
"""Ключевой сценарий #2594: два ряда с ОДИНАКОВЫМ текстом address, но РАЗНЫМИ
|
||
city — каждый должен получить свой geocode-вызов (city_hint) и свой UPDATE, не
|
||
затрагивающий другую пару. Раньше группировка была только по address: SELECT
|
||
группировал по тексту, а UPDATE бил по WHERE address = :addr без city — второй
|
||
UPDATE (для Нижнего Тагила) перезаписал бы координаты, уже проставленные первым
|
||
(для Екатеринбурга), и наоборот.
|
||
"""
|
||
rows = [
|
||
{"address": "ул. Победы, 30", "city": "Екатеринбург", "listings_count": 1},
|
||
{"address": "ул. Победы, 30", "city": "Нижний Тагил", "listings_count": 1},
|
||
]
|
||
|
||
ekb_geo = GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.605,
|
||
full_address="Екатеринбург, ул. Победы, 30",
|
||
provider="nominatim", # type: ignore[arg-type]
|
||
confidence="exact",
|
||
)
|
||
tagil_geo = GeocodeResult(
|
||
lat=57.910,
|
||
lon=59.985,
|
||
full_address="Нижний Тагил, ул. Победы, 30",
|
||
provider="nominatim", # type: ignore[arg-type]
|
||
confidence="exact",
|
||
)
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_ekb = MagicMock()
|
||
update_ekb.rowcount = 1
|
||
update_tagil = MagicMock()
|
||
update_tagil.rowcount = 1
|
||
db.execute.side_effect = [select_result, update_ekb, update_tagil]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
side_effect=[ekb_geo, tagil_geo],
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
# 2 отдельных geocode-вызова — по одному на пару (address, city), не 1 на address.
|
||
assert mock_geo.call_count == 2
|
||
ekb_call = mock_geo.call_args_list[0]
|
||
tagil_call = mock_geo.call_args_list[1]
|
||
assert ekb_call.args[0] == "ул. Победы, 30"
|
||
assert ekb_call.kwargs["city_hint"] == "Екатеринбург"
|
||
assert tagil_call.args[0] == "ул. Победы, 30"
|
||
assert tagil_call.kwargs["city_hint"] == "Нижний Тагил"
|
||
|
||
# 2 отдельных UPDATE, каждый со своим city в WHERE — не задевает другую пару.
|
||
update_calls = db.execute.call_args_list[1:]
|
||
assert len(update_calls) == 2
|
||
expected = [("Екатеринбург", 56.838), ("Нижний Тагил", 57.910)]
|
||
for call, (expected_city, expected_lat) in zip(update_calls, expected, strict=True):
|
||
sql = str(call.args[0])
|
||
params = call.args[1]
|
||
assert "IS NOT DISTINCT FROM" in sql
|
||
assert params["addr"] == "ул. Победы, 30"
|
||
assert params["city"] == expected_city
|
||
assert params["lat"] == pytest.approx(expected_lat)
|
||
|
||
assert result.addresses_processed == 2
|
||
assert result.addresses_geocoded == 2
|
||
assert result.listings_updated == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_null_city_group_uses_is_not_distinct_from() -> None:
|
||
"""city IS NULL — своя группа. city_hint=None передаётся геокодеру, UPDATE
|
||
использует IS NOT DISTINCT FROM (обычный `=` никогда не совпал бы с NULL —
|
||
группа NULL-city вообще не обновилась бы обычным equality-сравнением)."""
|
||
rows = [{"address": "ул. Дружинина, 33", "city": None, "listings_count": 2}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
update_result.rowcount = 2
|
||
db.execute.side_effect = [select_result, update_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("nominatim"),
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
mock_geo.assert_called_once_with("ул. Дружинина, 33", db, city_hint=None)
|
||
|
||
update_call = db.execute.call_args_list[1]
|
||
sql = str(update_call.args[0])
|
||
params = update_call.args[1]
|
||
assert "IS NOT DISTINCT FROM" in sql
|
||
assert params["city"] is None
|
||
assert result.listings_updated == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_select_groups_by_address_and_city() -> None:
|
||
"""SELECT содержит GROUP BY address, city — НЕ только по address (#2594)."""
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = []
|
||
db.execute.return_value = select_result
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock):
|
||
await geocode_missing_listings(db, batch_size=10)
|
||
|
||
first_call = db.execute.call_args_list[0]
|
||
sql_text = str(first_call[0][0])
|
||
assert "GROUP BY address, city" in sql_text
|
||
assert "SELECT address, city, COUNT(*)" in sql_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_failed_pair_tried_at_update_scoped_to_city() -> None:
|
||
"""Failed geocode (geo=None) для (address, city) → UPDATE tried_at ограничен
|
||
ЭТОЙ парой (IS NOT DISTINCT FROM city), не всеми строками с тем же address."""
|
||
rows = [{"address": "несуществующий адрес", "city": "Нижний Тагил", "listings_count": 1}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
db.execute.side_effect = [select_result, tried_at_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
mock_geo.assert_called_once_with("несуществующий адрес", db, city_hint="Нижний Тагил")
|
||
assert result.addresses_failed == 1
|
||
|
||
update_call = db.execute.call_args_list[1]
|
||
sql = str(update_call.args[0])
|
||
params = update_call.args[1]
|
||
assert "IS NOT DISTINCT FROM" in sql
|
||
assert params["city"] == "Нижний Тагил"
|
||
|
||
|
||
# ── #2604 п.1/п.2: UPDATE decisions — locked in by test, not just comment ────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_success_update_not_filtered_by_is_active() -> None:
|
||
"""Decision #2604 п.1 (UPDATE lat/lon): намеренно БЕЗ `is_active` в WHERE.
|
||
|
||
Координаты — свойство физического адреса (address, city), не свойство
|
||
конкретного listing. is_active=false дубликат ЭТОЙ ЖЕ пары никогда не будет
|
||
независимо отобран SELECT'ом (он навсегда исключён оттуда) — без unfiltered
|
||
UPDATE такой дубликат остался бы с NULL lat/lon навсегда, хотя ответ уже
|
||
получен и оплачен Nominatim-вызовом активного листинга.
|
||
"""
|
||
rows = [{"address": "ул. Тестовая, 1", "city": "Екатеринбург", "listings_count": 2}]
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
update_result.rowcount = 2
|
||
db.execute.side_effect = [select_result, update_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result("nominatim"),
|
||
):
|
||
await geocode_missing_listings(db, batch_size=200)
|
||
|
||
update_call = db.execute.call_args_list[1]
|
||
sql = str(update_call.args[0])
|
||
assert "is_active" not in sql
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_notfound_tried_at_update_not_filtered_by_is_active() -> None:
|
||
"""Decision #2604 п.2 (geo is None → tried_at UPDATE): намеренно БЕЗ `is_active`.
|
||
|
||
tried_at — backoff-метка для (address, city) КАК ТЕКСТА, не для конкретного
|
||
listing; is_active=false дубликат и так никогда не переотбирается SELECT'ом.
|
||
Единственный сценарий где это важно — реактивация (is_active → true) той же
|
||
строки: backoff уже стоит и корректно защищает от немедленного повтора
|
||
заведомо неудачного адреса.
|
||
"""
|
||
rows = [{"address": "несуществующий адрес", "city": None, "listings_count": 1}]
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
db.execute.side_effect = [select_result, tried_at_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=None,
|
||
):
|
||
await geocode_missing_listings(db, batch_size=200)
|
||
|
||
update_call = db.execute.call_args_list[1]
|
||
sql = str(update_call.args[0])
|
||
assert "is_active" not in sql
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_exception_tried_at_update_not_filtered_by_is_active() -> None:
|
||
"""Decision #2604 п.2 (geocode() raises → tried_at UPDATE): та же логика, что и
|
||
в NOT-FOUND ветке выше — намеренно БЕЗ `is_active`, зафиксировано тестом."""
|
||
rows = [{"address": "ул. Битая, 99", "city": None, "listings_count": 1}]
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
tried_at_result = MagicMock()
|
||
db.execute.side_effect = [select_result, tried_at_result]
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
side_effect=RuntimeError("timeout"),
|
||
):
|
||
await geocode_missing_listings(db, batch_size=200)
|
||
|
||
update_call = db.execute.call_args_list[1]
|
||
sql = str(update_call.args[0])
|
||
assert "is_active" not in sql
|
||
|
||
|
||
# ── Integration-style: estimator Avito exclusion removed ─────────────────────
|
||
|
||
|
||
def test_estimator_fetch_analogs_includes_avito() -> None:
|
||
"""estimator._fetch_analogs не содержит AND source <> 'avito'."""
|
||
import inspect
|
||
|
||
from app.services import estimator
|
||
|
||
source = inspect.getsource(estimator._fetch_analogs)
|
||
assert "source <> 'avito'" not in source, (
|
||
"Avito exclusion должен быть удалён из estimator._fetch_analogs"
|
||
)
|
||
|
||
|
||
# ── Admin endpoint smoke (schema-only, no real DB) ────────────────────────────
|
||
|
||
|
||
def test_admin_geocode_missing_endpoint_exists() -> None:
|
||
"""GET /api/v1/admin/scrape/geocode-missing-listings/status → endpoint registered."""
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1 import admin as admin_module
|
||
from app.core.db import get_db
|
||
|
||
app = FastAPI()
|
||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||
|
||
mock_db = MagicMock()
|
||
# status endpoint calls execute twice — stats + unique + cache_size
|
||
stats_result = MagicMock()
|
||
stats_result.mappings.return_value.all.return_value = []
|
||
scalar_result = MagicMock()
|
||
scalar_result.scalar.return_value = 0
|
||
mock_db.execute.side_effect = [stats_result, scalar_result, scalar_result]
|
||
|
||
app.dependency_overrides[get_db] = lambda: mock_db
|
||
|
||
client = TestClient(app)
|
||
resp = client.get("/api/v1/admin/scrape/geocode-missing-listings/status")
|
||
# Если endpoint зарегистрирован корректно — не 404
|
||
assert resp.status_code != 404
|
||
|
||
|
||
def test_admin_geocode_missing_post_dry_run_endpoint_exists() -> None:
|
||
"""POST /api/v1/admin/scrape/geocode-missing-listings?dry_run=true → endpoint registered."""
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1 import admin as admin_module
|
||
from app.core.db import get_db
|
||
|
||
app = FastAPI()
|
||
app.include_router(admin_module.router, prefix="/api/v1/admin")
|
||
|
||
# Mock db: не нужен реальный — тест только на routing
|
||
mock_db = MagicMock()
|
||
app.dependency_overrides[get_db] = lambda: mock_db
|
||
|
||
client = TestClient(app)
|
||
# Без реального geocode — mocked через patch
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
return_value=GeocodeBackfillResult(
|
||
addresses_total=0,
|
||
addresses_processed=0,
|
||
addresses_geocoded=0,
|
||
addresses_failed=0,
|
||
listings_updated=0,
|
||
cache_hits=0,
|
||
cache_misses=0,
|
||
duration_sec=0.01,
|
||
),
|
||
):
|
||
resp = client.post(
|
||
"/api/v1/admin/scrape/geocode-missing-listings?dry_run=true&batch_size=10"
|
||
)
|
||
|
||
assert resp.status_code != 404
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
assert "status" in data
|
||
assert "addresses_total" in data
|
||
|
||
|
||
# ── admin.geocode_missing (per-ID endpoint) city_hint (#2594 шаг 2/3) ────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("target", ["listings", "deals"])
|
||
async def test_admin_geocode_missing_passes_city_hint(target: str) -> None:
|
||
"""POST /admin/geocode-missing читает city из SELECT и передаёт как city_hint.
|
||
|
||
Раньше endpoint читал только row["address"] и звал geocode(clean, db) без
|
||
города — голый тагильский адрес без города в тексте уходил в Екатеринбург.
|
||
|
||
Оба target'а (#2603): колонка city есть и в listings (миграция 196), и в
|
||
deals (миграция 177) — SELECT + прокидывание хинта общие для обеих веток,
|
||
расходится только extra_filter, поэтому deals обязан покрываться тем же
|
||
контрактом.
|
||
"""
|
||
from app.api.v1 import admin as admin_module
|
||
|
||
rows = [{"id": 55, "address": "ул. Победы, 30", "city": "Нижний Тагил"}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
remaining_result = MagicMock()
|
||
remaining_result.scalar.return_value = 0
|
||
db.execute.side_effect = [select_result, update_result, remaining_result]
|
||
|
||
geo = GeocodeResult(
|
||
lat=57.910,
|
||
lon=59.985,
|
||
full_address="Нижний Тагил, ул. Победы, 30",
|
||
provider="nominatim", # type: ignore[arg-type]
|
||
confidence="exact",
|
||
)
|
||
|
||
with patch(
|
||
"app.api.v1.admin.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=geo,
|
||
) as mock_geo:
|
||
result = await admin_module.geocode_missing(
|
||
db,
|
||
limit=100,
|
||
target=target, # type: ignore[arg-type]
|
||
)
|
||
|
||
mock_geo.assert_called_once_with("ул. Победы, 30", db, city_hint="Нижний Тагил")
|
||
assert result["geocoded"] == 1
|
||
assert result["skipped"] == 0
|
||
# SELECT адресован именно запрошенной таблице (обе несут колонку city).
|
||
assert f"FROM {target}" in str(db.execute.call_args_list[0][0][0])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("target", ["listings", "deals"])
|
||
async def test_admin_geocode_missing_drops_junk_city_hint(target: str) -> None:
|
||
"""Мусорный город из колонки НЕ уходит в city_hint (#2603).
|
||
|
||
`deals.city` росреестровое: в хвосте «Бессонова», «Билейский рыбопитомник» —
|
||
улицы/урочища, а не города. Хинт из такого значения закрывает EKB-локальные
|
||
тиры и уезжает префиксом в запрос провайдеру, т.е. хуже отсутствия хинта.
|
||
Гейт общий с двумя другими DB-колоночными callers (geocoder.known_city_hint).
|
||
"""
|
||
from app.api.v1 import admin as admin_module
|
||
|
||
rows = [{"id": 56, "address": "ул. Бессонова, 11", "city": "Бессонова"}]
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = rows
|
||
update_result = MagicMock()
|
||
remaining_result = MagicMock()
|
||
remaining_result.scalar.return_value = 0
|
||
db.execute.side_effect = [select_result, update_result, remaining_result]
|
||
|
||
geo = GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.605,
|
||
full_address="Екатеринбург, ул. Бессонова, 11",
|
||
provider="nominatim", # type: ignore[arg-type]
|
||
confidence="exact",
|
||
)
|
||
|
||
with patch(
|
||
"app.api.v1.admin.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=geo,
|
||
) as mock_geo:
|
||
await admin_module.geocode_missing(
|
||
db,
|
||
limit=100,
|
||
target=target, # type: ignore[arg-type]
|
||
)
|
||
|
||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_listings_drops_junk_city_hint() -> None:
|
||
"""Тот же гейт в ночной задаче: мусорный city → city_hint=None (#2603).
|
||
|
||
Для listings.city это сегодня no-op (скрапер пишет шесть кураторских имён),
|
||
но инвариант «в geocode() уходит только словарный город» держим единым для
|
||
всех трёх DB-колоночных callers, чтобы седьмой не пришлось чинить заново.
|
||
Сырой city при этом остаётся ключом группы для UPDATE.
|
||
"""
|
||
rows = [{"address": "ул. Бессонова, 11", "city": "Бессонова", "listings_count": 2}]
|
||
db = _mock_db_rows(rows)
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result(),
|
||
) as mock_geo:
|
||
await geocode_missing_listings(db, batch_size=10)
|
||
|
||
mock_geo.assert_called_once_with("ул. Бессонова, 11", db, city_hint=None)
|
||
update_binds = [
|
||
c[0][1]
|
||
for c in db.execute.call_args_list
|
||
if "UPDATE listings" in str(c[0][0]) and "SET lat" in str(c[0][0])
|
||
]
|
||
assert update_binds[0]["city"] == "Бессонова" # группа UPDATE — по сырому городу
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_geocode_missing_select_includes_city_column() -> None:
|
||
"""SELECT в admin.geocode_missing содержит колонку city (#2594)."""
|
||
from app.api.v1 import admin as admin_module
|
||
|
||
db = MagicMock()
|
||
select_result = MagicMock()
|
||
select_result.mappings.return_value.all.return_value = []
|
||
remaining_result = MagicMock()
|
||
remaining_result.scalar.return_value = 0
|
||
db.execute.side_effect = [select_result, remaining_result]
|
||
|
||
with patch("app.api.v1.admin.geocode", new_callable=AsyncMock):
|
||
await admin_module.geocode_missing(db, limit=100, target="listings")
|
||
|
||
first_call = db.execute.call_args_list[0]
|
||
sql_text = str(first_call[0][0])
|
||
assert "SELECT id, address, city" in sql_text
|
||
|
||
|
||
# ── #3151: бюджет проверяется ВНУТРИ батча, а не только между батчами ────────
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_stops_immediately_on_expired_deadline() -> None:
|
||
"""Дедлайн уже прошёл → ни одного geocode-вызова, батч помечен оборванным."""
|
||
rows = [
|
||
{"address": f"ул. Тестовая, {i}", "city": "Екатеринбург", "listings_count": 1}
|
||
for i in range(5)
|
||
]
|
||
db = _mock_db_rows(rows)
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", new_callable=AsyncMock) as mock_geo:
|
||
result = await geocode_missing_listings(
|
||
db, batch_size=200, deadline_monotonic=time.monotonic() - 1.0
|
||
)
|
||
|
||
mock_geo.assert_not_called()
|
||
assert result.budget_exhausted is True
|
||
assert result.addresses_processed == 0
|
||
assert result.addresses_total == 5 # отобрали 5, обработали 0 — это разные числа
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_breaks_mid_batch_when_deadline_passes() -> None:
|
||
"""Дедлайн наступает посреди батча → обрабатываем часть, остальное — следующему прогону."""
|
||
rows = [
|
||
{"address": f"ул. Тестовая, {i}", "city": "Екатеринбург", "listings_count": 1}
|
||
for i in range(6)
|
||
]
|
||
db = _mock_db_rows(rows)
|
||
|
||
async def _slow_geocode(*_args, **_kwargs):
|
||
await asyncio.sleep(0.15)
|
||
return _make_geocode_result()
|
||
|
||
with patch("app.tasks.geocode_missing.geocode", side_effect=_slow_geocode):
|
||
result = await geocode_missing_listings(
|
||
db, batch_size=200, deadline_monotonic=time.monotonic() + 0.2
|
||
)
|
||
|
||
assert result.budget_exhausted is True
|
||
assert result.addresses_processed < len(rows)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_geocode_missing_without_deadline_processes_whole_batch() -> None:
|
||
"""deadline_monotonic=None (умолчание) — поведение прежнее, батч разбирается целиком."""
|
||
rows = [
|
||
{"address": f"ул. Тестовая, {i}", "city": "Екатеринбург", "listings_count": 1}
|
||
for i in range(4)
|
||
]
|
||
db = _mock_db_rows(rows)
|
||
|
||
with patch(
|
||
"app.tasks.geocode_missing.geocode",
|
||
new_callable=AsyncMock,
|
||
return_value=_make_geocode_result(),
|
||
) as mock_geo:
|
||
result = await geocode_missing_listings(db, batch_size=200)
|
||
|
||
assert mock_geo.call_count == 4
|
||
assert result.budget_exhausted is False
|
||
assert result.addresses_processed == 4
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_geocode_missing_listings_stops_on_budget_exhausted_flag() -> None:
|
||
"""Оборванный по бюджету батч останавливает run-обёртку.
|
||
|
||
Регрессия #3151: `addresses_total` на оборванном батче равен размеру ВЫБОРКИ,
|
||
то есть batch_size — ветка дренажа (`addresses_total < batch_size`) не сработает,
|
||
а `elapsed > budget_sec` в быстром тесте ложна. Без отдельного флага обёртка
|
||
крутила бы цикл дальше.
|
||
"""
|
||
db = MagicMock()
|
||
cut_short = GeocodeBackfillResult(
|
||
addresses_total=200, # == batch_size: дренаж не сработает
|
||
addresses_processed=12,
|
||
listings_updated=9,
|
||
budget_exhausted=True,
|
||
)
|
||
|
||
with (
|
||
patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
# список, а не постоянный возврат: если обёртка не остановится, тест
|
||
# упадёт на исчерпании side_effect, а не подвиснет
|
||
side_effect=[cut_short, cut_short, cut_short],
|
||
) as mock_batch,
|
||
patch("app.tasks.geocode_missing.runs_mod") as mock_runs,
|
||
):
|
||
result = await run_geocode_missing_listings(db, run_id=11, params={"batch_size": 200})
|
||
|
||
assert mock_batch.call_count == 1
|
||
mock_runs.mark_done.assert_called_once()
|
||
assert result.addresses_processed == 12
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_geocode_missing_listings_passes_deadline_to_batch() -> None:
|
||
"""Обёртка передаёт в батч дедлайн, а не только считает elapsed после возврата."""
|
||
db = MagicMock()
|
||
drained = GeocodeBackfillResult(addresses_total=0)
|
||
|
||
with (
|
||
patch(
|
||
"app.tasks.geocode_missing.geocode_missing_listings",
|
||
new_callable=AsyncMock,
|
||
return_value=drained,
|
||
) as mock_batch,
|
||
patch("app.tasks.geocode_missing.runs_mod"),
|
||
):
|
||
before = time.monotonic()
|
||
await run_geocode_missing_listings(db, run_id=12, params={"budget_sec": 90})
|
||
after = time.monotonic()
|
||
|
||
deadline = mock_batch.call_args.kwargs["deadline_monotonic"]
|
||
assert before + 90 <= deadline <= after + 90
|