gendesign/tradein-mvp/backend/tests/tasks/test_geocode_missing.py
bot-backend 8cdb195e18
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
fix(tradein/geocode): бюджет прогона не был потолком — проверялся только между батчами
`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
2026-08-27 19:10:53 +03:00

1025 lines
41 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 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