Merge branch 'test' into front
This commit is contained in:
commit
188189534b
@ -15,6 +15,8 @@ from src.api.v1.deps import get_current_active_user_with_set_db
|
|||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
|
|
||||||
router = APIRouter(prefix="/form", tags=["forms"])
|
router = APIRouter(prefix="/form", tags=["forms"])
|
||||||
|
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
|
||||||
|
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
|
||||||
|
|
||||||
|
|
||||||
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
|
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
|
||||||
@ -28,6 +30,31 @@ def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sections_csv(sections: Optional[str]) -> Optional[list[str]]:
|
||||||
|
if sections is None:
|
||||||
|
return None
|
||||||
|
parsed = [section.strip() for section in sections.split(",") if section.strip()]
|
||||||
|
return parsed or None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sheet_query_params(
|
||||||
|
form_type_code: FormTypeEnum,
|
||||||
|
sheet: str,
|
||||||
|
direction: Optional[DirectionSchemaEnum],
|
||||||
|
sections: Optional[list[str]],
|
||||||
|
) -> None:
|
||||||
|
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
|
||||||
|
raise HTTPException(400, "direction обязателен для FORM_1 листов AHR/CAP")
|
||||||
|
|
||||||
|
if direction and not (
|
||||||
|
form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
|
||||||
|
):
|
||||||
|
raise HTTPException(400, "direction допустим только для FORM_1 листов AHR/CAP")
|
||||||
|
|
||||||
|
if sections and sheet not in SHEETS_WITH_SECTIONS:
|
||||||
|
raise HTTPException(400, f"sections не поддерживается для листа {sheet}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def get_forms(
|
async def get_forms(
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@ -51,7 +78,7 @@ async def get_forms(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("{form_id}/sheets")
|
@router.get("/{form_id}/sheets")
|
||||||
async def get_form_sheets(
|
async def get_form_sheets(
|
||||||
form_id: int,
|
form_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@ -81,8 +108,7 @@ async def get_sheet(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
) -> BaseListResponse[SheetResponse]:
|
) -> BaseListResponse[SheetResponse]:
|
||||||
if sections is not None:
|
sections = _parse_sections_csv(sections)
|
||||||
sections = sections.split(",")
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
bf_service = BudgetFormService(db)
|
bf_service = BudgetFormService(db)
|
||||||
sheet_service = SheetService(db)
|
sheet_service = SheetService(db)
|
||||||
@ -96,10 +122,9 @@ async def get_sheet(
|
|||||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||||
if sheet not in form.form_type.sheet_list:
|
if sheet not in form.form_type.sheet_list:
|
||||||
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
|
||||||
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
||||||
raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден")
|
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
|
||||||
if form.form_type.code == FormTypeEnum.FORM_1 and not direction:
|
|
||||||
raise HTTPException(400, f"Форма типа {form.form_type.code} должна содержать в запросе direction")
|
|
||||||
|
|
||||||
result = await sheet_service.get(
|
result = await sheet_service.get(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
@ -126,8 +151,7 @@ async def update_cell(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
) -> BaseListResponse[SheetResponse]:
|
) -> BaseListResponse[SheetResponse]:
|
||||||
if sections is not None:
|
sections = _parse_sections_csv(sections)
|
||||||
sections = sections.split(",")
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
bf_service = BudgetFormService(db)
|
bf_service = BudgetFormService(db)
|
||||||
sheet_service = SheetService(db)
|
sheet_service = SheetService(db)
|
||||||
@ -141,8 +165,9 @@ async def update_cell(
|
|||||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||||
if sheet not in form.form_type.sheet_list:
|
if sheet not in form.form_type.sheet_list:
|
||||||
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
|
||||||
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
||||||
raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден")
|
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
|
||||||
|
|
||||||
budget_line_service = BudgetLineService(db)
|
budget_line_service = BudgetLineService(db)
|
||||||
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
|
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
|
||||||
@ -181,8 +206,7 @@ async def update_cells(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
) -> BaseListResponse[SheetResponse]:
|
) -> BaseListResponse[SheetResponse]:
|
||||||
if sections is not None:
|
sections = _parse_sections_csv(sections)
|
||||||
sections = sections.split(",")
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
bf_service = BudgetFormService(db)
|
bf_service = BudgetFormService(db)
|
||||||
sheet_service = SheetService(db)
|
sheet_service = SheetService(db)
|
||||||
@ -196,8 +220,9 @@ async def update_cells(
|
|||||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||||
if sheet not in form.form_type.sheet_list:
|
if sheet not in form.form_type.sheet_list:
|
||||||
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
|
||||||
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
||||||
raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден")
|
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
|
||||||
|
|
||||||
budget_line_service = BudgetLineService(db)
|
budget_line_service = BudgetLineService(db)
|
||||||
budget_lines = await budget_line_service.get_list(
|
budget_lines = await budget_line_service.get_list(
|
||||||
@ -246,6 +271,7 @@ async def add_line(
|
|||||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||||
if sheet not in form.form_type.sheet_list:
|
if sheet not in form.form_type.sheet_list:
|
||||||
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
_validate_sheet_query_params(form.form_type.code, sheet, body.direction, None)
|
||||||
|
|
||||||
result = await sheet_service.add_line(
|
result = await sheet_service.add_line(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
@ -278,7 +304,7 @@ async def delete_line(
|
|||||||
sheet: str,
|
sheet: str,
|
||||||
row_id: int,
|
row_id: int,
|
||||||
response: Response,
|
response: Response,
|
||||||
direction: str | None = None,
|
direction: Optional[DirectionSchemaEnum] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
):
|
):
|
||||||
@ -295,12 +321,13 @@ async def delete_line(
|
|||||||
raise HTTPException(404, f"Форма {form_id} не найдена")
|
raise HTTPException(404, f"Форма {form_id} не найдена")
|
||||||
if sheet not in form.form_type.sheet_list:
|
if sheet not in form.form_type.sheet_list:
|
||||||
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
_validate_sheet_query_params(form.form_type.code, sheet, direction, None)
|
||||||
|
|
||||||
result = await sheet_service.delete_line(
|
result = await sheet_service.delete_line(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
row_id=row_id,
|
row_id=row_id,
|
||||||
direction=direction,
|
direction=direction.value if direction else None,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
db_ms = (time.perf_counter() - t0) * 1000
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|||||||
@ -21,12 +21,17 @@ from src.services.project_service import ProjectService
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["forms"])
|
router = APIRouter(tags=["forms"])
|
||||||
|
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
|
||||||
|
|
||||||
|
|
||||||
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
|
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
|
||||||
if not sections:
|
if not sections:
|
||||||
return None
|
return None
|
||||||
return [s.strip() for s in sections.split(",") if s.strip()]
|
parsed = [s.strip() for s in sections.split(",") if s.strip()]
|
||||||
|
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(400, f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
|
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
|
||||||
|
|||||||
@ -32,14 +32,14 @@ class SheetRepository:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str] | None = None,
|
sections: list[str] | None = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
if any(
|
if any(
|
||||||
[
|
[
|
||||||
not isinstance(form_id, int),
|
not isinstance(form_id, int),
|
||||||
sheet not in ACCEPTABLE_SHEETS,
|
sheet not in ACCEPTABLE_SHEETS,
|
||||||
direction not in [s.value for s in DirectionEnum],
|
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
@ -72,8 +72,8 @@ class SheetRepository:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str],
|
sections: list[str] | None,
|
||||||
line_id: int,
|
line_id: int,
|
||||||
column: str,
|
column: str,
|
||||||
value: str | int | float | None = None,
|
value: str | int | float | None = None,
|
||||||
@ -83,7 +83,7 @@ class SheetRepository:
|
|||||||
[
|
[
|
||||||
not isinstance(form_id, int),
|
not isinstance(form_id, int),
|
||||||
sheet not in ACCEPTABLE_SHEETS,
|
sheet not in ACCEPTABLE_SHEETS,
|
||||||
direction not in [s.value for s in DirectionEnum],
|
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
@ -118,8 +118,8 @@ class SheetRepository:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str],
|
sections: list[str] | None,
|
||||||
changes: list[dict],
|
changes: list[dict],
|
||||||
user_id: int = None,
|
user_id: int = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
@ -127,7 +127,7 @@ class SheetRepository:
|
|||||||
[
|
[
|
||||||
not isinstance(form_id, int),
|
not isinstance(form_id, int),
|
||||||
sheet not in ACCEPTABLE_SHEETS,
|
sheet not in ACCEPTABLE_SHEETS,
|
||||||
direction not in [s.value for s in DirectionEnum],
|
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
|
|||||||
@ -15,7 +15,7 @@ class SheetService:
|
|||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str] | None = None,
|
sections: list[str] | None = None,
|
||||||
form_id: int | None = None,
|
form_id: int | None = None,
|
||||||
form: BudgetForm | None = None
|
form: BudgetForm | None = None
|
||||||
@ -31,8 +31,8 @@ class SheetService:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str],
|
sections: list[str] | None,
|
||||||
line_id: int,
|
line_id: int,
|
||||||
column: str,
|
column: str,
|
||||||
value: str | int | float | None = None,
|
value: str | int | float | None = None,
|
||||||
@ -53,8 +53,8 @@ class SheetService:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str],
|
sections: list[str] | None,
|
||||||
changes: list[dict],
|
changes: list[dict],
|
||||||
user: AppUser | None = None,
|
user: AppUser | None = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
@ -71,8 +71,8 @@ class SheetService:
|
|||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str,
|
direction: str | None,
|
||||||
sections: list[str],
|
sections: list[str] | None,
|
||||||
line_id: int,
|
line_id: int,
|
||||||
column: str,
|
column: str,
|
||||||
value: str | int | float | None = None,
|
value: str | int | float | None = None,
|
||||||
|
|||||||
310
api/tests/integration/test_db_functions_integration.py
Normal file
310
api/tests/integration/test_db_functions_integration.py
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(tokens: dict) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
|
||||||
|
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
forms = response.json().get("result") or []
|
||||||
|
|
||||||
|
for form in forms:
|
||||||
|
form_id = form.get("id")
|
||||||
|
form_type = form.get("form_type_code")
|
||||||
|
if not form_id or not form_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sheets_response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheets",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
if sheets_response.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
|
||||||
|
if "AHR" not in sheets:
|
||||||
|
continue
|
||||||
|
|
||||||
|
direction = "Support" if form_type == "FORM_1" else None
|
||||||
|
return int(form_id), "AHR", direction
|
||||||
|
|
||||||
|
pytest.skip("В тестовой БД не найдена форма с листом AHR")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_form_with_sheet(
|
||||||
|
client, admin_tokens, target_sheet: str, form_type_code: str | None = None
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
forms = response.json().get("result") or []
|
||||||
|
for form in forms:
|
||||||
|
form_id = form.get("id")
|
||||||
|
if not form_id:
|
||||||
|
continue
|
||||||
|
if form_type_code and form.get("form_type_code") != form_type_code:
|
||||||
|
continue
|
||||||
|
sheets_response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheets",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
if sheets_response.status_code != 200:
|
||||||
|
continue
|
||||||
|
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
|
||||||
|
if target_sheet in sheets:
|
||||||
|
return int(form_id), target_sheet
|
||||||
|
pytest.skip(f"Не найдена форма с листом {target_sheet}")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, direction: str | None) -> int:
|
||||||
|
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
|
||||||
|
params = []
|
||||||
|
if direction:
|
||||||
|
params.append(f"direction={direction}")
|
||||||
|
if params:
|
||||||
|
url = f"{url}?{'&'.join(params)}"
|
||||||
|
|
||||||
|
response = client.get(url, headers=_auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json().get("result") or []
|
||||||
|
for row in rows:
|
||||||
|
data = row.get("data") or {}
|
||||||
|
if row.get("row_type") == "INPUT" and data.get("line_id"):
|
||||||
|
return int(data["line_id"])
|
||||||
|
|
||||||
|
pytest.skip("Не найден INPUT line_id для проверки upd_form_cell")
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens):
|
||||||
|
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens)
|
||||||
|
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
|
||||||
|
if direction:
|
||||||
|
url = f"{url}?direction={direction}"
|
||||||
|
|
||||||
|
response = client.get(url, headers=_auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload.get("result"), list)
|
||||||
|
if payload["result"]:
|
||||||
|
first_row = payload["result"][0]
|
||||||
|
assert "row_type" in first_row
|
||||||
|
assert "data" in first_row
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin_tokens):
|
||||||
|
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens)
|
||||||
|
line_id = _pick_input_line_id(client, admin_tokens, form_id, sheet, direction)
|
||||||
|
|
||||||
|
url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell"
|
||||||
|
if direction:
|
||||||
|
url = f"{url}?direction={direction}"
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
url,
|
||||||
|
json={"line_id": line_id, "column": "bad.scope", "value": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
|
||||||
|
# SQL-функция отдает prefixed validation error.
|
||||||
|
assert response.status_code in (400, 422)
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
assert isinstance(payload["message"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_calls_form3_add_project_and_add_line_functions(client, admin_tokens):
|
||||||
|
create = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
project_payload = create.json()
|
||||||
|
assert "project_id" in project_payload
|
||||||
|
assert "limit_report_id" in project_payload
|
||||||
|
assert "current_expenses_report_id" in project_payload
|
||||||
|
|
||||||
|
project_id = int(project_payload["project_id"])
|
||||||
|
add_line = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
|
||||||
|
json={"expense_item_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert add_line.status_code == 200
|
||||||
|
rows = add_line.json()
|
||||||
|
assert isinstance(rows, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens):
|
||||||
|
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR")
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens):
|
||||||
|
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens)
|
||||||
|
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
|
||||||
|
if direction:
|
||||||
|
url += f"&direction={direction}"
|
||||||
|
response = client.get(url, headers=_auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload.get("result"), list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens):
|
||||||
|
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1")
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheet/{sheet}",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
assert "direction" in payload["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_validation_direction_case_sensitive(client, admin_tokens):
|
||||||
|
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1")
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens):
|
||||||
|
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_2")
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
assert "direction" in payload["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens):
|
||||||
|
create = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
project_id = int(create.json()["project_id"])
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens):
|
||||||
|
create = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
project_id = int(create.json()["project_id"])
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json().get("result"), list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens):
|
||||||
|
create = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
project_id = int(create.json()["project_id"])
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert isinstance(response.json().get("result"), list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_add_project_invalid_name_returns_422(client, admin_tokens):
|
||||||
|
# Пробел запрещён regex-правилом AddProjectBody.
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_and_ready_endpoints(client):
|
||||||
|
healthz = client.get("/healthz")
|
||||||
|
assert healthz.status_code == 200
|
||||||
|
health_payload = healthz.json()
|
||||||
|
assert health_payload.get("status") == "ok"
|
||||||
|
|
||||||
|
readyz = client.get("/readyz")
|
||||||
|
assert readyz.status_code in (200, 503)
|
||||||
|
ready_payload = readyz.json()
|
||||||
|
assert "status" in ready_payload
|
||||||
|
assert "db_status" in ready_payload
|
||||||
|
assert "mv_expense_item_tree" in ready_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_refresh_tree_requires_admin_role(client, admin_tokens):
|
||||||
|
admin_resp = client.post(
|
||||||
|
"/api/v1/admin/refresh-tree",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert admin_resp.status_code in (200, 503)
|
||||||
|
|
||||||
|
uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
|
||||||
|
create_user = client.put(
|
||||||
|
"/api/v1/users/",
|
||||||
|
json={
|
||||||
|
"email": f"{uname}@example.com",
|
||||||
|
"username": uname,
|
||||||
|
"password": "pass123",
|
||||||
|
"full_name": "Integration User",
|
||||||
|
"role_id": 2,
|
||||||
|
},
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
if create_user.status_code != 200:
|
||||||
|
pytest.skip("Не удалось создать non-admin пользователя в текущей БД")
|
||||||
|
|
||||||
|
login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"})
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
|
||||||
|
user_tokens = login_resp.json()
|
||||||
|
|
||||||
|
non_admin_resp = client.post(
|
||||||
|
"/api/v1/admin/refresh-tree",
|
||||||
|
headers=_auth_headers(user_tokens),
|
||||||
|
)
|
||||||
|
assert non_admin_resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_refresh_tree_requires_auth(client):
|
||||||
|
response = client.post("/api/v1/admin/refresh-tree")
|
||||||
|
assert response.status_code in (401, 403)
|
||||||
@ -21,8 +21,8 @@ def test_forms_sheets_not_found_error_shape(client, admin_tokens):
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert isinstance(payload, dict)
|
assert isinstance(payload, dict)
|
||||||
assert "detail" in payload
|
assert "message" in payload
|
||||||
assert isinstance(payload["detail"], str)
|
assert isinstance(payload["message"], str)
|
||||||
|
|
||||||
|
|
||||||
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens):
|
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens):
|
||||||
|
|||||||
74
api/tests/unit/test_query_validation.py
Normal file
74
api/tests/unit/test_query_validation.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.api.v1.forms import _parse_sections_csv, _validate_sheet_query_params
|
||||||
|
from src.api.v1.projects import _parse_sections
|
||||||
|
from src.db.models.form_type import FormTypeEnum
|
||||||
|
from src.domain.schemas import DirectionSchemaEnum
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sections_csv_returns_none_for_empty_values():
|
||||||
|
assert _parse_sections_csv(None) is None
|
||||||
|
assert _parse_sections_csv("") is None
|
||||||
|
assert _parse_sections_csv(" , , ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sections_csv_splits_and_trims():
|
||||||
|
assert _parse_sections_csv("plan, q1, q2 ") == ["plan", "q1", "q2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_sheet_query_requires_direction_for_form1_ahr_cap():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_sheet_query_params(FormTypeEnum.FORM_1, "AHR", None, None)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_sheet_query_rejects_direction_for_non_form1_or_non_ahr_cap():
|
||||||
|
with pytest.raises(HTTPException) as exc1:
|
||||||
|
_validate_sheet_query_params(
|
||||||
|
FormTypeEnum.FORM_2,
|
||||||
|
"AHR",
|
||||||
|
DirectionSchemaEnum.SUPPORT,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert exc1.value.status_code == 400
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc2:
|
||||||
|
_validate_sheet_query_params(
|
||||||
|
FormTypeEnum.FORM_1,
|
||||||
|
"SMETA",
|
||||||
|
DirectionSchemaEnum.SUPPORT,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert exc2.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_sheet_query_rejects_sections_for_non_main_sheets():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_sheet_query_params(FormTypeEnum.FORM_1, "SMETA", None, ["q1"])
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_sheet_query_accepts_valid_combinations():
|
||||||
|
_validate_sheet_query_params(
|
||||||
|
FormTypeEnum.FORM_1,
|
||||||
|
"AHR",
|
||||||
|
DirectionSchemaEnum.SUPPORT,
|
||||||
|
["plan", "q1"],
|
||||||
|
)
|
||||||
|
_validate_sheet_query_params(
|
||||||
|
FormTypeEnum.FORM_2,
|
||||||
|
"AHR",
|
||||||
|
None,
|
||||||
|
["plan", "q1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_form3_sections_accepts_supported_values():
|
||||||
|
assert _parse_sections("q1,q2,year") == ["q1", "q2", "year"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_form3_sections_rejects_invalid_values():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_parse_sections("q1,q5")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
Loading…
x
Reference in New Issue
Block a user