"""Тесты review-инструмента авто-маппингов (feat/mapping-review-page). Mock DB (unittest.mock) — не требует реального PostgreSQL. Покрывает: - list_mapping_review: обогащение (domrf comm/dev + objective devs) + эхо total; - approve_mapping: dict обновлённой строки; None (404) на несуществующий id; - reject_mapping: dict удалённой строки (DELETE) + логирование; None на 404; - endpoints approve/reject через TestClient: 200 + 404. """ from __future__ import annotations from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.core.db import get_db from app.main import app from app.services.etl.mapping_review import ( approve_mapping, list_mapping_review, reject_mapping, ) _NOW = datetime(2026, 7, 3, 12, 0, tzinfo=UTC) def _row(**kw: Any) -> SimpleNamespace: """Row-заглушка с attribute-доступом (как SQLAlchemy Row).""" defaults: dict[str, Any] = { "id": 1, "objective_complex_name": "Малевич", "objective_project_id": 111, "objective_group": "Екатеринбург", "domrf_obj_id": 5001, "match_method": "auto_core_dev_v5", "match_score": 0.90, "is_reviewed": False, "note": "#2177 step2", "created_at": _NOW, "domrf_comm_name": 'ЖК "Малевич"', "domrf_dev_name": "Брусника", "objective_developers": ["Брусника", "СЗ Малевич"], } defaults.update(kw) return SimpleNamespace(**defaults) def _mock_db_for_list(rows: list[SimpleNamespace], total: int) -> MagicMock: """MagicMock Session: первый execute (list) → .all(); второй (count) → scalar_one().""" list_res = MagicMock() list_res.all.return_value = rows count_res = MagicMock() count_res.scalar_one.return_value = total db = MagicMock() db.execute.side_effect = [list_res, count_res] return db # ── list_mapping_review: обогащение + total ────────────────────────────────── def test_list_enriches_and_counts() -> None: """rows обогащены (domrf comm/dev + objective devs), total эхо-ится.""" rows = [ _row(id=1, match_score=0.80, is_reviewed=False), _row( id=2, match_score=0.95, is_reviewed=False, domrf_obj_id=None, domrf_comm_name=None, domrf_dev_name=None, objective_developers=None, ), ] db = _mock_db_for_list(rows, total=14) out = list_mapping_review(db, only_unreviewed=True, limit=100, offset=0) assert out["total"] == 14 assert out["limit"] == 100 assert out["offset"] == 0 assert out["only_unreviewed"] is True assert len(out["rows"]) == 2 first = out["rows"][0] assert first["id"] == 1 assert first["match_score"] == 0.80 assert first["domrf_comm_name"] == 'ЖК "Малевич"' assert first["domrf_dev_name"] == "Брусника" assert first["objective_developers"] == ["Брусника", "СЗ Малевич"] assert first["created_at"] == _NOW.isoformat() # Строка без domrf-обогащения: None-поля + пустой список застройщиков. second = out["rows"][1] assert second["domrf_obj_id"] is None assert second["domrf_comm_name"] is None assert second["objective_developers"] == [] def test_list_passes_filter_and_paging_params() -> None: """only_unreviewed / limit / offset передаются в SQL-биндинги.""" db = _mock_db_for_list([_row()], total=1) list_mapping_review(db, only_unreviewed=False, limit=25, offset=50) # Первый execute — list-SQL с биндингами страницы. list_call = db.execute.call_args_list[0] params = list_call.args[1] assert params == {"only_unreviewed": False, "limit": 25, "offset": 50} # Второй execute — count-SQL с тем же фильтром. count_call = db.execute.call_args_list[1] assert count_call.args[1] == {"only_unreviewed": False} def test_list_score_none_serialized_as_null() -> None: """match_score=None (например legacy без скора) → None, не падает на float().""" db = _mock_db_for_list([_row(match_score=None)], total=1) out = list_mapping_review(db) assert out["rows"][0]["match_score"] is None # ── approve_mapping ────────────────────────────────────────────────────────── def test_approve_updates_and_returns_row() -> None: """approve → dict обновлённой строки, commit вызван.""" updated = _row(is_reviewed=True, note="#2177 step2\napproved 2026-07-03") res = MagicMock() res.first.return_value = updated db = MagicMock() db.execute.return_value = res out = approve_mapping(db, mapping_id=1) assert out is not None assert out["id"] == 1 assert out["is_reviewed"] is True assert "approved" in out["note"] db.commit.assert_called_once() db.rollback.assert_not_called() def test_approve_missing_id_returns_none() -> None: """approve несуществующего id → None (404 в роутере), rollback, без commit.""" res = MagicMock() res.first.return_value = None db = MagicMock() db.execute.return_value = res out = approve_mapping(db, mapping_id=999) assert out is None db.rollback.assert_called_once() db.commit.assert_not_called() # ── reject_mapping (DELETE) ────────────────────────────────────────────────── def test_reject_deletes_and_returns_row() -> None: """reject → dict удалённой строки (эхо), commit вызван (DELETE зафиксирован).""" deleted_row = _row(id=7, objective_complex_name="Отклонённый ЖК") res = MagicMock() res.first.return_value = deleted_row db = MagicMock() db.execute.return_value = res out = reject_mapping(db, mapping_id=7) assert out is not None assert out["id"] == 7 assert out["objective_complex_name"] == "Отклонённый ЖК" assert out["match_method"] == "auto_core_dev_v5" db.commit.assert_called_once() db.rollback.assert_not_called() def test_reject_missing_id_returns_none() -> None: """reject несуществующего id → None, rollback, без commit/DELETE-фиксации.""" res = MagicMock() res.first.return_value = None db = MagicMock() db.execute.return_value = res out = reject_mapping(db, mapping_id=404) assert out is None db.rollback.assert_called_once() db.commit.assert_not_called() # ── Endpoints (TestClient + get_db override) ───────────────────────────────── @pytest.fixture() def client() -> TestClient: return TestClient(app) def _override_db(mock: MagicMock) -> None: def _get_db_override() -> Any: yield mock app.dependency_overrides[get_db] = _get_db_override def test_endpoint_list_returns_payload(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /mapping-review → 200 + payload из list_mapping_review.""" _override_db(MagicMock()) payload = { "rows": [{"id": 1, "match_score": 0.80}], "total": 14, "limit": 100, "offset": 0, "only_unreviewed": True, } monkeypatch.setattr( "app.api.v1.admin_etl.list_mapping_review", lambda db, only_unreviewed, limit, offset: payload, ) r = client.get("/api/v1/admin/etl/mapping-review") assert r.status_code == 200 assert r.json()["total"] == 14 def test_endpoint_approve_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST approve → 200 при найденном id, 404 при None.""" _override_db(MagicMock()) monkeypatch.setattr( "app.api.v1.admin_etl.approve_mapping", lambda db, mapping_id: {"id": mapping_id, "is_reviewed": True}, ) ok = client.post("/api/v1/admin/etl/mapping-review/1/approve") assert ok.status_code == 200 assert ok.json()["is_reviewed"] is True monkeypatch.setattr( "app.api.v1.admin_etl.approve_mapping", lambda db, mapping_id: None, ) missing = client.post("/api/v1/admin/etl/mapping-review/999/approve") assert missing.status_code == 404 def test_endpoint_reject_ok_and_404(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST reject → 200 {status: deleted} при найденном, 404 при None.""" _override_db(MagicMock()) monkeypatch.setattr( "app.api.v1.admin_etl.reject_mapping", lambda db, mapping_id: {"id": mapping_id, "objective_complex_name": "X"}, ) ok = client.post("/api/v1/admin/etl/mapping-review/7/reject") assert ok.status_code == 200 body = ok.json() assert body["status"] == "deleted" assert body["deleted"]["id"] == 7 monkeypatch.setattr( "app.api.v1.admin_etl.reject_mapping", lambda db, mapping_id: None, ) missing = client.post("/api/v1/admin/etl/mapping-review/404/reject") assert missing.status_code == 404