"""Интеграционные тесты `/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 from services.email import EmailSendError 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=await 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", {}), ("POST", "/api/v1/admin/settings/test-email", {}), ] 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_list_users_filters_by_status( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все.""" admin = await _make_user(db_session, role="admin") active_user = await _make_user(db_session) blocked_user = await _make_user(db_session) blocked_user.is_blocked = True await db_session.commit() active_response = await client.get( "/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin) ) assert active_response.status_code == 200, active_response.text active_ids = [item["id"] for item in active_response.json()["items"]] assert str(active_user.id) in active_ids assert str(blocked_user.id) not in active_ids blocked_response = await client.get( "/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin) ) assert blocked_response.status_code == 200, blocked_response.text blocked_ids = [item["id"] for item in blocked_response.json()["items"]] assert str(blocked_user.id) in blocked_ids assert str(active_user.id) not in blocked_ids all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin)) all_ids = [item["id"] for item in all_response.json()["items"]] assert str(active_user.id) in all_ids assert str(blocked_user.id) in all_ids async def test_list_users_search_matches_team_name( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """`q` находит пользователей по (части) названия их команды; пользователи без команды при этом не пропадают из общей (без поиска) выдачи.""" admin = await _make_user(db_session, role="admin") suffix = uuid.uuid4().hex[:8] team = Team(name=f"Rocket-{suffix} Squad") db_session.add(team) await db_session.flush() teamed_user = await _make_user(db_session) teamed_user.team_id = team.id teamless_user = await _make_user(db_session) await db_session.commit() response = await client.get( "/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin) ) assert response.status_code == 200, response.text ids = [item["id"] for item in response.json()["items"]] assert str(teamed_user.id) in ids assert str(teamless_user.id) not in ids assert str(admin.id) not in ids all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin)) all_ids = [item["id"] for item in all_response.json()["items"]] assert str(teamless_user.id) in all_ids 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 async def test_put_settings_contact_email_enable_and_persist( 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={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"}, headers=_auth_headers(admin), ) assert response.status_code == 200, response.text body = response.json() assert body["contact_email_enabled"] is True assert body["contact_email"] == "contact@vidconf.ru" reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin)) assert reloaded.json()["contact_email"] == "contact@vidconf.ru" async def test_put_settings_contact_email_invalid_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={"contact_email_enabled": True, "contact_email": "not an email"}, headers=_auth_headers(admin), ) assert response.status_code == 400 async def test_get_settings_media_limits_defaults( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Дефолты рычагов нагрузки — без ограничения качества и с текущим максимумом сетки (5×5) — существующие инсталляции после обновления не получают внезапно ухудшенное качество.""" monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False) 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() assert body["publish_quality_cap"] == "off" assert body["stage_max_tiles"] == 25 async def test_put_settings_media_limits_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={"publish_quality_cap": "360p", "stage_max_tiles": 9}, headers=_auth_headers(admin), ) assert response.status_code == 200, response.text body = response.json() assert body["publish_quality_cap"] == "360p" assert body["stage_max_tiles"] == 9 reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin)) assert reloaded.json()["publish_quality_cap"] == "360p" assert reloaded.json()["stage_max_tiles"] == 9 async def test_put_settings_media_limits_invalid_values_return_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса ДО сервисного слоя, ещё на уровне FastAPI/pydantic.""" admin = await _make_user(db_session, role="admin") await db_session.commit() bad_cap = await client.put( "/api/v1/admin/settings", json={"publish_quality_cap": "4k"}, headers=_auth_headers(admin), ) assert bad_cap.status_code == 422 bad_tiles = await client.put( "/api/v1/admin/settings", json={"stage_max_tiles": 100}, headers=_auth_headers(admin), ) assert bad_tiles.status_code == 422 # --- Тестовое письмо ---------------------------------------------------------------- async def test_send_test_email_defaults_to_admin_email( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Без явного `to` тестовое письмо уходит на email текущего администратора.""" admin = await _make_user(db_session, role="admin") await db_session.commit() sent: dict[str, object] = {} class _FakeBackend: async def send(self, **kwargs: object) -> None: sent.update(kwargs) monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) response = await client.post( "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) ) assert response.status_code == 200, response.text body = response.json() assert body["success"] is True assert sent["to"] == admin.email async def test_send_test_email_to_explicit_recipient( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: admin = await _make_user(db_session, role="admin") await db_session.commit() sent: dict[str, object] = {} class _FakeBackend: async def send(self, **kwargs: object) -> None: sent.update(kwargs) monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) response = await client.post( "/api/v1/admin/settings/test-email", json={"to": "other@example.com"}, headers=_auth_headers(admin), ) assert response.status_code == 200, response.text assert sent["to"] == "other@example.com" async def test_send_test_email_uses_contact_email_as_reply_to( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: admin = await _make_user(db_session, role="admin") await db_session.commit() await client.put( "/api/v1/admin/settings", json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"}, headers=_auth_headers(admin), ) sent: dict[str, object] = {} class _FakeBackend: async def send(self, **kwargs: object) -> None: sent.update(kwargs) monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) response = await client.post( "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) ) assert response.status_code == 200, response.text assert sent["reply_to"] == "contact@vidconf.example" async def test_send_test_email_reports_transport_failure_without_leaking_secrets( client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет.""" monkeypatch.setenv("EMAIL_BACKEND", "smtp") monkeypatch.setenv("SMTP_HOST", "smtp.example.com") monkeypatch.setenv("SMTP_PORT", "2525") monkeypatch.setenv("SMTP_USERNAME", "secret-user") monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password") get_settings.cache_clear() class _FailingBackend: async def send(self, **kwargs: object) -> None: raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True) monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend()) try: admin = await _make_user(db_session, role="admin") await db_session.commit() response = await client.post( "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) ) assert response.status_code == 200, response.text body = response.json() assert body["success"] is False assert "сбой SMTP" in body["message"] assert body["smtp_host"] == "smtp.example.com" assert body["smtp_port"] == 2525 assert "secret-user" not in response.text assert "super-secret-password" not in response.text finally: get_settings.cache_clear()