Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,608 @@
"""Интеграционные тесты `/api/v1/admin/*` («Админ-API», блок C).
Покрывает: 403 не-админу на все эндпоинты; CRUD конференций (реюз
`ConferenceService`); PATCH пользователя (роль/блокировка), запрет
самоизменения (409); заблокированный пользователь получает 401 на защищённых
эндпоинтах; ручная рассылка приглашений ставит задачу (мок продюсера);
`PUT /admin/settings` с недоступным уровнем AI → 400; частичный PUT настроек.
"""
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
import api.admin as admin_module
from core.config import get_settings
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.team import Team
from models.user import User
from services import ai_levels
from services.conference_ids import generate_number, generate_slug
FUTURE = datetime.now(UTC) + timedelta(days=3)
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Admin API Tester",
password_hash=hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role)
return {"Authorization": f"Bearer {token}"}
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None, **overrides: object
) -> Conference:
defaults: dict[str, object] = {
"number": generate_number(),
"slug": generate_slug(),
"owner_id": owner_id,
"status": "scheduled",
"scheduled_at": FUTURE,
}
defaults.update(overrides)
conference = Conference(**defaults)
session.add(conference)
await session.flush()
return conference
# --- 403 не-админу -----------------------------------------------------------------
async def test_all_admin_endpoints_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=user.id)
other_user = await _make_user(db_session)
await db_session.commit()
headers = _auth_headers(user)
requests = [
("GET", "/api/v1/admin/conferences", None),
("PATCH", f"/api/v1/admin/conferences/{conference.id}", {"title": "x"}),
("DELETE", f"/api/v1/admin/conferences/{conference.id}", None),
("POST", f"/api/v1/admin/conferences/{conference.id}/invitations", {}),
("GET", "/api/v1/admin/users", None),
("GET", f"/api/v1/admin/users/{other_user.id}", None),
("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}),
("GET", "/api/v1/admin/settings", None),
("PUT", "/api/v1/admin/settings", {}),
]
for method, path, body in requests:
response = await client.request(method, path, json=body, headers=headers)
assert response.status_code == 403, f"{method} {path} -> {response.status_code}"
async def test_admin_endpoints_require_authentication(client: httpx.AsyncClient) -> None:
response = await client.get("/api/v1/admin/conferences")
assert response.status_code == 401
# --- Конференции ---------------------------------------------------------------------
async def test_list_conferences_returns_all(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, title="Sync")
await db_session.commit()
response = await client.get("/api/v1/admin/conferences", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
ids = [item["id"] for item in body["items"]]
assert str(conference.id) in ids
assert body["total"] >= 1
item = next(item for item in body["items"] if item["id"] == str(conference.id))
assert item["owner_name"] == owner.name_user
assert item["owner_email"] == owner.email
async def test_list_conferences_owner_fields_are_null_without_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=None, title="Ownerless")
await db_session.commit()
response = await client.get("/api/v1/admin/conferences", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
item = next(item for item in response.json()["items"] if item["id"] == str(conference.id))
assert item["owner_name"] is None
assert item["owner_email"] is None
async def test_list_conferences_filters_by_status_and_query(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
await _make_conference(db_session, owner_id=owner.id, title="Findable Sync", status="scheduled")
await _make_conference(db_session, owner_id=owner.id, title="Other", status="ended")
await db_session.commit()
response = await client.get(
"/api/v1/admin/conferences",
params={"status": "scheduled", "q": "Findable"},
headers=_auth_headers(admin),
)
assert response.status_code == 200
titles = [item["title"] for item in response.json()["items"]]
assert titles == ["Findable Sync"]
async def test_patch_conference_by_admin_returns_200(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, title="Original")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/conferences/{conference.id}",
json={"title": "Renamed by admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["title"] == "Renamed by admin"
assert body["owner_name"] == owner.name_user
assert body["owner_email"] == owner.email
async def test_patch_unknown_conference_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/conferences/{uuid.uuid4()}",
json={"title": "x"},
headers=_auth_headers(admin),
)
assert response.status_code == 404
async def test_delete_conference_by_admin_returns_204(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/conferences/{conference.id}", headers=_auth_headers(admin)
)
assert response.status_code == 204
async def test_delete_active_conference_by_admin_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(
db_session, owner_id=owner.id, status="active", scheduled_at=None
)
await db_session.commit()
response = await client.delete(
f"/api/v1/admin/conferences/{conference.id}", headers=_auth_headers(admin)
)
assert response.status_code == 409
async def test_send_invitations_enqueues_task(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_enqueue = MagicMock()
monkeypatch.setattr(admin_module, "enqueue_invitations", mock_enqueue)
response = await client.post(
f"/api/v1/admin/conferences/{conference.id}/invitations",
json={"emails": ["custom@example.com"]},
headers=_auth_headers(admin),
)
assert response.status_code == 202, response.text
mock_enqueue.assert_called_once_with(conference.id, emails=["custom@example.com"])
async def test_send_invitations_unknown_conference_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
monkeypatch.setattr(admin_module, "enqueue_invitations", MagicMock())
response = await client.post(
f"/api/v1/admin/conferences/{uuid.uuid4()}/invitations",
json={},
headers=_auth_headers(admin),
)
assert response.status_code == 404
# --- Создание пользователя администратором ---------------------------------------------
async def test_create_user_returns_201_and_login_works(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
email = f"{uuid.uuid4()}@example.com"
response = await client.post(
"/api/v1/admin/users",
json={"name_user": "Новый пользователь", "email": email, "password": "password123"},
headers=_auth_headers(admin),
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == email
assert body["name_user"] == "Новый пользователь"
assert body["role"] == "user"
assert body["email_verified"] is True
login = await client.post(
"/api/v1/auth/token", data={"username": email, "password": "password123"}
)
assert login.status_code == 200, login.text
assert "access_token" in login.json()
async def test_create_user_with_team_id(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
team = Team(name=f"Team {uuid.uuid4()}")
db_session.add(team)
await db_session.flush()
await db_session.commit()
email = f"{uuid.uuid4()}@example.com"
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "С командой",
"email": email,
"password": "password123",
"team_id": str(team.id),
},
headers=_auth_headers(admin),
)
assert response.status_code == 201, response.text
assert response.json()["team_id"] == str(team.id)
async def test_create_user_unknown_team_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Без команды",
"email": f"{uuid.uuid4()}@example.com",
"password": "password123",
"team_id": str(uuid.uuid4()),
},
headers=_auth_headers(admin),
)
assert response.status_code == 404
assert response.json()["detail"] == "team_not_found"
async def test_create_user_duplicate_email_returns_409(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
existing = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Дубль",
"email": existing.email,
"password": "password123",
},
headers=_auth_headers(admin),
)
assert response.status_code == 409
assert response.json()["detail"] == "email_already_registered"
async def test_create_user_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/admin/users",
json={
"name_user": "Not allowed",
"email": f"{uuid.uuid4()}@example.com",
"password": "password123",
},
headers=_auth_headers(user),
)
assert response.status_code == 403
# --- Пользователи ----------------------------------------------------------------------
async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
other = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
assert response.status_code == 200
items = response.json()["items"]
ids = [item["id"] for item in items]
assert str(admin.id) in ids
assert str(other.id) in ids
assert all("email_verified" in item for item in items)
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"role": "admin", "is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["role"] == "admin"
assert body["is_blocked"] is True
assert body["email_verified"] is True
async def test_get_user_returns_profile_card(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Карточка профиля из таблицы пользователей — те же поля, что `/users/me`."""
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.get(f"/api/v1/admin/users/{target.id}", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
assert body["id"] == str(target.id)
assert body["name_user"] == target.name_user
assert body["avatar_url"] is None
assert body["team_name"] is None
async def test_get_unknown_user_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get(f"/api/v1/admin/users/{uuid.uuid4()}", headers=_auth_headers(admin))
assert response.status_code == 404
async def test_patch_user_updates_name(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"name_user": "Renamed By Admin"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
assert response.json()["name_user"] == "Renamed By Admin"
async def test_upload_user_avatar_by_admin(
client: httpx.AsyncClient,
db_session: AsyncSession,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
get_settings.cache_clear()
try:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/users/{target.id}/avatar",
headers=_auth_headers(admin),
files={"file": ("avatar.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 32, "image/png")},
)
assert response.status_code == 200, response.text
assert response.json()["avatar_url"] is not None
finally:
get_settings.cache_clear()
async def test_upload_user_avatar_forbidden_for_non_admin(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
target = await _make_user(db_session)
await db_session.commit()
response = await client.post(
f"/api/v1/admin/users/{target.id}/avatar",
headers=_auth_headers(user),
files={"file": ("avatar.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 32, "image/png")},
)
assert response.status_code == 403
async def test_patch_self_returns_409(client: httpx.AsyncClient, db_session: AsyncSession) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{admin.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 409
async def test_blocked_user_gets_401_on_protected_endpoint(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
target = await _make_user(db_session)
await db_session.commit()
target_headers = _auth_headers(target)
block_response = await client.patch(
f"/api/v1/admin/users/{target.id}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert block_response.status_code == 200
response = await client.get("/api/v1/users/me", headers=target_headers)
assert response.status_code == 401
async def test_patch_unknown_user_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.patch(
f"/api/v1/admin/users/{uuid.uuid4()}",
json={"is_blocked": True},
headers=_auth_headers(admin),
)
assert response.status_code == 404
# --- Настройки --------------------------------------------------------------------------
async def test_get_settings_includes_ai_levels(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Плумбинг `detect_ai_levels` через API — не тест самого детекта (см.
`test_ai_levels.py`): железо теста-«пресета 3» смоделировано env-переменными
(RAM хватает на `min`, но не на `medium`; GPU нет — `max` недоступен), файлы
моделей считаются скачанными (изолируем тест от реального диска)."""
monkeypatch.setattr(ai_levels, "_model_downloaded", lambda _path: True)
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
monkeypatch.setenv("HW_RAM_MB", "16384")
monkeypatch.delenv("HW_GPU_NAME", raising=False)
get_settings.cache_clear()
try:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
levels = {item["level"]: item["available"] for item in body["ai_levels"]}
assert levels["min"] is True
assert levels["medium"] is False
assert levels["max"] is False
finally:
get_settings.cache_clear()
async def test_get_settings_transcription_queue_served_reflects_worker_presence(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`transcription_queue_served`
в ответе `GET /admin/settings` для обоих исходов детекта воркера (замокан
на уровне сервисной функции — сам детект Celery inspect покрыт
`test_task_producers.py`)."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: True)
served = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert served.status_code == 200, served.text
assert served.json()["transcription_queue_served"] is True
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
not_served = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert not_served.status_code == 200, not_served.text
assert not_served.json()["transcription_queue_served"] is False
async def test_put_settings_partial_update(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["chat_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False
async def test_put_settings_unavailable_ai_level_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"ai_level": "medium"},
headers=_auth_headers(admin),
)
assert response.status_code == 400