"""Тесты для own_planned_project CRUD (#1169 PR1, ТЗ §25.3). Покрывает (зеркало test_insights.py): 1. POST /api/v1/own-projects → 201, created_by из X-Authenticated-User (не из тела) 2. POST без name → 422; bad obj_class → 422; price_min>max → 422; unit_mix вне [0,1] → 422 3. POST без X-Authenticated-User → 401 4. GET /api/v1/own-projects → OwnPlannedProjectList (total/limit/offset/rows) 5. GET /api/v1/own-projects? → district/obj_class/created_by в сервис 6. GET /api/v1/own-projects/{id} → 200; 404 если нет 7. PUT /api/v1/own-projects/{id} → 200 updated; 404 если нет; 401 без заголовка 8. DELETE /api/v1/own-projects/{id} → 204; 404 если нет; 401 без заголовка 9. Service-level: db.commit() в create/update/delete (#261 regression guard) Стратегия mock: сервисные функции патчим через unittest.mock.patch, DB — MagicMock (как test_insights.py). settings.testing=True (conftest) отключает rbac_guard. """ from __future__ import annotations from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from app.main import app from app.schemas.own_project import ( OwnPlannedProjectCreate, OwnPlannedProjectList, OwnPlannedProjectOut, OwnPlannedProjectUpdate, ) # ── Константы ───────────────────────────────────────────────────────────────── _USER = "analysttest" _TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC) _AUTH = {"X-Authenticated-User": _USER} # ── Helpers ──────────────────────────────────────────────────────────────────── def _make_out( project_id: int = 1, created_by: str = _USER, name: str = "ЖК Наш Будущий", district: str | None = "Октябрьский", obj_class: str | None = "Комфорт", planned_release_month: date | None = date(2027, 3, 1), price_min_per_m2: float | None = 140000.0, price_max_per_m2: float | None = 180000.0, unit_mix: dict[str, float] | None = None, lon: float | None = None, lat: float | None = None, ) -> OwnPlannedProjectOut: return OwnPlannedProjectOut( id=project_id, created_by=created_by, name=name, district=district, obj_class=obj_class, planned_release_month=planned_release_month, price_min_per_m2=price_min_per_m2, price_max_per_m2=price_max_per_m2, unit_mix=unit_mix, lon=lon, lat=lat, created_at=_TS, updated_at=_TS, ) # ── Tests: CREATE ──────────────────────────────────────────────────────────────── def test_create_own_project_returns_201_and_sets_created_by() -> None: """POST → 201; created_by берётся из X-Authenticated-User, не из тела.""" expected = _make_out() with patch( "app.api.v1.own_projects.create_own_project", return_value=expected ) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={ "name": "ЖК Наш Будущий", "district": "Октябрьский", "obj_class": "Комфорт", "planned_release_month": "2027-03-15", "price_min_per_m2": 140000, "price_max_per_m2": 180000, }, headers=_AUTH, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["name"] == "ЖК Наш Будущий" assert body["created_by"] == _USER mock_create.assert_called_once() # created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса. assert mock_create.call_args[0][1] == _USER def test_create_own_project_with_unit_mix() -> None: """unit_mix в теле → пробрасывается в payload сервиса.""" mix = {"studio": 0.3, "1k": 0.4, "2k": 0.2, "3k": 0.1} expected = _make_out(unit_mix=mix) with patch( "app.api.v1.own_projects.create_own_project", return_value=expected ) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={"name": "ЖК Микс", "unit_mix": mix}, headers=_AUTH, ) assert resp.status_code == 201, resp.text payload: OwnPlannedProjectCreate = mock_create.call_args[0][2] assert payload.unit_mix == mix def test_create_own_project_missing_name_returns_422() -> None: """name обязателен → 422.""" client = TestClient(app) resp = client.post("/api/v1/own-projects", json={"district": "X"}, headers=_AUTH) assert resp.status_code == 422, resp.text def test_create_own_project_bad_class_returns_422() -> None: """obj_class вне enum → 422 (Literal).""" client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={"name": "X", "obj_class": "Люкс-Делюкс"}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_own_project_price_min_above_max_returns_422() -> None: """price_min_per_m2 > price_max_per_m2 → 422 (model_validator).""" client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={"name": "X", "price_min_per_m2": 200000, "price_max_per_m2": 100000}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_own_project_negative_price_returns_422() -> None: """Отрицательная цена → 422 (ge=0).""" client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={"name": "X", "price_min_per_m2": -5}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_own_project_unit_mix_out_of_range_returns_422() -> None: """Доля unit_mix вне [0,1] → 422 (field_validator).""" client = TestClient(app) resp = client.post( "/api/v1/own-projects", json={"name": "X", "unit_mix": {"studio": 1.5}}, headers=_AUTH, ) assert resp.status_code == 422, resp.text def test_create_own_project_without_auth_header_returns_401() -> None: """Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах).""" client = TestClient(app) resp = client.post("/api/v1/own-projects", json={"name": "X"}) assert resp.status_code == 401, resp.text # ── Tests: LIST ────────────────────────────────────────────────────────────────── def test_list_own_projects_returns_envelope() -> None: """GET → OwnPlannedProjectList {total, limit, offset, rows}.""" listing = OwnPlannedProjectList( total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)] ) with patch("app.api.v1.own_projects.list_own_projects", return_value=listing): client = TestClient(app) resp = client.get("/api/v1/own-projects", headers=_AUTH) assert resp.status_code == 200, resp.text body = resp.json() assert body["total"] == 2 assert len(body["rows"]) == 2 assert body["rows"][0]["id"] == 1 def test_list_own_projects_passes_filters_to_service() -> None: """Фильтры district/obj_class/created_by → в сервис как kwargs.""" listing = OwnPlannedProjectList(total=0, limit=50, offset=0, rows=[]) with patch( "app.api.v1.own_projects.list_own_projects", return_value=listing ) as mock_list: client = TestClient(app) resp = client.get( "/api/v1/own-projects", params={"district": "Октябрьский", "obj_class": "Бизнес", "created_by": _USER}, headers=_AUTH, ) assert resp.status_code == 200, resp.text kwargs = mock_list.call_args.kwargs assert kwargs["district"] == "Октябрьский" assert kwargs["obj_class"] == "Бизнес" assert kwargs["created_by"] == _USER # ── Tests: GET one ─────────────────────────────────────────────────────────────── def test_get_own_project_returns_200() -> None: with patch("app.api.v1.own_projects.get_own_project", return_value=_make_out(7)): client = TestClient(app) resp = client.get("/api/v1/own-projects/7", headers=_AUTH) assert resp.status_code == 200, resp.text assert resp.json()["id"] == 7 def test_get_own_project_not_found_returns_404() -> None: with patch("app.api.v1.own_projects.get_own_project", return_value=None): client = TestClient(app) resp = client.get("/api/v1/own-projects/999", headers=_AUTH) assert resp.status_code == 404, resp.text # ── Tests: UPDATE ──────────────────────────────────────────────────────────────── def test_put_own_project_returns_updated() -> None: updated = _make_out(name="Переименовано") with patch("app.api.v1.own_projects.update_own_project", return_value=updated): client = TestClient(app) resp = client.put( "/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH ) assert resp.status_code == 200, resp.text assert resp.json()["name"] == "Переименовано" def test_put_own_project_not_found_returns_404() -> None: with patch("app.api.v1.own_projects.update_own_project", return_value=None): client = TestClient(app) resp = client.put("/api/v1/own-projects/999", json={"name": "x"}, headers=_AUTH) assert resp.status_code == 404, resp.text def test_put_own_project_without_auth_returns_401() -> None: client = TestClient(app) resp = client.put("/api/v1/own-projects/1", json={"name": "x"}) assert resp.status_code == 401, resp.text # ── Tests: DELETE ──────────────────────────────────────────────────────────────── def test_delete_own_project_returns_204() -> None: with patch("app.api.v1.own_projects.delete_own_project", return_value=True): client = TestClient(app) resp = client.delete("/api/v1/own-projects/1", headers=_AUTH) assert resp.status_code == 204, resp.text def test_delete_own_project_not_found_returns_404() -> None: with patch("app.api.v1.own_projects.delete_own_project", return_value=False): client = TestClient(app) resp = client.delete("/api/v1/own-projects/999", headers=_AUTH) assert resp.status_code == 404, resp.text def test_delete_own_project_without_auth_returns_401() -> None: client = TestClient(app) resp = client.delete("/api/v1/own-projects/1") assert resp.status_code == 401, resp.text # ── Service-level: commit + mapping (#261 regression guard) ───────────────────── def _make_row_data( project_id: int = 1, created_by: str = _USER, name: str = "ЖК Наш", district: str | None = "Октябрьский", obj_class: str | None = "Комфорт", planned_release_month: date | None = date(2027, 3, 1), price_min_per_m2: Any = 140000, price_max_per_m2: Any = 180000, unit_mix: dict[str, float] | None = None, lon: float | None = None, lat: float | None = None, ) -> dict[str, Any]: return { "id": project_id, "created_by": created_by, "name": name, "district": district, "obj_class": obj_class, "planned_release_month": planned_release_month, "price_min_per_m2": price_min_per_m2, "price_max_per_m2": price_max_per_m2, "unit_mix": unit_mix, "lon": lon, "lat": lat, "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_own_project_commits_and_sets_created_by() -> None: """create_own_project вызывает db.commit() и мапит created_by — regression #261.""" from app.services.own_projects import create_own_project db = _make_db_with_row(_make_row_data()) payload = OwnPlannedProjectCreate(name="ЖК Наш", obj_class="Комфорт") result = create_own_project(db, _USER, payload) db.commit.assert_called_once() assert result.id == 1 assert result.created_by == _USER assert result.obj_class == "Комфорт" def test_service_create_own_project_serialises_unit_mix() -> None: """unit_mix сериализуется в JSON-строку для CAST(:unit_mix AS jsonb).""" from app.services.own_projects import create_own_project mix = {"studio": 0.5, "1k": 0.5} db = _make_db_with_row(_make_row_data(unit_mix=mix)) payload = OwnPlannedProjectCreate(name="ЖК Микс", unit_mix=mix) create_own_project(db, _USER, payload) # unit_mix-параметр INSERT — JSON-строка, не dict (psycopg CAST AS jsonb). insert_params = db.execute.call_args_list[0].args[1] assert insert_params["unit_mix"] == '{"studio": 0.5, "1k": 0.5}' def test_service_create_own_project_uses_cast_not_double_colon() -> None: """INSERT использует CAST(:x AS type), никогда :x::type (psycopg v3).""" from app.services.own_projects import create_own_project db = _make_db_with_row(_make_row_data()) create_own_project(db, _USER, OwnPlannedProjectCreate(name="X")) insert_sql = str(db.execute.call_args_list[0].args[0]) assert "CAST(" in insert_sql assert "::" not in insert_sql def test_service_update_own_project_commits_when_fields_given() -> None: """update_own_project вызывает db.commit() при наличии изменяемых полей — #261.""" from app.services.own_projects import update_own_project db = _make_db_with_row(_make_row_data(name="new")) result = update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate(name="new")) db.commit.assert_called_once() assert result is not None def test_service_update_own_project_no_commit_when_payload_empty() -> None: """Пустой payload → только updated_at → UPDATE не выполняется, commit не зовётся.""" from app.services.own_projects import update_own_project db = _make_db_with_row(_make_row_data()) update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate()) db.commit.assert_not_called() def test_service_update_own_project_no_commit_when_not_found() -> None: """update_own_project НЕ коммитит если проект не найден.""" from app.services.own_projects import update_own_project db = _make_db_with_row(None) result = update_own_project(db, project_id=999, payload=OwnPlannedProjectUpdate(name="x")) db.commit.assert_not_called() assert result is None def test_service_delete_own_project_commits_when_found() -> None: """delete_own_project коммитит когда строка найдена — regression #261.""" from app.services.own_projects import delete_own_project db = _make_db_with_row(_make_row_data()) db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy assert delete_own_project(db, project_id=1) is True db.commit.assert_called_once() def test_service_delete_own_project_no_commit_when_not_found() -> None: """delete_own_project НЕ коммитит если строки нет.""" from app.services.own_projects import delete_own_project db = _make_db_with_row(None) db.execute.return_value.first.return_value = None # RETURNING id пусто assert delete_own_project(db, project_id=999) is False db.commit.assert_not_called() def test_service_list_own_projects_builds_filtered_query() -> None: """list_own_projects собирает WHERE по фильтрам + возвращает list с total.""" from app.services.own_projects import list_own_projects db = MagicMock() row = _make_row_data(1) 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_own_projects(db, district="Октябрьский", obj_class="Комфорт", limit=10, offset=0) assert isinstance(out, OwnPlannedProjectList) assert out.total == 1 assert out.limit == 10 assert len(out.rows) == 1 select_sql = str(db.execute.call_args_list[0].args[0]) assert "WHERE" in select_sql assert "district = :district" in select_sql assert "obj_class = :obj_class" 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["obj_class"] == "Комфорт"