Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m40s
CI / openapi-codegen-check (pull_request) Failing after 2m45s
CI / backend-tests (pull_request) Successful in 15m13s
- backend: mapping_review.py (list с LATERAL-обогащением domrf+developers, approve = is_reviewed+note, reject = DELETE по PK с RETURNING-логом) - 3 endpoint'а в admin_etl.py (GET list / POST approve / POST reject) - frontend: /admin/mapping-review — таблица с warn-подсветкой несовпадающих ядер, confirm-модалка на reject, пагинация; таб в admin layout - 10 тестов (happy/404/null-score)
272 lines
9.5 KiB
Python
272 lines
9.5 KiB
Python
"""Тесты 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
|