All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2) will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix): - current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id) - future <- new manual-entry own_planned_project entity (migration 148) Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []), own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean. Does not touch special_indices.py or parcels.py (out of scope). Refs #1169
464 lines
18 KiB
Python
464 lines
18 KiB
Python
"""Тесты для 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?<filters> → 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"] == "Комфорт"
|