gendesign/backend/tests/api/v1/test_own_projects.py
Light1YT df34e55ab4
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
feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
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
2026-06-08 16:21:53 +05:00

464 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для 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"] == "Комфорт"