Merge pull request 'Логи' (#12) from future/AURORA-1012 into test

Reviewed-on: #12
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
This commit is contained in:
Raykov-MS 2026-05-21 10:25:21 +03:00
commit 06ad5e0e93
15 changed files with 750 additions and 163 deletions

42
api/src/api/v1/audit.py Normal file
View File

@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import require_admin
from src.db.session import get_db
# from src.domain.models import Users
from src.db.models.app_user import AppUser
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
from src.services.auditlog_service import AuditLogService
router = APIRouter(tags=["audit"])
@router.get(
"/audit-logs",
response_model=BaseListResponse[AuditLog],
status_code=status.HTTP_200_OK,
summary="Получение журнала аудита",
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
)
async def get_audit_logs(
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
params: AuditLogQueryParams = Depends(),
):
offset = (params.page - 1) * params.limit
audit_service = AuditLogService(db)
logs, count = await audit_service.get_all(
user=current_user,
limit=params.limit,
offset=offset,
user_id=params.user_id,
org_unit_id=params.org_unit_id,
task_id=params.task_id,
form_id=params.form_id,
event_type=params.event_type,
date_from=params.date_from,
date_to=params.date_to,
)
result = [audit_service.orm_log_to_response(log) for log in logs]
return BaseListResponse(result=result, count=count)

View File

@ -1,12 +1,13 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.v1 import auth, users, admin, forms, form_phases, projects from src.api.v1 import auth, users, admin, audit, forms, form_phases, projects
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(audit.router)
api_router.include_router(forms.router) api_router.include_router(forms.router)
api_router.include_router(projects.router) api_router.include_router(projects.router)
api_router.include_router(form_phases.router) api_router.include_router(form_phases.router)

View File

@ -105,6 +105,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита", description="Размер батча для очистки аудита",
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE", alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
) )
AUDIT_LOG_CLEANUP_INTERVAL_SECONDS: int = Field(
default=86400,
description="Интервал автозапуска очистки аудита в секундах",
alias="AUDIT_LOG_CLEANUP_INTERVAL_SECONDS",
)
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
import enum import enum
from typing import Any, Generic, Literal, Optional, TypeVar from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"] FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
@ -280,3 +280,49 @@ class FormPhaseUpdate(BaseModel):
column_keys: list[str] | None = None column_keys: list[str] | None = None
opens_at: datetime | None = None opens_at: datetime | None = None
closes_at: datetime | None = None closes_at: datetime | None = None
class AuditLogBase(BaseModel):
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
entity: str = Field(..., description="Тип сущности")
entity_id: Optional[int] = Field(None, description="ID сущности")
action: str = Field(..., description="Действие")
payload_json: Optional[Dict[str, Any]] = None
class AuditLogInDB(AuditLogBase):
"""Схема записи аудита в базе данных."""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="ID записи")
user_id: Optional[int] = Field(None, description="ID пользователя")
at: datetime = Field(..., description="Дата/время события")
class AuditLog(AuditLogInDB):
"""Схема записи аудита для ответа API."""
user: Optional[User] = None
class AuditLogListResponse(ResponseBase):
"""Схема всех записей аудита для ответа API."""
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
class AuditLogQueryParams(BaseModel):
"""Query-параметры для фильтрации журнала аудита."""
page: int = Field(1, ge=1, description="Номер страницы")
limit: int = Field(
20, ge=1, le=100, description="Количество записей на странице"
)
user_id: Optional[int] = Field(None, description="ID пользователя")
org_unit_id: Optional[int] = Field(None, description="ID ССП")
task_id: Optional[int] = Field(None, description="ID задачи")
form_id: Optional[int] = Field(None, description="ID формы")
event_type: Optional[str] = Field(None, description="Тип события")
date_from: Optional[datetime] = Field(None, description="Дата начала (ISO 8601)")
date_to: Optional[datetime] = Field(None, description="Дата окончания (ISO 8601)")

View File

@ -1,6 +1,8 @@
import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI, Response from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -27,8 +29,72 @@ if not settings.DEBUG:
) )
_AUDIT_CLEANUP_LOCK_KEY = 21987431
async def _cleanup_audit_log_once() -> int:
if "postgresql" not in settings.DATABASE_URL:
return 0
total_deleted = 0
async with engine.begin() as conn:
lock_ok = (
await conn.execute(
text("SELECT pg_try_advisory_lock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
).scalar_one()
if not lock_ok:
return 0
try:
while True:
deleted = (
await conn.execute(
text(
"""
WITH doomed AS (
SELECT id
FROM v3.audit_log
WHERE event_dt < now() - make_interval(days => :retention_days)
ORDER BY id
LIMIT :batch_size
)
DELETE FROM v3.audit_log a
USING doomed d
WHERE a.id = d.id
RETURNING a.id
"""
),
{
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
},
)
).rowcount
if not deleted:
break
total_deleted += deleted
finally:
await conn.execute(
text("SELECT pg_advisory_unlock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
return total_deleted
async def _audit_cleanup_loop() -> None:
# Первый прогон сразу после старта, дальше — по интервалу.
while True:
try:
await _cleanup_audit_log_once()
except Exception:
pass
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG: if not settings.DEBUG:
await create_tables() await create_tables()
ProtectedSettings( ProtectedSettings(
@ -38,9 +104,14 @@ async def lifespan(app: FastAPI):
APP_NAME=settings.APP_NAME, APP_NAME=settings.APP_NAME,
), ),
) )
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try: try:
yield yield
finally: finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
await engine.dispose() await engine.dispose()

View File

@ -0,0 +1,79 @@
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import func
# from sqlalchemy.orm import selectinload
# from app.domain.models import AuditLog
from src.db.models.audit_log import AuditLog
class AuditLogRepository:
"""Репозиторий для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
return (
(
await self.db.execute(
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
)
)
.scalars()
.first()
)
async def get_all(
self,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
query = select(AuditLog)
query_count = select(func.count(AuditLog.id))
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
query_count = query_count.where(AuditLog.user_id == user_id)
if org_unit_id is not None:
query = query.where(AuditLog.org_unit_id == org_unit_id)
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
if task_id is not None:
query = query.where(AuditLog.task_id == task_id)
query_count = query_count.where(AuditLog.task_id == task_id)
if form_id is not None:
query = query.where(AuditLog.form_id == form_id)
query_count = query_count.where(AuditLog.form_id == form_id)
if event_type is not None:
query = query.where(AuditLog.event_type == event_type)
query_count = query_count.where(AuditLog.event_type == event_type)
if date_from is not None:
query = query.where(AuditLog.event_dt >= date_from)
query_count = query_count.where(AuditLog.event_dt >= date_from)
if date_to is not None:
query = query.where(AuditLog.event_dt <= date_to)
query_count = query_count.where(AuditLog.event_dt <= date_to)
query = query.order_by(AuditLog.event_dt.desc())
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return (await self.db.execute(query)).scalars().all(), (
await self.db.execute(query_count)
).scalar_one()

View File

@ -0,0 +1,96 @@
from datetime import datetime
from typing import Any, Dict, Iterable, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException
# from src.domain.models import AuditLog, UserRole, Users
from src.db.models.audit_log import AuditLog
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.repository.auditlog_repository import AuditLogRepository
from src.domain.schemas import AuditLog as AuditLogSchema
class AuditLogService:
"""Сервис для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
self.audit_repo = AuditLogRepository(db)
async def get(self, audit_log_id: int, user: AppUser) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get(audit_log_id)
async def get_all(
self,
user: AppUser,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get_all(
limit=limit,
offset=offset,
user_id=user_id,
org_unit_id=org_unit_id,
task_id=task_id,
form_id=form_id,
event_type=event_type,
date_from=date_from,
date_to=date_to,
)
def _can_view_audit_logs(self, user: AppUser) -> bool:
"""Проверяет, может ли пользователь просматривать журнал аудита."""
return user.role_id == UserRoleEnum.ADMIN.value
def orm_log_to_response(self, log: AuditLog) -> AuditLogSchema:
"""Маппинг записи ORM audit_log в формат ответа API (entity, entity_id, action, at, payload_json).
model_validate(orm) не подходит: в БД поля event_dt/event/event_type/event_data, в API at/action/entity/entity_id; entity и entity_id из event_data JSON.
"""
event_data: Optional[Dict[str, Any]] = getattr(log, "event_data", None) or {}
entity = (
event_data.get("entity_type") if isinstance(event_data, dict) else None
) or getattr(log, "event_type", "unknown")
entity_id = (
event_data.get("entity_id") if isinstance(event_data, dict) else None
)
if entity_id is not None and not isinstance(entity_id, int):
try:
entity_id = int(entity_id)
except (TypeError, ValueError):
entity_id = None
action = getattr(log, "event", "")
at = getattr(log, "event_dt", None)
return AuditLogSchema(
entity=entity,
entity_id=entity_id,
action=action,
payload_json=event_data if isinstance(event_data, dict) else None,
id=log.id,
user_id=getattr(log, "user_id", None),
at=at,
user=getattr(log, "user", None),
)

View File

@ -55,6 +55,13 @@ def admin_tokens(client, admin_password: str):
return response.json() return response.json()
@pytest.fixture
def auth_headers():
def _build(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
return _build
@pytest.fixture @pytest.fixture
def isp_tokens(client, isp_password: str): def isp_tokens(client, isp_password: str):
response = client.post( response = client.post(

View File

@ -0,0 +1,67 @@
# import uuid
#
#
# def _create_executor_and_tokens(client, admin_tokens, auth_headers) -> tuple[int, dict]:
# suffix = uuid.uuid4().hex[:8]
# username = f"audit_exec_{suffix}"
# password = "pass123"
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": password,
# "full_name": "Audit Executor",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# user_id = created.json()["result"]["id"]
#
# login = client.post(
# "/api/v1/auth/login",
# json={"username": username, "password": password},
# )
# assert login.status_code == 200
# return user_id, login.json()
def test_audit_logs_admin_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/audit-logs", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
def test_audit_logs_requires_auth(client):
response = client.get("/api/v1/audit-logs")
assert response.status_code == 403
# Тест временно отключён: внутри создаётся пользователь.
# def test_audit_logs_forbidden_for_non_admin(client, admin_tokens, auth_headers):
# user_id, executor_tokens = _create_executor_and_tokens(
# client, admin_tokens, auth_headers
# )
# try:
# response = client.get(
# "/api/v1/audit-logs",
# headers=auth_headers(executor_tokens),
# )
# assert response.status_code == 403
# finally:
# client.delete(
# f"/api/v1/users/{user_id}",
# headers=auth_headers(admin_tokens),
# )
def test_audit_logs_query_validation(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/audit-logs?limit=101",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422

View File

@ -3,12 +3,8 @@ import uuid
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def _pick_form_and_sheet(client, admin_tokens, auth_headers) -> tuple[int, str, str | None]:
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
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 assert response.status_code == 200
forms = response.json().get("result") or [] forms = response.json().get("result") or []
@ -20,7 +16,7 @@ def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
sheets_response = client.get( sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets", f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if sheets_response.status_code != 200: if sheets_response.status_code != 200:
continue continue
@ -36,9 +32,9 @@ def _pick_form_and_sheet(client, admin_tokens) -> tuple[int, str, str | None]:
def _pick_form_with_sheet( def _pick_form_with_sheet(
client, admin_tokens, target_sheet: str, form_type_code: str | None = None client, admin_tokens, auth_headers, target_sheet: str, form_type_code: str | None = None
) -> tuple[int, str]: ) -> tuple[int, str]:
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens)) response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
forms = response.json().get("result") or [] forms = response.json().get("result") or []
for form in forms: for form in forms:
@ -49,7 +45,7 @@ def _pick_form_with_sheet(
continue continue
sheets_response = client.get( sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets", f"/api/v1/form/{form_id}/sheets",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if sheets_response.status_code != 200: if sheets_response.status_code != 200:
continue continue
@ -59,7 +55,9 @@ def _pick_form_with_sheet(
pytest.skip(f"Не найдена форма с листом {target_sheet}") pytest.skip(f"Не найдена форма с листом {target_sheet}")
def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, direction: str | None) -> int: def _pick_input_line_id(
client, admin_tokens, auth_headers, form_id: int, sheet: str, direction: str | None
) -> int:
url = f"/api/v1/form/{form_id}/sheet/{sheet}" url = f"/api/v1/form/{form_id}/sheet/{sheet}"
params = [] params = []
if direction: if direction:
@ -67,7 +65,7 @@ def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, directio
if params: if params:
url = f"{url}?{'&'.join(params)}" url = f"{url}?{'&'.join(params)}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
rows = response.json().get("result") or [] rows = response.json().get("result") or []
for row in rows: for row in rows:
@ -78,13 +76,13 @@ def _pick_input_line_id(client, admin_tokens, form_id: int, sheet: str, directio
pytest.skip("Не найден INPUT line_id для проверки upd_form_cell") pytest.skip("Не найден INPUT line_id для проверки upd_form_cell")
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens): def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}" url = f"/api/v1/form/{form_id}/sheet/{sheet}"
if direction: if direction:
url = f"{url}?direction={direction}" url = f"{url}?direction={direction}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert isinstance(payload.get("result"), list) assert isinstance(payload.get("result"), list)
@ -94,9 +92,13 @@ def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens):
assert "data" in first_row assert "data" in first_row
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin_tokens): def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) client, admin_tokens, auth_headers
line_id = _pick_input_line_id(client, admin_tokens, form_id, sheet, direction) ):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
line_id = _pick_input_line_id(
client, admin_tokens, auth_headers, form_id, sheet, direction
)
url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell" url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell"
if direction: if direction:
@ -105,7 +107,7 @@ def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin
response = client.patch( response = client.patch(
url, url,
json={"line_id": line_id, "column": "bad.scope", "value": 1}, json={"line_id": line_id, "column": "bad.scope", "value": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
# SQL-функция отдает prefixed validation error. # SQL-функция отдает prefixed validation error.
@ -116,11 +118,13 @@ def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(client, admin
assert isinstance(payload["message"], str) assert isinstance(payload["message"], str)
def test_backend_calls_form3_add_project_and_add_line_functions(client, admin_tokens): def test_backend_calls_form3_add_project_and_add_line_functions(
client, admin_tokens, auth_headers
):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_payload = create.json() project_payload = create.json()
@ -132,18 +136,18 @@ def test_backend_calls_form3_add_project_and_add_line_functions(client, admin_to
add_line = client.post( add_line = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": 1}, json={"expense_item_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert add_line.status_code == 200 assert add_line.status_code == 200
rows = add_line.json() rows = add_line.json()
assert isinstance(rows, list) assert isinstance(rows, list)
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens): def test_forms_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR") form_id, sheet = _pick_form_with_sheet(client, admin_tokens, auth_headers, "AHR")
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section", f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -151,22 +155,26 @@ def test_forms_validation_invalid_sections_returns_400(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens): def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens) form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , " url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
if direction: if direction:
url += f"&direction={direction}" url += f"&direction={direction}"
response = client.get(url, headers=_auth_headers(admin_tokens)) response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert isinstance(payload.get("result"), list) assert isinstance(payload.get("result"), list)
def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens): def test_forms_validation_direction_required_for_form1_ahr(
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1") client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}", f"/api/v1/form/{form_id}/sheet/{sheet}",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -175,20 +183,26 @@ def test_forms_validation_direction_required_for_form1_ahr(client, admin_tokens)
assert "direction" in payload["message"].lower() assert "direction" in payload["message"].lower()
def test_forms_validation_direction_case_sensitive(client, admin_tokens): def test_forms_validation_direction_case_sensitive(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_1") form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support", f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 422 assert response.status_code == 422
def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens): def test_forms_validation_direction_forbidden_on_non_form1(
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, "AHR", form_type_code="FORM_2") client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_2"
)
response = client.get( response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support", f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -197,17 +211,17 @@ def test_forms_validation_direction_forbidden_on_non_form1(client, admin_tokens)
assert "direction" in payload["message"].lower() assert "direction" in payload["message"].lower()
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens): def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -215,44 +229,44 @@ def test_form3_validation_invalid_sections_returns_400(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens): def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json().get("result"), list) assert isinstance(response.json().get("result"), list)
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens): def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers):
create = client.post( create = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1}, json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert create.status_code == 200 assert create.status_code == 200
project_id = int(create.json()["project_id"]) project_id = int(create.json()["project_id"])
response = client.get( response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1", f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
assert isinstance(response.json().get("result"), list) assert isinstance(response.json().get("result"), list)
def test_form3_add_project_invalid_name_returns_422(client, admin_tokens): def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_headers):
# Пробел запрещён regex-правилом AddProjectBody. # Пробел запрещён regex-правилом AddProjectBody.
response = client.post( response = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1}, json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 422 assert response.status_code == 422
@ -271,38 +285,46 @@ def test_health_and_ready_endpoints(client):
assert "mv_expense_item_tree" 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( # def test_admin_refresh_tree_requires_admin_role(client, admin_tokens, auth_headers):
"/api/v1/admin/refresh-tree", # admin_resp = client.post(
headers=_auth_headers(admin_tokens), # "/api/v1/admin/refresh-tree",
) # headers=auth_headers(admin_tokens),
assert admin_resp.status_code in (200, 503) # )
# assert admin_resp.status_code in (200, 503)
uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}" #
create_user = client.put( # uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
"/api/v1/users/", # create_user = client.put(
json={ # "/api/v1/users/",
"email": f"{uname}@example.com", # json={
"username": uname, # "email": f"{uname}@example.com",
"password": "pass123", # "username": uname,
"full_name": "Integration User", # "password": "pass123",
"role_id": 2, # "full_name": "Integration User",
}, # "role_id": 2,
headers=_auth_headers(admin_tokens), # },
) # headers=auth_headers(admin_tokens),
if create_user.status_code != 200: # )
pytest.skip("Не удалось создать non-admin пользователя в текущей БД") # 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: # login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"})
pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД") # if login_resp.status_code != 200:
user_tokens = login_resp.json() # pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
# user_tokens = login_resp.json()
non_admin_resp = client.post( #
"/api/v1/admin/refresh-tree", # non_admin_resp = client.post(
headers=_auth_headers(user_tokens), # "/api/v1/admin/refresh-tree",
) # headers=auth_headers(user_tokens),
assert non_admin_resp.status_code == 403 # )
# assert non_admin_resp.status_code == 403
#
# # Что бы не было переполнения бд
# deleted = client.delete(
# f"/api/v1/users/{create_user.json()['result']['id']}",
# headers=auth_headers(admin_tokens),
# )
# assert deleted.status_code == 200
def test_admin_refresh_tree_requires_auth(client): def test_admin_refresh_tree_requires_auth(client):

View File

@ -1,12 +1,8 @@
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def test_forms_list_smoke(client, admin_tokens, auth_headers):
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
def test_forms_list_smoke(client, admin_tokens):
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert "result" in payload assert "result" in payload
@ -14,9 +10,9 @@ def test_forms_list_smoke(client, admin_tokens):
assert isinstance(payload["result"], list) assert isinstance(payload["result"], list)
def test_forms_sheets_not_found_error_shape(client, admin_tokens): def test_forms_sheets_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.get( response = client.get(
"/api/v1/form/999/sheets", headers=_auth_headers(admin_tokens) "/api/v1/form/999/sheets", headers=auth_headers(admin_tokens)
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -25,9 +21,9 @@ def test_forms_sheets_not_found_error_shape(client, admin_tokens):
assert isinstance(payload["message"], 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, auth_headers):
response = client.get( response = client.get(
"/api/v1/form/999/sheet/AHR", headers=_auth_headers(admin_tokens) "/api/v1/form/999/sheet/AHR", headers=auth_headers(admin_tokens)
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -35,11 +31,11 @@ def test_forms_sheet_get_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cell_update_not_found_error_shape(client, admin_tokens): def test_forms_cell_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/999/sheet/AHR/cell", "/api/v1/form/999/sheet/AHR/cell",
json={"line_id": 1, "column": "q1.m1", "value": 100}, json={"line_id": 1, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -47,7 +43,7 @@ def test_forms_cell_update_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cell_update(client, admin_tokens): def test_forms_cell_update(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary",
json={ json={
@ -55,7 +51,7 @@ def test_forms_cell_update(client, admin_tokens):
"column": "plan.q1", "column": "plan.q1",
"value": 225 "value": 225
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -63,11 +59,11 @@ def test_forms_cell_update(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_cells_update_not_found_error_shape(client, admin_tokens): def test_forms_cells_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/999/sheet/AHR/cells", "/api/v1/form/999/sheet/AHR/cells",
json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]}, json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -75,7 +71,7 @@ def test_forms_cells_update_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_cells_update(client, admin_tokens): def test_forms_cells_update(client, admin_tokens, auth_headers):
response = client.patch( response = client.patch(
"/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary",
json={ json={
@ -87,7 +83,7 @@ def test_forms_cells_update(client, admin_tokens):
} }
], ],
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -95,11 +91,11 @@ def test_forms_cells_update(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_add_line_not_found_error_shape(client, admin_tokens): def test_forms_add_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.post( response = client.post(
"/api/v1/form/999/sheet/AHR/line", "/api/v1/form/999/sheet/AHR/line",
json={}, json={},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()
@ -107,14 +103,14 @@ def test_forms_add_line_not_found_error_shape(client, admin_tokens):
assert "message" in payload assert "message" in payload
def test_forms_add_line(client, admin_tokens): def test_forms_add_line(client, admin_tokens, auth_headers):
response = client.post( response = client.post(
"/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary", "/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary",
json={ json={
"expense_item_id": 120, "expense_item_id": 120,
"direction": "Support" "direction": "Support"
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
@ -122,10 +118,10 @@ def test_forms_add_line(client, admin_tokens):
assert "result" in payload assert "result" in payload
def test_forms_delete_line_not_found_error_shape(client, admin_tokens): def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.delete( response = client.delete(
"/api/v1/form/999/sheet/AHR/line/1", "/api/v1/form/999/sheet/AHR/line/1",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 404 assert response.status_code == 404
payload = response.json() payload = response.json()

View File

@ -3,12 +3,8 @@ import uuid
import pytest import pytest
def _auth_headers(tokens: dict) -> dict: def test_projects_list_smoke(client, admin_tokens, auth_headers):
return {"Authorization": f"Bearer {tokens['access_token']}"} response = client.get("/api/v1/projects", headers=auth_headers(admin_tokens))
def test_projects_list_smoke(client, admin_tokens):
response = client.get("/api/v1/projects", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert "result" in payload assert "result" in payload
@ -16,24 +12,24 @@ def test_projects_list_smoke(client, admin_tokens):
assert isinstance(payload["result"], list) assert isinstance(payload["result"], list)
def _create_project(client, admin_tokens) -> tuple[int, str]: def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
name = f"PT_{uuid.uuid4().hex[:10]}" name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post( response = client.post(
"/api/v1/projects", "/api/v1/projects",
json={"name": name, "year": 2026, "branch_id": 1}, json={"name": name, "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
return payload["project_id"], name return payload["project_id"], name
def _add_line(client, admin_tokens, project_id: int) -> int: def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
for expense_item_id in range(1, 80): for expense_item_id in range(1, 80):
response = client.post( response = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": expense_item_id}, json={"expense_item_id": expense_item_id},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
if response.status_code != 200: if response.status_code != 200:
continue continue
@ -52,22 +48,22 @@ def _add_line(client, admin_tokens, project_id: int) -> int:
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД") pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_projects_write_smoke(client, admin_tokens): def test_projects_write_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens) project_id, name = _create_project(client, admin_tokens, auth_headers)
upd_project_response = client.patch( upd_project_response = client.patch(
f"/api/v1/project/{project_id}", f"/api/v1/project/{project_id}",
json={"column": "name", "value": f"{name}_U"}, json={"column": "name", "value": f"{name}_U"},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_project_response.status_code == 200 assert upd_project_response.status_code == 200
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
upd_cell_response = client.patch( upd_cell_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell", f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_cell_response.status_code == 200 assert upd_cell_response.status_code == 200
assert isinstance(upd_cell_response.json(), list) assert isinstance(upd_cell_response.json(), list)
@ -80,26 +76,26 @@ def test_projects_write_smoke(client, admin_tokens):
{"line_id": line_id, "column": "q1.m3", "value": 300}, {"line_id": line_id, "column": "q1.m3", "value": 300},
] ]
}, },
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert upd_cells_response.status_code == 200 assert upd_cells_response.status_code == 200
assert isinstance(upd_cells_response.json(), list) assert isinstance(upd_cells_response.json(), list)
del_line_response = client.delete( del_line_response = client.delete(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}", f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert del_line_response.status_code == 200 assert del_line_response.status_code == 200
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens): def test_projects_write_invalid_report_type_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens) project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch( response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell", f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()
@ -108,14 +104,14 @@ def test_projects_write_invalid_report_type_error_shape(client, admin_tokens):
assert isinstance(payload["message"], str) assert isinstance(payload["message"], str)
def test_projects_write_report_not_found_error_shape(client, admin_tokens): def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens) project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, project_id) line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch( response = client.patch(
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell", f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100}, json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens), headers=auth_headers(admin_tokens),
) )
assert response.status_code == 400 assert response.status_code == 400
payload = response.json() payload = response.json()

View File

@ -1,39 +1,39 @@
import uuid import uuid
import uuid def test_users_me(client, admin_tokens, auth_headers):
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_users_me(client, admin_tokens):
response = client.get("/api/v1/users/me", headers=_auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["username"] == "admin" assert response.json()["username"] == "admin"
# Задокуменировано что бы не было переполнения бд, так как юзер удаляется только логически
# def test_users_create_smoke(client, admin_tokens, auth_headers):
# suffix = uuid.uuid4().hex[:8]
# username = f"user_{suffix}"
# # create_payload = {
# # "email": f"{username}@example.com",
# # "username": username,
# # "password": "pass123",
# # "full_name": "User One",
# # "role_id": 2,
# # }
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": "pass123",
# "full_name": "User One",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# assert created.json()["result"]["username"] == username
def test_users_create_smoke(client, admin_tokens): # # Что бы не было переполнения бд
suffix = uuid.uuid4().hex[:8] # deleted = client.delete(
username = f"user_{suffix}" # f"/api/v1/users/{created.json()['result']['id']}",
create_payload = { # headers=auth_headers(admin_tokens),
"email": f"{username}@example.com", # )
"username": username, # assert deleted.status_code == 200
"password": "pass123",
"full_name": "User One",
"role_id": 2,
}
created = client.put(
"/api/v1/users/",
json={
"email": f"{username}@example.com",
"username": username,
"password": "pass123",
"full_name": "User One",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
)
assert created.status_code == 200
assert created.json()["result"]["username"] == username

View File

@ -0,0 +1,68 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.auditlog_repository import AuditLogRepository
def _result_with_scalars(items):
result = MagicMock()
scalars = MagicMock()
scalars.first.return_value = items[0] if items else None
scalars.all.return_value = items
result.scalars.return_value = scalars
return result
@pytest.fixture
def mock_db():
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def repository(mock_db):
return AuditLogRepository(mock_db)
@pytest.mark.asyncio
async def test_get_returns_audit_log(repository, mock_db):
row = MagicMock(id=10, user_id=1, event_type="WRITE")
mock_db.execute.return_value = _result_with_scalars([row])
result = await repository.get(10)
assert result is row
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_returns_none_for_missing_id(repository, mock_db):
mock_db.execute.return_value = _result_with_scalars([])
result = await repository.get(999999)
assert result is None
@pytest.mark.asyncio
async def test_get_all_applies_filters(repository, mock_db):
row = MagicMock(id=11, user_id=2, org_unit_id=7, event_type="UPDATE")
mock_db.execute.return_value = _result_with_scalars([row])
result = await repository.get_all(
limit=10,
offset=5,
user_id=2,
org_unit_id=7,
task_id=3,
form_id=4,
event_type="UPDATE",
date_from=datetime(2026, 1, 1, tzinfo=timezone.utc),
date_to=datetime(2026, 12, 31, tzinfo=timezone.utc),
)
assert result == [row]
mock_db.execute.assert_awaited_once()

View File

@ -0,0 +1,91 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.errors import AccessDeniedException
from src.db.models.role import UserRoleEnum
from src.services.auditlog_service import AuditLogService
@pytest.fixture
def service_with_mock_repo(monkeypatch):
mock_repo = MagicMock()
mock_repo.get = AsyncMock()
mock_repo.get_all = AsyncMock()
repo_cls = MagicMock(return_value=mock_repo)
monkeypatch.setattr("src.services.auditlog_service.AuditLogRepository", repo_cls)
service = AuditLogService(AsyncMock())
return service, mock_repo
@pytest.fixture
def admin_user():
return SimpleNamespace(
id=1,
role_id=UserRoleEnum.ADMIN.value,
email="admin@example.com",
)
@pytest.fixture
def non_admin_user():
return SimpleNamespace(
id=2,
role_id=UserRoleEnum.EXECUTOR_DFIP.value,
email="executor@example.com",
)
@pytest.mark.asyncio
async def test_get_all_allowed_for_admin(service_with_mock_repo, admin_user):
service, mock_repo = service_with_mock_repo
mock_row = SimpleNamespace(id=10)
mock_repo.get_all.return_value = [mock_row]
result = await service.get_all(admin_user, org_unit_id=77, event_type="form")
assert result == [mock_row]
mock_repo.get_all.assert_awaited_once_with(
limit=None,
offset=None,
user_id=None,
org_unit_id=77,
task_id=None,
form_id=None,
event_type="form",
date_from=None,
date_to=None,
)
@pytest.mark.asyncio
async def test_get_all_denied_for_non_admin(service_with_mock_repo, non_admin_user):
service, mock_repo = service_with_mock_repo
with pytest.raises(AccessDeniedException):
await service.get_all(non_admin_user)
mock_repo.get_all.assert_not_called()
def test_orm_log_to_response_maps_entity_and_entity_id(service_with_mock_repo):
service, _ = service_with_mock_repo
log = SimpleNamespace(
id=55,
user_id=7,
event="UPDATE",
event_type="fallback_entity",
event_dt="2026-05-19T12:00:00Z",
event_data={"entity_type": "form_line", "entity_id": "42", "x": 1},
user=None,
)
dto = service.orm_log_to_response(log)
assert dto.entity == "form_line"
assert dto.entity_id == 42
assert dto.action == "UPDATE"
assert dto.user_id == 7