"""Unit tests for PR Q3 — backfill_houses_dadata.py. Coverage: - Happy path: row с NULL coords + qc_geo=0 → UPDATE с полным набором полей. - qc_geo=3 (city-level) → tracking only, lat/lon не overwrite'аются. - DaData returns None → tracking only (qc_geo / qc_house = NULL). - Per-row SAVEPOINT: exception от clean_address не валит batch. - --dry-run: nothing written в DB. - --limit N: батч обрезается до N даже если кандидатов больше. - --priority coords / cadnum / both — фильтр source-rows работает. - _is_enriched_result — accept / reject правила. - main() с отсутствующими DADATA_API_TOKEN / SECRET → SystemExit. No real Postgres. Session — MagicMock что записывает UPDATE и роутит SELECT side-effects (тот же pattern что в test_backfill_house_coords.py / test_backfill_listing_sources.py). """ from __future__ import annotations import os from unittest.mock import AsyncMock, MagicMock, patch # Settings требует DSN на import — set dummy DSN до любого app.* import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") import pytest from app.services.dadata import DadataAddressResult from scripts.backfill_houses_dadata import ( HouseRow, Stats, _is_enriched_result, _parse_args, _result_in_ekb, _run_backfill, _select_candidates, _update_house_attempt, _update_house_enriched, _with_city_context, main, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_dadata_result( *, qc_geo: int | None = 0, qc_house: int | None = 2, lat: float | None = 56.838, lon: float | None = 60.586, cadnum: str | None = "66:41:0704045:350", fias: str | None = "abcdef00-aaaa-bbbb-cccc-1234567890ab", canonical: str | None = "г Екатеринбург, ул Малышева, д 51", ) -> DadataAddressResult: """DadataAddressResult fixture с sensible defaults для tests.""" return DadataAddressResult( canonical_address=canonical, house_cadnum=cadnum, house_fias_id=fias, lat=lat, lon=lon, qc_geo=qc_geo, qc_house=qc_house, kladr_id=None, okato=None, oktmo=None, metro=[], raw={"test": True}, ) def _make_db_mock( coords_sample: list[dict] | None = None, cadnum_sample: list[dict] | None = None, listings_sample: list[dict] | None = None, ) -> tuple[MagicMock, list[dict]]: """MagicMock Session что: - возвращает coords_sample для coords-priority SELECT (`lat IS NULL OR lon IS NULL`) - возвращает listings_sample для listings-priority SELECT (WHERE содержит `cadastral_number IS NULL AND EXISTS ... listings`) - возвращает cadnum_sample для cadnum-priority SELECT (`cadastral_number IS NULL`) - записывает UPDATE houses в `updated` список (полные binds dict'ы) - корректно работает с `db.begin_nested()` контекст-менеджером Порядок проверок важен: все три SELECT'а теперь содержат `EXISTS ... listings` в projection (`AS has_active_listings`), поэтому listings-priority детектим по EXISTS *в WHERE* (`cadastral_number IS NULL AND EXISTS`) ДО общего `cadastral_number IS NULL` бранча. """ coords_sample = coords_sample or [] cadnum_sample = cadnum_sample or [] listings_sample = listings_sample or [] updated: list[dict] = [] db = MagicMock() db.begin_nested.return_value.__enter__ = lambda self: self db.begin_nested.return_value.__exit__ = lambda self, *a: False def execute_side_effect(sql, params=None): sql_str = str(sql) result = MagicMock() if "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str: lim = params.get("lim", len(coords_sample)) if params else len(coords_sample) result.mappings.return_value.all.return_value = coords_sample[:lim] elif "FROM houses" in sql_str and "cadastral_number IS NULL AND EXISTS" in sql_str: lim = params.get("lim", len(listings_sample)) if params else len(listings_sample) result.mappings.return_value.all.return_value = listings_sample[:lim] elif "FROM houses" in sql_str and "cadastral_number IS NULL" in sql_str: lim = params.get("lim", len(cadnum_sample)) if params else len(cadnum_sample) result.mappings.return_value.all.return_value = cadnum_sample[:lim] elif "UPDATE houses" in sql_str: updated.append(dict(params) if params else {}) return result db.execute.side_effect = execute_side_effect db.commit = MagicMock() db.rollback = MagicMock() db.close = MagicMock() return db, updated # --------------------------------------------------------------------------- # _is_enriched_result — accept / reject правила # --------------------------------------------------------------------------- def test_is_enriched_result_qc_geo_0_lat_lon_present_accepts(): res = _make_dadata_result(qc_geo=0, lat=56.838, lon=60.586) assert _is_enriched_result(res) is True def test_is_enriched_result_qc_geo_1_accepts(): """qc_geo=1 (street-level) — также accept'аем для overwrite.""" res = _make_dadata_result(qc_geo=1, lat=56.838, lon=60.586) assert _is_enriched_result(res) is True def test_is_enriched_result_qc_geo_2_rejects(): """qc_geo=2 (settlement) — слишком грубо для дома.""" res = _make_dadata_result(qc_geo=2) assert _is_enriched_result(res) is False def test_is_enriched_result_qc_geo_3_rejects(): """qc_geo=3 (city) — точно не дом.""" res = _make_dadata_result(qc_geo=3) assert _is_enriched_result(res) is False def test_is_enriched_result_none_input_rejects(): assert _is_enriched_result(None) is False def test_is_enriched_result_qc_geo_ok_but_no_coords_rejects(): """Defensive: qc_geo=0 но lat/lon отсутствуют → не overwrite.""" res = _make_dadata_result(qc_geo=0, lat=None, lon=None) assert _is_enriched_result(res) is False def test_is_enriched_result_qc_geo_ok_but_non_ekb_city_rejects(): """Safety: qc=0 + coords но город НЕ Екатеринбург → не overwrite. DaData без явного города мапит тёзки в Москву (баг dry-run 2026-05-27). Защищаемся post-filter'ом по `canonical_address`/`raw.city`. """ res = _make_dadata_result( qc_geo=0, lat=55.755, lon=37.617, canonical="г Москва, ул Просторная, д 1", ) assert _is_enriched_result(res) is False # --------------------------------------------------------------------------- # _with_city_context — pre-pending «Екатеринбург, » # --------------------------------------------------------------------------- def test_with_city_context_prepends_when_missing(): assert _with_city_context("улица Малышева, 125") == "Екатеринбург, улица Малышева, 125" def test_with_city_context_is_idempotent_when_city_present(): """Уже-полный адрес не префиксируется повторно.""" full = "Екатеринбург, улица Малышева, 125" assert _with_city_context(full) == full def test_with_city_context_case_insensitive(): """ЕКАТЕРИНБУРГ / екатеринбург — тоже не префиксируем.""" assert _with_city_context("ЕКАТЕРИНБУРГ, ул. Ленина") == "ЕКАТЕРИНБУРГ, ул. Ленина" assert _with_city_context("екатеринбург, ленина 1") == "екатеринбург, ленина 1" def test_with_city_context_empty_passthrough(): """Пустой / None не падает.""" assert _with_city_context("") == "" # --------------------------------------------------------------------------- # _result_in_ekb — городской post-filter # --------------------------------------------------------------------------- def test_result_in_ekb_uses_canonical_address_fallback(): """Если raw.city пустой — fallback на substring в canonical_address.""" res = _make_dadata_result(canonical="г Екатеринбург, ул Малышева, д 125") assert _result_in_ekb(res) is True def test_result_in_ekb_uses_raw_city_field(): """raw['city']='Екатеринбург' → True (даже если canonical другое).""" res = _make_dadata_result(canonical="ул Малышева, д 125") res = DadataAddressResult( canonical_address=res.canonical_address, house_cadnum=res.house_cadnum, house_fias_id=res.house_fias_id, lat=res.lat, lon=res.lon, qc_geo=res.qc_geo, qc_house=res.qc_house, kladr_id=None, okato=None, oktmo=None, metro=[], raw={"city": "Екатеринбург"}, ) assert _result_in_ekb(res) is True def test_result_in_ekb_rejects_moscow(): res = _make_dadata_result(canonical="г Москва, ул Просторная, д 1") assert _result_in_ekb(res) is False def test_result_in_ekb_none_input(): assert _result_in_ekb(None) is False # --------------------------------------------------------------------------- # _update_house_enriched — bind shape # --------------------------------------------------------------------------- def test_update_house_enriched_passes_full_payload(): """UPDATE houses содержит lat/lon/cadnum/fias/qc-codes и dadata_enriched_at = NOW().""" db = MagicMock() res = _make_dadata_result( qc_geo=0, qc_house=2, lat=56.838, lon=60.586, cadnum="66:41:0704045:350", fias="fias-uuid-here", ) _update_house_enriched(db, house_id=42, result=res) args, _kw = db.execute.call_args sql_str = str(args[0]) binds = args[1] assert "UPDATE houses" in sql_str assert "dadata_enriched_at = NOW()" in sql_str assert "cadastral_number" in sql_str assert "house_fias_id" in sql_str assert binds["id"] == 42 assert binds["lat"] == 56.838 assert binds["lon"] == 60.586 assert binds["cadnum"] == "66:41:0704045:350" assert binds["fias"] == "fias-uuid-here" assert binds["qc_geo"] == 0 assert binds["qc_house"] == 2 def test_update_house_attempt_writes_only_tracking_fields(): """attempt-write не должен затрагивать lat/lon/cadnum/fias columns.""" db = MagicMock() _update_house_attempt(db, house_id=7, qc_geo=3, qc_house=10) args, _kw = db.execute.call_args sql_str = str(args[0]) binds = args[1] assert "UPDATE houses" in sql_str assert "dadata_enriched_at = NOW()" in sql_str # Не должно быть UPDATE на coord-payload поля assert "lat =" not in sql_str assert "lon =" not in sql_str assert "cadastral_number =" not in sql_str assert "house_fias_id =" not in sql_str assert binds == {"id": 7, "qc_geo": 3, "qc_house": 10} # --------------------------------------------------------------------------- # _run_backfill — happy path qc_geo=0 # --------------------------------------------------------------------------- async def test_run_backfill_qc_geo_0_writes_enriched_update(): """Happy path — qc_geo=0 → UPDATE с полным payload, stats.enriched=1.""" row = HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None, cadastral_number=None) db, updated = _make_db_mock() fake = _make_dadata_result(qc_geo=0) with patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): stats = await _run_backfill( db, [(row, "coords")], batch="b1", dry_run=False ) assert stats.enriched == 1 assert stats.no_change == 0 assert stats.failed == 0 assert stats.processed == 1 assert len(updated) == 1 assert updated[0]["lat"] == 56.838 assert updated[0]["lon"] == 60.586 assert updated[0]["cadnum"] == "66:41:0704045:350" assert updated[0]["qc_geo"] == 0 assert db.commit.call_count == 1 assert stats.by_priority["coords"]["enriched"] == 1 # --------------------------------------------------------------------------- # _run_backfill — qc_geo=3 city-level → tracking-only # --------------------------------------------------------------------------- async def test_run_backfill_qc_geo_3_records_attempt_only(): """qc_geo=3 → НЕ overwrite coords, только tracking UPDATE.""" row = HouseRow( id=2, address="Екатеринбург (без улицы)", lat=None, lon=None, cadastral_number=None ) db, updated = _make_db_mock() fake = _make_dadata_result(qc_geo=3, qc_house=None, lat=None, lon=None, cadnum=None, fias=None) with patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): stats = await _run_backfill( db, [(row, "coords")], batch="b2", dry_run=False ) assert stats.enriched == 0 assert stats.no_change == 1 assert len(updated) == 1 # Tracking-only UPDATE — нет lat/lon/cadnum binds assert "lat" not in updated[0] assert "lon" not in updated[0] assert updated[0]["qc_geo"] == 3 assert stats.by_priority["coords"]["no_change"] == 1 # --------------------------------------------------------------------------- # _run_backfill — DaData returns None (graceful) # --------------------------------------------------------------------------- async def test_run_backfill_dadata_returns_none_records_attempt(): """clean_address возвращает None (HTTP error / unrecognized) → tracking only с NULL qc.""" row = HouseRow(id=3, address="несуществующая 99", lat=None, lon=None, cadastral_number=None) db, updated = _make_db_mock() with patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=None), ): stats = await _run_backfill( db, [(row, "coords")], batch="b3", dry_run=False ) assert stats.no_change == 1 assert stats.enriched == 0 assert len(updated) == 1 assert updated[0]["qc_geo"] is None assert updated[0]["qc_house"] is None # --------------------------------------------------------------------------- # Per-row SAVEPOINT — exception в clean_address не валит batch # --------------------------------------------------------------------------- async def test_run_backfill_clean_address_exception_isolated_to_row(): """Один clean_address raise → этот row failed, остальные processed normally.""" rows = [ (HouseRow(id=10, address="ул Bad", lat=None, lon=None, cadastral_number=None), "coords"), (HouseRow(id=11, address="ул Good 5", lat=None, lon=None, cadastral_number=None), "coords"), ] db, updated = _make_db_mock() good_result = _make_dadata_result(qc_geo=0) call_count = {"n": 0} async def fake_clean(addr): call_count["n"] += 1 if call_count["n"] == 1: raise RuntimeError("dadata blew up") return good_result with patch("scripts.backfill_houses_dadata.clean_address", side_effect=fake_clean): stats = await _run_backfill(db, rows, batch="b4", dry_run=False) assert stats.processed == 1 # только 2-й row дошёл до DB-write success assert stats.failed == 1 assert stats.enriched == 1 # Только один UPDATE — для второго row assert len(updated) == 1 assert updated[0]["id"] == 11 # --------------------------------------------------------------------------- # --dry-run — no DB writes # --------------------------------------------------------------------------- async def test_run_backfill_dry_run_skips_db_writes(): """dry_run=True → counters обновляются, но db.execute UPDATE не вызывается.""" row = HouseRow(id=20, address="ул Test 1", lat=None, lon=None, cadastral_number=None) db, updated = _make_db_mock() fake = _make_dadata_result(qc_geo=0) with patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): stats = await _run_backfill( db, [(row, "coords")], batch="dry", dry_run=True ) assert stats.processed == 1 assert stats.enriched == 1 assert updated == [] # никаких UPDATE'ов assert db.commit.call_count == 0 # никаких commit'ов # --------------------------------------------------------------------------- # _select_candidates — priority filter работает # --------------------------------------------------------------------------- def test_select_candidates_coords_priority_only_fetches_null_coords(): """priority=coords → запрашивается только `lat IS NULL OR lon IS NULL` SELECT.""" coords_rows = [ {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None}, ] db, _updated = _make_db_mock(coords_sample=coords_rows) out = _select_candidates(db, priority="coords", limit=10) assert len(out) == 2 assert all(p == "coords" for _row, p in out) assert out[0][0].id == 1 assert out[1][0].id == 2 def test_select_candidates_cadnum_priority_only_fetches_null_cadnum(): """priority=cadnum → только NOT NULL coords + NULL cadastral_number rows.""" cadnum_rows = [ {"id": 50, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, _updated = _make_db_mock(cadnum_sample=cadnum_rows) out = _select_candidates(db, priority="cadnum", limit=10) assert len(out) == 1 assert out[0][1] == "cadnum" assert out[0][0].id == 50 def test_select_candidates_both_priority_fetches_coords_first_then_cadnum(): """priority=both → сначала NULL coords, потом NULL cadnum (в пределах limit).""" coords_rows = [ {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, ] cadnum_rows = [ {"id": 100, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, _updated = _make_db_mock(coords_sample=coords_rows, cadnum_sample=cadnum_rows) out = _select_candidates(db, priority="both", limit=10) assert len(out) == 2 # coords bucket первый assert out[0][1] == "coords" assert out[0][0].id == 1 assert out[1][1] == "cadnum" assert out[1][0].id == 100 # --------------------------------------------------------------------------- # accuracy-roadmap #2 — active-listings prioritization (EXISTS + ORDER BY) # --------------------------------------------------------------------------- def _capture_select_sql(db: MagicMock) -> list[str]: """Извлекает SQL-строки всех SELECT FROM houses из db.execute.call_args_list.""" out: list[str] = [] for call in db.execute.call_args_list: sql_str = str(call.args[0]) if "FROM houses" in sql_str and "SELECT" in sql_str.upper(): out.append(sql_str) return out def test_select_candidates_coords_sql_has_active_listings_exists_and_order(): """coords-priority SELECT включает has_active_listings EXISTS + ORDER BY DESC.""" coords_rows = [ {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, ] db, _updated = _make_db_mock(coords_sample=coords_rows) _select_candidates(db, priority="coords", limit=10) sqls = _capture_select_sql(db) assert len(sqls) == 1 sql = sqls[0] # EXISTS на listings.house_id_fk → houses.id + is_active, aliased в projection assert "EXISTS (SELECT 1 FROM listings l" in sql assert "l.house_id_fk = houses.id" in sql assert "l.is_active" in sql assert "AS has_active_listings" in sql # active-listings дома сортируются первыми, затем стабильно по id assert "ORDER BY has_active_listings DESC, id" in sql def test_select_candidates_cadnum_sql_has_active_listings_order(): """cadnum-priority SELECT — тот же EXISTS + ORDER BY (приоритизация во всех режимах).""" cadnum_rows = [ {"id": 50, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, _updated = _make_db_mock(cadnum_sample=cadnum_rows) _select_candidates(db, priority="cadnum", limit=10) sql = _capture_select_sql(db)[0] assert "AS has_active_listings" in sql assert "ORDER BY has_active_listings DESC, id" in sql def test_select_candidates_active_listings_sort_first_within_bucket(): """ORDER BY DESC: дом с active listings идёт перед домом без — within один bucket. Mock возвращает rows уже в order'е, в котором их вернул бы Postgres (has_active_listings DESC) — проверяем, что _select_candidates сохраняет порядок и не пересортировывает по id. """ # id=2 имеет active listing → Postgres вернёт его первым несмотря на больший id coords_rows = [ {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None}, {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, ] db, _updated = _make_db_mock(coords_sample=coords_rows) out = _select_candidates(db, priority="coords", limit=10) assert [r.id for r, _p in out] == [2, 1] def test_select_candidates_listings_priority_filters_null_cadnum_and_active(): """priority=listings → WHERE = NULL cadnum AND EXISTS active listing.""" listings_rows = [ {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, {"id": 8, "address": "ул E 5", "lat": None, "lon": None, "cadastral_number": None}, ] db, _updated = _make_db_mock(listings_sample=listings_rows) out = _select_candidates(db, priority="listings", limit=10) assert len(out) == 2 assert all(p == "listings" for _row, p in out) assert [r.id for r, _p in out] == [7, 8] sql = _capture_select_sql(db)[0] # Фильтр в WHERE: NULL cadnum AND active-listing EXISTS assert "cadastral_number IS NULL AND EXISTS (SELECT 1 FROM listings l" in sql assert "l.house_id_fk = houses.id" in sql assert "l.is_active" in sql assert "ORDER BY has_active_listings DESC, id" in sql def test_select_candidates_listings_priority_skips_coords_and_cadnum_buckets(): """priority=listings не должен трогать coords/cadnum SELECT'ы — только один fetch.""" listings_rows = [ {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] coords_rows = [ {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, ] cadnum_rows = [ {"id": 99, "address": "ул Z 9", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, _updated = _make_db_mock( coords_sample=coords_rows, cadnum_sample=cadnum_rows, listings_sample=listings_rows ) out = _select_candidates(db, priority="listings", limit=10) # Ровно один SELECT FROM houses (listings bucket), coords/cadnum не запрошены assert len(_capture_select_sql(db)) == 1 assert [r.id for r, _p in out] == [7] def test_select_candidates_listings_priority_respects_limit(): """priority=listings прокидывает limit в LIMIT bind.""" listings_rows = [ {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, {"id": 8, "address": "ул E 5", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, {"id": 9, "address": "ул F 6", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, _updated = _make_db_mock(listings_sample=listings_rows) out = _select_candidates(db, priority="listings", limit=2) assert len(out) == 2 # bind lim передан в execute _sql, params = db.execute.call_args.args assert params["lim"] == 2 # --------------------------------------------------------------------------- # --limit caps batch size # --------------------------------------------------------------------------- async def test_main_respects_limit(monkeypatch): """main(--limit 1) при 3 кандидатах → только 1 row processed.""" coords_rows = [ {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None}, {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None}, {"id": 3, "address": "ул C 3", "lat": None, "lon": None, "cadastral_number": None}, ] db, updated = _make_db_mock(coords_sample=coords_rows) monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN") monkeypatch.setenv("DADATA_API_SECRET", "TEST_SECRET") fake = _make_dadata_result(qc_geo=0) with ( patch("scripts.backfill_houses_dadata.SessionLocal", return_value=db), patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ), # Уберём sleep чтобы test не висел 0.5s patch("scripts.backfill_houses_dadata.asyncio.sleep", new=AsyncMock()), ): n = await main(["--limit", "1", "--batch", "test_limit"]) assert n == 1 assert len(updated) == 1 # --------------------------------------------------------------------------- # --priority listings — CLI choice + end-to-end routing # --------------------------------------------------------------------------- def test_parse_args_default_priority_is_both(): """Дефолт priority остаётся 'both' (не ломаем существующий cron).""" assert _parse_args([]).priority == "both" def test_parse_args_accepts_priority_listings(): """--priority listings — валидный choice.""" assert _parse_args(["--priority", "listings"]).priority == "listings" def test_parse_args_still_accepts_legacy_priorities(): """coords / cadnum / both продолжают работать без изменений.""" assert _parse_args(["--priority", "coords"]).priority == "coords" assert _parse_args(["--priority", "cadnum"]).priority == "cadnum" assert _parse_args(["--priority", "both"]).priority == "both" def test_parse_args_rejects_unknown_priority(): """Несуществующий priority → argparse SystemExit (choices guard).""" with pytest.raises(SystemExit): _parse_args(["--priority", "garbage"]) async def test_main_priority_listings_enriches_active_listing_house(monkeypatch): """main(--priority listings) гонит listings-bucket rows через enrichment.""" listings_rows = [ {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None}, ] db, updated = _make_db_mock(listings_sample=listings_rows) monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN") monkeypatch.setenv("DADATA_API_SECRET", "TEST_SECRET") fake = _make_dadata_result(qc_geo=0) with ( patch("scripts.backfill_houses_dadata.SessionLocal", return_value=db), patch( "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ), patch("scripts.backfill_houses_dadata.asyncio.sleep", new=AsyncMock()), ): n = await main(["--priority", "listings", "--batch", "test_listings"]) assert n == 1 assert len(updated) == 1 assert updated[0]["id"] == 7 assert updated[0]["cadnum"] == "66:41:0704045:350" # --------------------------------------------------------------------------- # Missing creds → SystemExit # --------------------------------------------------------------------------- async def test_main_requires_dadata_creds(monkeypatch): """Без DADATA_API_TOKEN + DADATA_API_SECRET → SystemExit ещё до DB connect.""" monkeypatch.delenv("DADATA_API_TOKEN", raising=False) monkeypatch.delenv("DADATA_API_SECRET", raising=False) with pytest.raises(SystemExit): await main(["--limit", "1"]) async def test_main_requires_secret_not_just_token(monkeypatch): """Только TOKEN (без SECRET) → /clean/address не сработает → fail fast.""" monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN") monkeypatch.delenv("DADATA_API_SECRET", raising=False) with pytest.raises(SystemExit): await main(["--limit", "1"]) # --------------------------------------------------------------------------- # Stats summary contains by_priority breakdown # --------------------------------------------------------------------------- def test_stats_bump_buckets_by_priority(): """Stats.bump поддерживает несколько priority bucket'ов одновременно.""" s = Stats() s.bump("coords", "enriched") s.bump("coords", "enriched") s.bump("cadnum", "no_change") assert s.by_priority["coords"]["enriched"] == 2 assert s.by_priority["cadnum"]["no_change"] == 1 # Не существующие outcome'ы должны быть 0 assert s.by_priority["coords"]["failed"] == 0