"""Тесты для GET /{cad_num}/connection-capacity (Forgejo #2119 Phase A). FastAPI TestClient + mock сервиса get_connection_capacity — без Postgres. """ from __future__ import annotations from typing import Any from unittest.mock import patch import pytest from fastapi.testclient import TestClient from app.main import app _VALID_CAD = "66:41:0204016:10" def _power_point( name: str = "ПС 110/10 Уктус", reserve: float | None = 15.0, load_index: str | None = "open", distance_m: float = 850.0, reserve_unit: str | None = "МВА", district: str | None = None, reserve_note: str | None = None, ) -> dict[str, Any]: return { "name": name, "dzo_name": "Россети Урал", "voltage_class": "110/10", "load_index": load_index, "installed_capacity_mva": 40.0, "current_load_mva": 25.0, "reserve_mva": reserve, "reserve_unit": reserve_unit, "district": district, "reserve_asof": "2026-06-30", "reserve_note": reserve_note, "distance_m": distance_m, "lat": 56.78, "lon": 60.61, } def _heat_block( systems: list[dict[str, Any]] | None = None, total: float | None = 3.26, ) -> dict[str, Any]: if systems is None: systems = [ { "org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2", } ] return {"systems": systems, "total_reserve_gcal_h": total} def _make_response( power_points: list[dict[str, Any]] | None = None, water: list[dict[str, Any]] | None = None, heat: dict[str, Any] | None = None, ) -> dict[str, Any]: if power_points is None: power_points = [_power_point()] if water is None: water = [ { "system_kind": "water", "system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": -168.5, "note": "дефицит", "period": "2026-Q1", } ] by_load_index: dict[str, int] = {} for p in power_points: li = p["load_index"] or "unknown" by_load_index[li] = by_load_index.get(li, 0) + 1 nearest = next((p for p in power_points if p["reserve_mva"] and p["reserve_mva"] > 0), None) return { "power_points": power_points, "power_summary": { "total_power_points": len(power_points), "by_load_index": by_load_index, "nearest_with_reserve": nearest, }, "water": water, "heat": heat if heat is not None else _heat_block(), } def test_connection_capacity_returns_power_and_water() -> None: full = _make_response() with patch( "app.api.v1.parcels.get_connection_capacity", return_value=full, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert len(body["power_points"]) == 1 assert body["power_points"][0]["voltage_class"] == "110/10" assert body["power_summary"]["total_power_points"] == 1 assert body["power_summary"]["by_load_index"]["open"] == 1 assert body["power_summary"]["nearest_with_reserve"]["reserve_mva"] == pytest.approx(15.0) # water: отрицательный резерв (дефицит) сохраняет знак. assert body["water"][0]["reserve_thousand_m3_day"] == pytest.approx(-168.5) assert body["water"][0]["system_name"] == "ЗФС, ГСВ" def test_connection_capacity_empty() -> None: empty = _make_response(power_points=[], water=[]) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=empty, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["power_points"] == [] assert body["power_summary"]["total_power_points"] == 0 assert body["power_summary"]["nearest_with_reserve"] is None assert body["water"] == [] def test_connection_capacity_no_reserve_nearest_null() -> None: """Все ЦП без положительного резерва → nearest_with_reserve=null.""" pts = [_power_point(reserve=None, load_index="closed")] with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=pts), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["power_summary"]["nearest_with_reserve"] is None assert body["power_summary"]["by_load_index"]["closed"] == 1 def test_connection_capacity_parcel_not_found_404() -> None: with patch( "app.api.v1.parcels.get_connection_capacity", side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"), ): client = TestClient(app) r = client.get("/api/v1/parcels/66:41:9999999:1/connection-capacity") assert r.status_code == 404 assert "не найден" in r.json()["detail"] def test_connection_capacity_passes_radius() -> None: with patch("app.api.v1.parcels.get_connection_capacity") as mock_svc: mock_svc.return_value = _make_response() client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=5000") assert r.status_code == 200 call = mock_svc.call_args assert call[0][2] == 5000 or call[1].get("radius_m") == 5000 def test_connection_capacity_radius_too_small_422() -> None: client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=50") assert r.status_code == 422 def test_connection_capacity_radius_too_large_422() -> None: client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=20000") assert r.status_code == 422 # ── Phase B2: heat-блок + reserve_unit/district + reserve_note ──────────────── def test_connection_capacity_heat_block_per_org() -> None: """Heat-блок: строки за последний период per-org + суммарный резерв. #2119 B2.""" heat = _heat_block( systems=[ {"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"}, # Другая организация может публиковать свежий квартал раньше/позже — # per-org MAX(period) сервиса это учитывает (тут просто передаём готовое). { "org": "Екатеринбургэнерго", "system_name": "ТЭЦ Зона-1", "reserve_gcal_h": -5.0, # дефицит — знак сохраняется "period": "2026-Q1", }, ], total=-1.74, ) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(heat=heat), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert len(body["heat"]["systems"]) == 2 assert body["heat"]["systems"][0]["org"] == "ЕТК" assert body["heat"]["systems"][0]["reserve_gcal_h"] == pytest.approx(3.26) # дефицит (отрицательный резерв) сохраняет знак. assert body["heat"]["systems"][1]["reserve_gcal_h"] == pytest.approx(-5.0) assert body["heat"]["systems"][1]["period"] == "2026-Q1" assert body["heat"]["total_reserve_gcal_h"] == pytest.approx(-1.74) def test_connection_capacity_heat_graceful_absent() -> None: """Ключ 'heat' отсутствует (таблица не наполнена / миграция не применена) → null.""" resp = _make_response() del resp["heat"] with patch( "app.api.v1.parcels.get_connection_capacity", return_value=resp, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text assert r.json()["heat"] is None def test_connection_capacity_heat_empty_systems() -> None: """Таблица есть, но систем нет → пустой блок, total=null (graceful degrade).""" with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(heat=_heat_block(systems=[], total=None)), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["heat"]["systems"] == [] assert body["heat"]["total_reserve_gcal_h"] is None def test_connection_capacity_power_reserve_unit_and_district() -> None: """power_points пробрасывает reserve_unit ('МВт' у ЕЭСК) + district. #2119 B2.""" pt = _power_point( name="ПС 110/10 Свердловск", reserve_unit="МВт", district="Октябрьский", ) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=[pt]), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text p0 = r.json()["power_points"][0] assert p0["reserve_unit"] == "МВт" assert p0["district"] == "Октябрьский" def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None: """ЕЭСК теперь с числами → reserve_note NULL когда reserve_mva заполнен. #2119 B2.""" # Заполненный резерв → сервис отдаёт reserve_note=None (даже для ЕЭСК-строк). pt = _power_point(reserve=12.5, reserve_note=None) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=[pt]), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text assert r.json()["power_points"][0]["reserve_note"] is None