"""Тесты read-only API для location (#948 Part B, ТЗ §8.2). Покрывает: 1. GET /api/v1/locations → LocationList (total + rows с индексами) 2. GET /api/v1/locations/{district_name} → 200 (одна локация) 3. GET /api/v1/locations/{district_name} → 404 если нет 4. Индексы-None (нет данных) корректно сериализуются как null 5. Нет write-методов: POST/PUT/DELETE → 405 (read-only сущность) Стратегия mock: сервисные функции (list_locations / get_location) патчим через unittest.mock.patch; get_db переопределяем заглушкой (сервис запатчен — БД не трогается). settings.testing=True (conftest) отключает rbac_guard. """ from __future__ import annotations from datetime import UTC, datetime from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from app.main import app from app.schemas.location import LocationList, LocationOut _TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC) _DISTRICT = "Кировский" def _make_out( district_name: str = _DISTRICT, infra: float | None = 0.82, competition: float | None = 0.6, demand: float | None = 0.5, future_supply: float | None = 0.3, lon: float | None = 60.6, lat: float | None = 56.84, ) -> LocationOut: return LocationOut( id=1, district_name=district_name, region=None, lon=lon, lat=lat, infra_index=infra, competition_index=competition, demand_index=demand, future_supply_index=future_supply, indices_computed_at=_TS, created_at=_TS, updated_at=_TS, ) def _override_db(): """Заглушка get_db — сервис запатчен, реальная сессия не нужна.""" def _get_db_override(): yield MagicMock() return _get_db_override # ── 1. LIST ───────────────────────────────────────────────────────────────────── def test_list_locations_returns_list_with_indices() -> None: from app.core.db import get_db expected = LocationList( total=2, rows=[_make_out("Кировский"), _make_out("Ленинский", infra=None)], ) app.dependency_overrides[get_db] = _override_db() with patch("app.api.v1.locations.list_locations", return_value=expected) as mock_list: client = TestClient(app) resp = client.get("/api/v1/locations") assert resp.status_code == 200, resp.text body = resp.json() assert body["total"] == 2 assert len(body["rows"]) == 2 assert body["rows"][0]["district_name"] == "Кировский" assert body["rows"][0]["competition_index"] == 0.6 # None-индекс сериализуется как null (нет данных, не 0). assert body["rows"][1]["infra_index"] is None mock_list.assert_called_once() def test_list_locations_empty() -> None: from app.core.db import get_db app.dependency_overrides[get_db] = _override_db() with patch("app.api.v1.locations.list_locations", return_value=LocationList(total=0, rows=[])): client = TestClient(app) resp = client.get("/api/v1/locations") assert resp.status_code == 200, resp.text assert resp.json() == {"total": 0, "rows": []} # ── 2 + 3. GET BY NAME ────────────────────────────────────────────────────────── def test_get_location_by_name_returns_200() -> None: from app.core.db import get_db app.dependency_overrides[get_db] = _override_db() with patch("app.api.v1.locations.get_location", return_value=_make_out()) as mock_get: client = TestClient(app) resp = client.get(f"/api/v1/locations/{_DISTRICT}") assert resp.status_code == 200, resp.text body = resp.json() assert body["district_name"] == _DISTRICT assert body["demand_index"] == 0.5 assert body["future_supply_index"] == 0.3 assert body["lon"] == 60.6 # district_name пробрасывается из path в сервис. assert mock_get.call_args[0][1] == _DISTRICT def test_get_location_unknown_returns_404() -> None: from app.core.db import get_db app.dependency_overrides[get_db] = _override_db() with patch("app.api.v1.locations.get_location", return_value=None): client = TestClient(app) resp = client.get("/api/v1/locations/НесуществующийРайон") assert resp.status_code == 404, resp.text assert resp.json()["detail"] == "Location not found" def test_get_location_null_indices_serialize_as_null() -> None: from app.core.db import get_db app.dependency_overrides[get_db] = _override_db() out = _make_out( infra=None, competition=None, demand=None, future_supply=None, lon=None, lat=None ) with patch("app.api.v1.locations.get_location", return_value=out): client = TestClient(app) resp = client.get(f"/api/v1/locations/{_DISTRICT}") assert resp.status_code == 200, resp.text body = resp.json() assert body["infra_index"] is None assert body["competition_index"] is None assert body["demand_index"] is None assert body["future_supply_index"] is None assert body["lon"] is None # ── 5. READ-ONLY: no write methods ────────────────────────────────────────────── def test_post_location_not_allowed() -> None: """Локации вычисляются, не вводятся → POST не зарегистрирован (405).""" client = TestClient(app) resp = client.post("/api/v1/locations", json={"district_name": "Кировский"}) assert resp.status_code == 405 def test_delete_location_not_allowed() -> None: client = TestClient(app) resp = client.delete(f"/api/v1/locations/{_DISTRICT}") assert resp.status_code == 405