"""Тесты для insight CRUD (#948 Part A, ТЗ §7.13/§8.9). Покрывает (зеркало test_custom_pois.py): 1. POST /api/v1/insights → 201, created_by из X-Authenticated-User 2. POST без title/body → 422 (Pydantic required-field validation) 3. POST без X-Authenticated-User → 401 4. GET /api/v1/insights → InsightList (total/limit/offset/rows) 5. GET /api/v1/insights? → фильтры (district/cad_num/category/is_confidential) пробрасываются в сервис 6. GET /api/v1/insights/{id} → 200; 404 если нет 7. PUT /api/v1/insights/{id} → 200 updated; 404 если нет 8. DELETE /api/v1/insights/{id} → 204; 404 если нет 9. is_confidential хранится и фильтруется 10. Service-level: db.commit() в create/update/delete (#261 regression guard) Стратегия mock: сервисные функции патчим через unittest.mock.patch, DB — MagicMock (как test_custom_pois.py). settings.testing=True (conftest) отключает rbac_guard, поэтому X-Authenticated-User в тесте проверяет роутерный _require_user напрямую. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from app.main import app from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate # ── Константы ───────────────────────────────────────────────────────────────── _CAD = "66:41:0204016:10" _USER = "analysttest" _TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC) _AUTH = {"X-Authenticated-User": _USER} # ── Helpers ──────────────────────────────────────────────────────────────────── def _make_out( insight_id: int = 1, created_by: str = _USER, title: str = "Конкурент строит ЖК рядом", body: str = "Замечен котлован, забор ГК «Х».", category: str | None = "competition", is_confidential: bool = False, district: str | None = "Октябрьский", cad_num: str | None = None, lon: float | None = None, lat: float | None = None, ) -> InsightOut: return InsightOut( id=insight_id, created_by=created_by, title=title, body=body, category=category, is_confidential=is_confidential, district=district, cad_num=cad_num, lon=lon, lat=lat, source=None, created_at=_TS, updated_at=_TS, ) # ── Tests: CREATE ──────────────────────────────────────────────────────────────── def test_create_insight_returns_201_and_sets_created_by() -> None: """POST /insights → 201; created_by берётся из X-Authenticated-User, не из тела.""" expected = _make_out() with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/insights", json={ "title": "Конкурент строит ЖК рядом", "body": "Замечен котлован, забор ГК «Х».", "category": "competition", "district": "Октябрьский", }, headers=_AUTH, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["title"] == "Конкурент строит ЖК рядом" assert body["created_by"] == _USER assert body["is_confidential"] is False mock_create.assert_called_once() # created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса. call_args = mock_create.call_args assert call_args[0][1] == _USER def test_create_confidential_insight_stores_flag() -> None: """is_confidential=True в теле → пробрасывается в payload сервиса.""" expected = _make_out(is_confidential=True) with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/insights", json={"title": "Инсайдер", "body": "Закрытая инфа", "is_confidential": True}, headers=_AUTH, ) assert resp.status_code == 201, resp.text assert resp.json()["is_confidential"] is True payload: InsightCreate = mock_create.call_args[0][2] assert payload.is_confidential is True def test_create_insight_missing_title_returns_422() -> None: """title обязателен → 422.""" client = TestClient(app) resp = client.post( "/api/v1/insights", json={"body": "только тело без заголовка"}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_insight_missing_body_returns_422() -> None: """body обязателен → 422.""" client = TestClient(app) resp = client.post( "/api/v1/insights", json={"title": "только заголовок"}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_insight_empty_title_returns_422() -> None: """Пустой title (min_length=1) → 422.""" client = TestClient(app) resp = client.post( "/api/v1/insights", json={"title": "", "body": "непустое тело"}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_insight_bad_category_returns_422() -> None: """category вне enum → 422 (Literal).""" client = TestClient(app) resp = client.post( "/api/v1/insights", json={"title": "t", "body": "b", "category": "totally-unknown"}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_insight_without_auth_header_returns_401() -> None: """Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах).""" client = TestClient(app) resp = client.post( "/api/v1/insights", json={"title": "t", "body": "b"}, ) assert resp.status_code == 401, resp.text # ── Tests: LIST ────────────────────────────────────────────────────────────────── def test_list_insights_returns_envelope() -> None: """GET /insights → InsightList {total, limit, offset, rows}.""" listing = InsightList(total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]) with patch("app.api.v1.insights.list_insights", return_value=listing): client = TestClient(app) resp = client.get("/api/v1/insights", headers=_AUTH) assert resp.status_code == 200, resp.text body = resp.json() assert body["total"] == 2 assert body["limit"] == 50 assert body["offset"] == 0 assert len(body["rows"]) == 2 assert body["rows"][0]["id"] == 1 def test_list_insights_passes_filters_to_service() -> None: """Фильтры district/cad_num/category/is_confidential → в сервис как kwargs.""" listing = InsightList(total=0, limit=50, offset=0, rows=[]) with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list: client = TestClient(app) resp = client.get( "/api/v1/insights", params={ "district": "Октябрьский", "cad_num": _CAD, "category": "risk", "is_confidential": "true", }, headers=_AUTH, ) assert resp.status_code == 200, resp.text kwargs = mock_list.call_args.kwargs assert kwargs["district"] == "Октябрьский" assert kwargs["cad_num"] == _CAD assert kwargs["category"] == "risk" assert kwargs["is_confidential"] is True def test_list_insights_filter_confidential_false() -> None: """is_confidential=false тоже пробрасывается (не путается с «не задано»).""" listing = InsightList(total=0, limit=50, offset=0, rows=[]) with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list: client = TestClient(app) resp = client.get( "/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH ) assert resp.status_code == 200, resp.text assert mock_list.call_args.kwargs["is_confidential"] is False # ── Tests: GET one ─────────────────────────────────────────────────────────────── def test_get_insight_returns_200() -> None: with patch("app.api.v1.insights.get_insight", return_value=_make_out(7)): client = TestClient(app) resp = client.get("/api/v1/insights/7", headers=_AUTH) assert resp.status_code == 200, resp.text assert resp.json()["id"] == 7 def test_get_insight_not_found_returns_404() -> None: with patch("app.api.v1.insights.get_insight", return_value=None): client = TestClient(app) resp = client.get("/api/v1/insights/999", headers=_AUTH) assert resp.status_code == 404, resp.text # ── Tests: UPDATE ──────────────────────────────────────────────────────────────── def test_put_insight_returns_updated() -> None: updated = _make_out(title="Обновлено", is_confidential=True) with patch("app.api.v1.insights.update_insight", return_value=updated): client = TestClient(app) resp = client.put( "/api/v1/insights/1", json={"title": "Обновлено", "is_confidential": True}, headers=_AUTH, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["title"] == "Обновлено" assert body["is_confidential"] is True def test_put_insight_not_found_returns_404() -> None: with patch("app.api.v1.insights.update_insight", return_value=None): client = TestClient(app) resp = client.put( "/api/v1/insights/999", json={"title": "x"}, headers=_AUTH ) assert resp.status_code == 404, resp.text def test_put_insight_without_auth_returns_401() -> None: client = TestClient(app) resp = client.put("/api/v1/insights/1", json={"title": "x"}) assert resp.status_code == 401, resp.text # ── Tests: DELETE ──────────────────────────────────────────────────────────────── def test_delete_insight_returns_204() -> None: with patch("app.api.v1.insights.delete_insight", return_value=True): client = TestClient(app) resp = client.delete("/api/v1/insights/1", headers=_AUTH) assert resp.status_code == 204, resp.text def test_delete_insight_not_found_returns_404() -> None: with patch("app.api.v1.insights.delete_insight", return_value=False): client = TestClient(app) resp = client.delete("/api/v1/insights/999", headers=_AUTH) assert resp.status_code == 404, resp.text def test_delete_insight_without_auth_returns_401() -> None: client = TestClient(app) resp = client.delete("/api/v1/insights/1") assert resp.status_code == 401, resp.text # ── Service-level: commit + mapping (#261 regression guard) ───────────────────── def _make_row_data( insight_id: int = 1, created_by: str = _USER, title: str = "T", body: str = "B", category: str | None = "competition", is_confidential: bool = False, district: str | None = "Октябрьский", cad_num: str | None = None, lon: float | None = None, lat: float | None = None, ) -> dict[str, Any]: return { "id": insight_id, "created_by": created_by, "title": title, "body": body, "category": category, "is_confidential": is_confidential, "district": district, "cad_num": cad_num, "lon": lon, "lat": lat, "source": None, "created_at": _TS, "updated_at": _TS, } def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock: """MagicMock-сессия: execute().mappings().first() → row_data mapping.""" db = MagicMock() row_mock: MagicMock | None = None if row_data is not None: row_mock = MagicMock() row_mock.__getitem__ = lambda self, k: row_data[k] row_mock.get = lambda k, default=None: row_data.get(k, default) mapping_mock = MagicMock() mapping_mock.first.return_value = row_mock exec_mock = MagicMock() exec_mock.mappings.return_value = mapping_mock exec_mock.first.return_value = row_mock # DELETE RETURNING id (не mappings) db.execute.return_value = exec_mock return db def test_service_create_insight_commits_and_sets_created_by() -> None: """create_insight вызывает db.commit() и мапит created_by — regression #261.""" from app.services.insights import create_insight db = _make_db_with_row(_make_row_data(is_confidential=True)) payload = InsightCreate(title="T", body="B", is_confidential=True) result = create_insight(db, _USER, payload) db.commit.assert_called_once() assert result.id == 1 assert result.created_by == _USER assert result.is_confidential is True def test_service_update_insight_commits_when_fields_given() -> None: """update_insight вызывает db.commit() при наличии изменяемых полей — regression #261.""" from app.services.insights import update_insight db = _make_db_with_row(_make_row_data(title="new")) payload = InsightUpdate(title="new") result = update_insight(db, insight_id=1, payload=payload) db.commit.assert_called_once() assert result is not None def test_service_update_insight_confidential_false_triggers_update() -> None: """is_confidential=False — это изменение (False is not None) → commit.""" from app.services.insights import update_insight db = _make_db_with_row(_make_row_data(is_confidential=False)) payload = InsightUpdate(is_confidential=False) update_insight(db, insight_id=1, payload=payload) db.commit.assert_called_once() def test_service_update_insight_no_commit_when_payload_empty() -> None: """Пустой payload → только updated_at в sets → UPDATE не выполняется, commit не зовётся.""" from app.services.insights import update_insight db = _make_db_with_row(_make_row_data()) update_insight(db, insight_id=1, payload=InsightUpdate()) db.commit.assert_not_called() def test_service_update_insight_no_commit_when_not_found() -> None: """update_insight НЕ коммитит если insight не найден.""" from app.services.insights import update_insight db = _make_db_with_row(None) result = update_insight(db, insight_id=999, payload=InsightUpdate(title="x")) db.commit.assert_not_called() assert result is None def test_service_delete_insight_commits_when_found() -> None: """delete_insight коммитит когда строка найдена — regression #261.""" from app.services.insights import delete_insight db = _make_db_with_row(_make_row_data()) db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy assert delete_insight(db, insight_id=1) is True db.commit.assert_called_once() def test_service_delete_insight_no_commit_when_not_found() -> None: """delete_insight НЕ коммитит если строки нет.""" from app.services.insights import delete_insight db = _make_db_with_row(None) db.execute.return_value.first.return_value = None # RETURNING id пусто assert delete_insight(db, insight_id=999) is False db.commit.assert_not_called() def test_service_list_insights_builds_filtered_query() -> None: """list_insights собирает WHERE по фильтрам + возвращает InsightList с total.""" from app.services.insights import list_insights db = MagicMock() row = _make_row_data(1, is_confidential=True) row_mock = MagicMock() row_mock.__getitem__ = lambda self, k: row[k] rows_result = MagicMock() rows_result.mappings.return_value.all.return_value = [row_mock] count_result = MagicMock() count_result.scalar_one.return_value = 1 db.execute.side_effect = [rows_result, count_result] out = list_insights(db, district="Октябрьский", is_confidential=True, limit=10, offset=0) assert isinstance(out, InsightList) assert out.total == 1 assert out.limit == 10 assert len(out.rows) == 1 assert out.rows[0].is_confidential is True # Первый execute — SELECT с WHERE по district + is_confidential, psycopg v3 CAST. select_sql = str(db.execute.call_args_list[0].args[0]) assert "WHERE" in select_sql assert "district = :district" in select_sql assert "is_confidential = CAST(:is_confidential AS boolean)" in select_sql assert "::" not in select_sql select_params = db.execute.call_args_list[0].args[1] assert select_params["district"] == "Октябрьский" assert select_params["is_confidential"] is True