445 lines
17 KiB
Python
445 lines
17 KiB
Python
from datetime import datetime
|
||
import re
|
||
import uuid
|
||
|
||
import pytest
|
||
|
||
|
||
def test_projects_list_smoke(client, admin_tokens, auth_headers):
|
||
response = client.get("/api/v1/projects", headers=auth_headers(admin_tokens))
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert "result" in payload
|
||
assert "count" in payload
|
||
assert isinstance(payload["result"], list)
|
||
|
||
|
||
def _create_project(client, admin_tokens, auth_headers, year=2026, branch_id=1) -> tuple[int, str]:
|
||
name = f"PT_{uuid.uuid4().hex[:10]}"
|
||
response = client.post(
|
||
"/api/v1/projects",
|
||
json={
|
||
"name": name,
|
||
"year": year,
|
||
"branch_id": branch_id,
|
||
"project_type": "Открытие ВСП",
|
||
"vsp_format": "Типовой",
|
||
"placement_type": "Собственность",
|
||
"object_address": "Тестовая улица 1",
|
||
},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
return payload["project_id"], name
|
||
|
||
|
||
def _add_line(client, admin_tokens, auth_headers, project_id: int, year: int = 2026) -> int:
|
||
for expense_item_id in range(1, 80):
|
||
response = client.post(
|
||
f"/api/v1/projects/{project_id}/report/{year}/LIMIT/line",
|
||
json={"expense_item_id": expense_item_id},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
if response.status_code != 200:
|
||
continue
|
||
rows = response.json()
|
||
input_row = next(
|
||
(
|
||
row
|
||
for row in rows
|
||
if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id")
|
||
),
|
||
None,
|
||
)
|
||
if input_row:
|
||
return int(input_row["data"]["line_id"])
|
||
|
||
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
|
||
|
||
|
||
def test_create_project_smoke(client, admin_tokens, auth_headers):
|
||
name = f"PT_{uuid.uuid4().hex[:10]}"
|
||
response = client.post(
|
||
"/api/v1/projects",
|
||
json={
|
||
"name": name,
|
||
"year": None,
|
||
"branch_id": 1,
|
||
"project_type": "Открытие ВСП",
|
||
"vsp_format": "Типовой",
|
||
"placement_type": "Собственность",
|
||
"object_address": "Тестовая улица 1",
|
||
},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert "project_id" in payload
|
||
assert (project_id := payload["project_id"]) is not None
|
||
response = client.get(
|
||
f"/api/v1/projects/{project_id}",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert "result" in payload
|
||
assert "id" in payload["result"]
|
||
assert payload["result"]["id"] == project_id
|
||
assert "years" in payload["result"]
|
||
assert isinstance(payload["result"]["years"], list)
|
||
assert len(payload["result"]["years"]) == 1
|
||
|
||
|
||
def test_projects_write_smoke(client, admin_tokens, auth_headers):
|
||
project_id, name = _create_project(client, admin_tokens, auth_headers)
|
||
|
||
upd_project_response = client.patch(
|
||
f"/api/v1/project/{project_id}",
|
||
json={"name": f"{name}_U"},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert upd_project_response.status_code == 200
|
||
|
||
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||
|
||
upd_cell_response = client.patch(
|
||
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
|
||
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
|
||
assert upd_cell_response.status_code == 200
|
||
assert isinstance(upd_cell_response.json(), list)
|
||
|
||
upd_cells_response = client.patch(
|
||
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells",
|
||
json={
|
||
"changes": [
|
||
{"line_id": line_id, "column": "q1.m2", "value": 200},
|
||
{"line_id": line_id, "column": "q1.m3", "value": 300},
|
||
]
|
||
},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert upd_cells_response.status_code == 200
|
||
assert isinstance(upd_cells_response.json(), list)
|
||
|
||
del_line_response = client.delete(
|
||
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert del_line_response.status_code == 200
|
||
|
||
|
||
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers)
|
||
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||
|
||
response = client.patch(
|
||
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
|
||
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert isinstance(payload, dict)
|
||
assert "message" in payload
|
||
assert isinstance(payload["message"], str)
|
||
|
||
|
||
def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers)
|
||
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||
|
||
response = client.patch(
|
||
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
|
||
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert isinstance(payload, dict)
|
||
assert payload.get("message") == "Отчёт не найден"
|
||
|
||
|
||
def test_delete_project_smoke(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=datetime.now().year)
|
||
|
||
response = client.delete(
|
||
f"/api/v1/projects/{project_id}",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert payload["message"] == "Проект удалён"
|
||
|
||
|
||
def test_delete_project_verify_gone(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=datetime.now().year)
|
||
|
||
del_response = client.delete(
|
||
f"/api/v1/projects/{project_id}",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert del_response.status_code == 200
|
||
|
||
get_response = client.get(
|
||
f"/api/v1/projects/{project_id}",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert get_response.status_code == 404
|
||
|
||
|
||
def test_delete_project_not_found(client, admin_tokens, auth_headers):
|
||
response = client.delete(
|
||
"/api/v1/projects/99999",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert isinstance(payload, dict)
|
||
assert "message" in payload
|
||
|
||
|
||
def test_delete_project_unauthorized(client):
|
||
response = client.delete("/api/v1/projects/1")
|
||
assert response.status_code == 403
|
||
|
||
#ToDo: вернуть, когда пофиксим смещение фаз в зависимости от года
|
||
# def test_delete_project_with_lines_blocked(client, admin_tokens, auth_headers):
|
||
# """Проект со строками отчёта нельзя удалить: can_delete_project → false → RAISE EXCEPTION."""
|
||
# year = datetime.now().year - 1
|
||
# project_id, _ = _create_project(client, admin_tokens, auth_headers, year=year)
|
||
# _add_line(client, admin_tokens, auth_headers, project_id, year=year)
|
||
|
||
# response = client.delete(
|
||
# f"/api/v1/projects/{project_id}",
|
||
# headers=auth_headers(admin_tokens),
|
||
# )
|
||
# assert response.status_code == 400
|
||
# payload = response.json()
|
||
# assert isinstance(payload, dict)
|
||
# assert "code" in payload
|
||
# assert "message" in payload
|
||
# assert "невозможно удалить" in payload["message"]
|
||
|
||
|
||
def test_delete_project_executor_from_other_org(client, admin_tokens, auth_headers, isp_tokens):
|
||
"""ISP1 (привязан к РФ id=2) не может удалить проект на ССП id=1 — ACL."""
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=1, year=datetime.now().year)
|
||
|
||
response = client.delete(
|
||
f"/api/v1/projects/{project_id}",
|
||
headers=auth_headers(isp_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert "message" in payload
|
||
assert payload["message"] == "Проект не найден"
|
||
|
||
|
||
#ToDo: вернуть, когда пофиксим смещение фаз в зависимости от года
|
||
# def test_delete_project_executor_from_same_org(client, isp_tokens, auth_headers):
|
||
# """ISP1 может удалить проект на своём РФ (id=2), если нет строк отчёта."""
|
||
# project_id = _create_project(client, isp_tokens, auth_headers, branch_id=2, year=datetime.now().year)
|
||
|
||
# response = client.delete(
|
||
# f"/api/v1/projects/{project_id}",
|
||
# headers=auth_headers(isp_tokens),
|
||
# )
|
||
# assert response.status_code == 200
|
||
# payload = response.json()
|
||
# assert payload["success"] is True
|
||
# assert payload["message"] == "Проект удалён"
|
||
|
||
|
||
def test_delete_seed_project_smoke(client, admin_tokens, auth_headers):
|
||
"""Проект из фикстуры (id=2) удаляется: у него нет строк отчёта, can_delete_project → true."""
|
||
response = client.delete(
|
||
"/api/v1/projects/2",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert payload["message"] == "Проект удалён"
|
||
|
||
|
||
def _get_project_with_reports(client, auth_headers, admin_tokens, project_id):
|
||
response = client.get(
|
||
"/api/v1/projects/with-reports?limit=1000",
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
return next((p for p in response.json()["result"] if p["id"] == project_id), None)
|
||
|
||
|
||
def test_upd_project_smoke(client, admin_tokens, auth_headers):
|
||
project_id, name = _create_project(client, admin_tokens, auth_headers)
|
||
|
||
before = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||
assert before is not None
|
||
assert before["technical_number"]
|
||
technical_number_before = before["technical_number"]
|
||
|
||
response = client.patch(
|
||
f"/api/v1/project/{project_id}",
|
||
json={
|
||
"name": f"{name}_U",
|
||
"status": "agreed",
|
||
"technical_number": "9999_999999",
|
||
"krf_decision_date": "2026-06-11",
|
||
"fk_decision_date": "2026-06-18",
|
||
"board_decision_date": "2026-07-02",
|
||
"open_relocate_close_date": "2026-07-15",
|
||
"funding_by_ko_decision": "prrs_budget",
|
||
"placement_type": "Собственность",
|
||
},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||
assert project is not None
|
||
assert project["name"] == f"{name}_U"
|
||
assert project["status"] == "agreed"
|
||
# technical_number генерируется автоматически и не редактируется
|
||
assert project["technical_number"] == technical_number_before
|
||
assert re.fullmatch(r"\d{4}_\d{6}", project["technical_number"])
|
||
assert project["krf_decision_date"] == "2026-06-11"
|
||
assert project["fk_decision_date"] == "2026-06-18"
|
||
assert project["board_decision_date"] == "2026-07-02"
|
||
assert project["open_relocate_close_date"] == "2026-07-15"
|
||
assert project["funding_by_ko_decision"] == "prrs_budget"
|
||
# placement_type сконвертирован в БД-код и обратно в строковое представление
|
||
assert project["placement_type"] == "Собственность"
|
||
|
||
|
||
def test_create_project_auto_generates_technical_number(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers)
|
||
|
||
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||
assert project is not None
|
||
assert project["technical_number"]
|
||
assert re.fullmatch(r"\d{4}_\d{6}", project["technical_number"])
|
||
|
||
|
||
def test_upd_smeta_smoke(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||
|
||
response = client.patch(
|
||
f"/api/v1/projects/{project_id}/smeta/2026",
|
||
json={
|
||
"is_in_plan": True,
|
||
"is_in_plan_q2": True,
|
||
"is_in_plan_q3": False,
|
||
"is_in_plan_q4": False,
|
||
"development_block": "out_of_base_plan",
|
||
"reserve_to_prrs_ahr": 1500.5,
|
||
"reserve_to_prrs_kv": 2300,
|
||
},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["is_in_plan"] is True
|
||
assert payload["is_in_plan_q2"] is True
|
||
assert payload["is_in_plan_q3"] is False
|
||
assert payload["is_in_plan_q4"] is False
|
||
assert payload["development_block"] == "out_of_base_plan"
|
||
assert payload["reserve_to_prrs_ahr"] == 1500.5
|
||
assert payload["reserve_to_prrs_kv"] == 2300
|
||
|
||
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||
assert project is not None
|
||
sub_row = next((sr for sr in project["sub_rows"] if sr["year"] == 2026), None)
|
||
assert sub_row is not None
|
||
assert sub_row["is_in_plan"] is True
|
||
assert sub_row["development_block"] == "out_of_base_plan"
|
||
|
||
|
||
def test_upd_smeta_not_found(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||
response = client.patch(
|
||
f"/api/v1/projects/{project_id}/smeta/2099",
|
||
json={"is_in_plan": True},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert payload.get("message") == "Смета не найдена"
|
||
|
||
|
||
def test_add_project_year_smoke(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||
|
||
response = client.post(
|
||
f"/api/v1/projects/{project_id}/year",
|
||
json={"year": 2050},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["year"] == 2027
|
||
assert payload["project"] == "Смета_2027"
|
||
assert payload["id"] is not None
|
||
|
||
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||
assert project is not None
|
||
sub_years = {sr["year"] for sr in project["sub_rows"]}
|
||
assert 2026 in sub_years
|
||
assert 2027 in sub_years
|
||
|
||
|
||
def test_add_project_year_ignores_requested_year(client, admin_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||
|
||
response = client.post(
|
||
f"/api/v1/projects/{project_id}/year",
|
||
json={"year": 2026},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["year"] == 2027
|
||
|
||
|
||
def test_add_project_year_project_not_found(client, admin_tokens, auth_headers):
|
||
response = client.post(
|
||
"/api/v1/projects/99999/year",
|
||
json={"year": 2027},
|
||
headers=auth_headers(admin_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert payload.get("message") == "Проект не найден"
|
||
|
||
|
||
def test_add_project_year_by_executor(client, admin_tokens, isp_tokens, auth_headers):
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=2, year=2026)
|
||
|
||
response = client.post(
|
||
f"/api/v1/projects/{project_id}/year",
|
||
json={"year": 2027},
|
||
headers=auth_headers(isp_tokens),
|
||
)
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["year"] == 2027
|
||
|
||
|
||
def test_add_project_year_by_executor_from_other_org(client, admin_tokens, isp_tokens, auth_headers):
|
||
"""Исполнитель, привязанный к РФ id=2, не может добавить год проекту на ССП id=1 — ACL."""
|
||
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=1, year=2026)
|
||
|
||
response = client.post(
|
||
f"/api/v1/projects/{project_id}/year",
|
||
json={"year": 2027},
|
||
headers=auth_headers(isp_tokens),
|
||
)
|
||
assert response.status_code == 400
|
||
payload = response.json()
|
||
assert payload.get("message") == "Проект не найден"
|