gendesign/backend/tests/services/test_mapping_review.py
bot-backend dfc1ee8be0
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m47s
Deploy / build-worker (push) Successful in 5m42s
Deploy / build-frontend (push) Successful in 5m55s
Deploy / deploy (push) Has been cancelled
feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) (#2278)
2026-07-03 07:59:13 +00:00

272 lines
9.5 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.

"""Тесты 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