"""Интеграционные тесты `/api/v1/conferences`: создание, «мои», календарь, резолв, вход (пользователь/гость), пароль закрытых, правки, rate limit. """ import json import uuid from collections.abc import Generator from datetime import UTC, date, datetime, timedelta from pathlib import Path import httpx import jwt import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.config import get_settings from core.security import create_access_token, hash_password from models.conference import Conference from models.guest import GuestAccess from models.invitee import ConferenceInvitee from models.user import User from services.conference_ids import generate_number, generate_slug FUTURE = datetime.now(UTC) + timedelta(days=3) def _iso(dt: datetime) -> str: return dt.isoformat() async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Conference 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, status: str = "active", is_pinned: bool = False, is_closed: bool = False, password: str | None = None, ended_at: datetime | None = None, scheduled_at: datetime | None = None, duration_minutes: int | None = None, recurrence: dict[str, object] | None = None, ) -> Conference: conference = Conference( number=generate_number(), slug=generate_slug(), owner_id=owner_id, status=status, is_pinned=is_pinned, is_closed=is_closed, password_hash=await hash_password(password) if password else None, ended_at=ended_at, scheduled_at=scheduled_at, duration_minutes=duration_minutes, ) if recurrence is not None: conference.recurrence = recurrence session.add(conference) await session.flush() return conference def _weekly_recurrence() -> dict[str, object]: """Правило еженедельного повторения — для регрессионных тестов «Моих конференций».""" return { "type": "weekly", "weekdays": [0], "anchor_date": "2026-08-03", "time_local": "09:00", "timezone": "UTC", "duration_minutes": 30, } # --- Создание ----------------------------------------------------------------- async def test_create_instant_conference_returns_active_with_join( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() response = await client.post( "/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user) ) assert response.status_code == 201, response.text body = response.json() assert body["status"] == "active" assert len(body["number"]) == 9 assert body["number"].isdigit() assert body["slug"] assert body["join"] is not None assert body["join"]["conference_id"] == body["id"] assert body["join"]["room_name"] == body["slug"] assert body["join"]["token"] async def test_create_instant_conference_join_metadata_contains_owner_avatar_url( client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path ) -> None: """Мгновенное создание: владелец сразу входит — токен из `join` должен нести его аватар.""" user = await _make_user(db_session) avatar_path = f"avatars/{user.id}.png" (media_root / "avatars").mkdir(parents=True, exist_ok=True) (media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) user.avatar_path = avatar_path await db_session.commit() response = await client.post( "/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user) ) assert response.status_code == 201, response.text payload = _decode_livekit_token(response.json()["join"]["token"]) metadata = json.loads(str(payload["metadata"])) assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=") async def test_create_scheduled_conference_returns_scheduled_without_join( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() response = await client.post( "/api/v1/conferences", json={"title": "Planning", "scheduled_at": _iso(FUTURE), "duration_minutes": 30}, headers=_auth_headers(user), ) assert response.status_code == 201, response.text body = response.json() assert body["status"] == "scheduled" assert body["join"] is None async def test_create_closed_conference_without_password_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() response = await client.post( "/api/v1/conferences", json={"is_closed": True}, headers=_auth_headers(user) ) assert response.status_code == 422 async def test_create_recurrence_without_pinned_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() recurrence = { "type": "weekly", "weekdays": [0, 2, 4], "anchor_date": "2026-01-05", "time_local": "10:00", "timezone": "Europe/Moscow", "duration_minutes": 45, } response = await client.post( "/api/v1/conferences", json={"is_pinned": False, "recurrence": recurrence}, headers=_auth_headers(user), ) assert response.status_code == 422 async def test_create_scheduled_in_the_past_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() past = datetime.now(UTC) - timedelta(hours=1) response = await client.post( "/api/v1/conferences", json={"title": "Past", "scheduled_at": _iso(past)}, headers=_auth_headers(user), ) assert response.status_code == 422 # --- «Мои конференции» --------------------------------------------------------- async def test_pinned_conference_visible_in_my_conferences_immediately( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Weekly sync", "is_pinned": True}, headers=_auth_headers(user), ) assert created.status_code == 201 conference_id = created.json()["id"] my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user)) assert my_response.status_code == 200 ids = [c["id"] for c in my_response.json()] assert conference_id in ids async def test_non_pinned_instant_conference_not_in_my_conferences( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Ad hoc"}, headers=_auth_headers(user) ) conference_id = created.json()["id"] my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user)) assert conference_id not in [c["id"] for c in my_response.json()] # --- Регрессия: «Мои конференции» не должны быть пустыми --------------------------------------- async def test_admin_my_conferences_shows_pinned_recurring_and_upcoming_one_off( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Сценарий из бага: admin с закреплённой повторяющейся + разовой предстоящей видит обе.""" admin = await _make_user(db_session, role="admin") pinned_recurring = await _make_conference( db_session, owner_id=admin.id, status="scheduled", is_pinned=True, recurrence=_weekly_recurrence(), ) upcoming_one_off = await _make_conference( db_session, owner_id=admin.id, status="scheduled", scheduled_at=FUTURE ) await db_session.commit() response = await client.get("/api/v1/conferences/my", headers=_auth_headers(admin)) assert response.status_code == 200, response.text ids = {c["id"] for c in response.json()} assert str(pinned_recurring.id) in ids assert str(upcoming_one_off.id) in ids async def test_regular_user_my_conferences_shows_pinned_recurring_and_upcoming_one_off( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Тот же сценарий под обычным `user` — баг мог быть ролевым.""" user = await _make_user(db_session, role="user") pinned_recurring = await _make_conference( db_session, owner_id=user.id, status="scheduled", is_pinned=True, recurrence=_weekly_recurrence(), ) upcoming_one_off = await _make_conference( db_session, owner_id=user.id, status="scheduled", scheduled_at=FUTURE ) await db_session.commit() response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user)) assert response.status_code == 200, response.text ids = {c["id"] for c in response.json()} assert str(pinned_recurring.id) in ids assert str(upcoming_one_off.id) in ids async def test_my_conferences_excludes_others_and_ended( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Чужие конференции и завершённые разовые в выдачу владельца не попадают.""" owner = await _make_user(db_session) stranger = await _make_user(db_session) mine_pinned = await _make_conference( db_session, owner_id=owner.id, status="scheduled", is_pinned=True ) mine_upcoming = await _make_conference( db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE ) mine_ended_one_off = await _make_conference( db_session, owner_id=owner.id, status="ended", scheduled_at=datetime.now(UTC) - timedelta(days=1), ended_at=datetime.now(UTC), ) strangers_pinned = await _make_conference( db_session, owner_id=stranger.id, status="scheduled", is_pinned=True ) await db_session.commit() response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner)) assert response.status_code == 200, response.text ids = {c["id"] for c in response.json()} assert str(mine_pinned.id) in ids assert str(mine_upcoming.id) in ids assert str(mine_ended_one_off.id) not in ids assert str(strangers_pinned.id) not in ids # --- Видимость приглашённого в /my и /calendar (решение 2026-07-20 поверх ADR-003) --------- async def test_invitee_by_user_id_sees_pinned_and_upcoming_in_my_conferences( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) invitee = await _make_user(db_session) await db_session.commit() pinned = await _make_conference( db_session, owner_id=owner.id, status="scheduled", is_pinned=True ) upcoming = await _make_conference( db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE ) not_invited = await _make_conference( db_session, owner_id=owner.id, status="scheduled", is_pinned=True ) db_session.add(ConferenceInvitee(conference_id=pinned.id, user_id=invitee.id)) db_session.add(ConferenceInvitee(conference_id=upcoming.id, user_id=invitee.id)) await db_session.commit() response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee)) assert response.status_code == 200, response.text items = {c["id"]: c for c in response.json()} assert str(pinned.id) in items assert str(upcoming.id) in items assert str(not_invited.id) not in items assert items[str(pinned.id)]["is_owner"] is False assert items[str(pinned.id)]["organizer_name"] == owner.name_user async def test_invitee_by_email_case_insensitive_sees_conference_in_my_conferences( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Внешнее приглашение на email, под которым человек зарегистрирован (иной регистр).""" owner = await _make_user(db_session) invitee = await _make_user(db_session) await db_session.commit() pinned = await _make_conference( db_session, owner_id=owner.id, status="scheduled", is_pinned=True ) db_session.add(ConferenceInvitee(conference_id=pinned.id, email=invitee.email.upper())) await db_session.commit() response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee)) assert response.status_code == 200, response.text ids = {c["id"] for c in response.json()} assert str(pinned.id) in ids async def test_invitee_sees_occurrences_in_calendar( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) invitee = await _make_user(db_session) await db_session.commit() anchor = date(2026, 8, 3) # понедельник recurrence = { "type": "weekly", "weekdays": [0], "anchor_date": anchor.isoformat(), "time_local": "09:00", "timezone": "UTC", "duration_minutes": 30, } conference = await _make_conference( db_session, owner_id=owner.id, status="scheduled", is_pinned=True, recurrence=recurrence ) db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id)) await db_session.commit() t_from = datetime(2026, 8, 1, tzinfo=UTC) t_to = datetime(2026, 8, 15, tzinfo=UTC) response = await client.get( "/api/v1/conferences/calendar", params={"from": _iso(t_from), "to": _iso(t_to)}, headers=_auth_headers(invitee), ) assert response.status_code == 200, response.text occurrences = response.json() assert len(occurrences) == 2 assert all(o["conference_id"] == str(conference.id) for o in occurrences) async def test_invitee_by_user_id_gets_detail_with_participants( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) invitee = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled") db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id)) await db_session.commit() response = await client.get( f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee) ) assert response.status_code == 200, response.text body = response.json() assert body["is_owner"] is False assert body["organizer_name"] == owner.name_user user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None} assert str(invitee.id) in user_ids async def test_invitee_by_user_id_patch_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Видимость в списках/detail не расширяет права на правку/удаление (403 как раньше).""" owner = await _make_user(db_session) invitee = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled") db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id)) await db_session.commit() patch_response = await client.patch( f"/api/v1/conferences/{conference.id}", json={"title": "Hijack"}, headers=_auth_headers(invitee), ) assert patch_response.status_code == 403 assert patch_response.json()["detail"] == "not_owner" delete_response = await client.delete( f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee) ) assert delete_response.status_code == 403 assert delete_response.json()["detail"] == "not_owner" # --- Календарь ------------------------------------------------------------------ async def test_calendar_expands_recurrence_occurrences( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() anchor = date(2026, 8, 3) # понедельник recurrence = { "type": "weekly", "weekdays": [0], "anchor_date": anchor.isoformat(), "time_local": "09:00", "timezone": "UTC", "duration_minutes": 30, } created = await client.post( "/api/v1/conferences", json={"title": "Weekly", "is_pinned": True, "recurrence": recurrence}, headers=_auth_headers(user), ) assert created.status_code == 201 t_from = datetime(2026, 8, 1, tzinfo=UTC) t_to = datetime(2026, 8, 15, tzinfo=UTC) response = await client.get( "/api/v1/conferences/calendar", params={"from": _iso(t_from), "to": _iso(t_to)}, headers=_auth_headers(user), ) assert response.status_code == 200 occurrences = response.json() assert len(occurrences) == 2 # 3 и 10 августа 2026 async def test_calendar_range_too_wide_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() t_from = datetime.now(UTC) t_to = t_from + timedelta(days=100) response = await client.get( "/api/v1/conferences/calendar", params={"from": _iso(t_from), "to": _iso(t_to)}, headers=_auth_headers(user), ) assert response.status_code == 422 # --- Резолв (slug/номер) --------------------------------------------------------- async def test_resolve_by_slug_returns_200( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Findable"}, headers=_auth_headers(user) ) slug = created.json()["slug"] response = await client.get("/api/v1/conferences/resolve", params={"q": slug}) assert response.status_code == 200 body = response.json() assert body["title"] == "Findable" assert body["requires_password"] is False async def test_resolve_by_number_ignores_spaces( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Numbered"}, headers=_auth_headers(user) ) number = created.json()["number"] grouped = f"{number[:3]} {number[3:6]} {number[6:]}" response = await client.get("/api/v1/conferences/resolve", params={"q": grouped}) assert response.status_code == 200 assert response.json()["title"] == "Numbered" async def test_resolve_ended_conference_returns_minimal_response( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """ADR-001, п.4 (уточнение резолва): для ended — только id/title/status, без пароля.""" conference = await _make_conference( db_session, status="ended", is_closed=True, password="secret1", ended_at=datetime.now(UTC) ) await db_session.commit() response = await client.get("/api/v1/conferences/resolve", params={"q": conference.slug}) assert response.status_code == 200 body = response.json() assert body["id"] == str(conference.id) assert body["status"] == "ended" assert body.get("is_closed") is None assert body.get("requires_password") is None async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) -> None: response = await client.get( "/api/v1/conferences/resolve", params={"q": "does-not-exist-at-all"} ) assert response.status_code == 404 assert response.json()["detail"] == "not_found" def _ip_headers() -> dict[str, str]: """Уникальный `X-Real-IP` на каждый тест. Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса, поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи. Заодно это проверяет, что заголовок вообще читается: раньше ключ строился по `request.client.host`, то есть по адресу nginx, одинаковому для всех. """ return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"} async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None: """Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4).""" headers = _ip_headers() for _ in range(10): response = await client.get( "/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers ) assert response.status_code == 404 limited = await client.get( "/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers ) assert limited.status_code == 429 async def test_successful_resolves_are_not_limited_by_miss_counter( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026). Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый участник получал 429 — фронтенд показывал «Не удалось найти конференцию» для существующей и активной конференции. """ conference = await _make_conference(db_session) await db_session.commit() headers = _ip_headers() for _ in range(50): response = await client.get( "/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers ) assert response.status_code == 200, response.text async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None: """Счётчик привязан к адресу клиента, а не к адресу nginx. Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно работать. До исправления ключ был общим на весь инстанс. """ first, second = _ip_headers(), _ip_headers() for _ in range(11): await client.get( "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first ) exhausted = await client.get( "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first ) assert exhausted.status_code == 429 other = await client.get( "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second ) assert other.status_code == 404, "лимит одного клиента не должен задевать другого" # --- Вход зарегистрированным пользователем --------------------------------------- async def test_join_ended_conference_returns_410( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC)) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user) ) assert response.status_code == 410 assert response.json()["detail"] == "conference_ended" async def test_join_closed_conference_without_password_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) stranger = await _make_user(db_session) conference = await _make_conference( db_session, owner_id=owner.id, is_closed=True, password="secret1" ) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(stranger) ) assert response.status_code == 403 assert response.json()["detail"] == "password_required" async def test_join_closed_conference_wrong_password_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) stranger = await _make_user(db_session) conference = await _make_conference( db_session, owner_id=owner.id, is_closed=True, password="secret1" ) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", json={"password": "wrong-password"}, headers=_auth_headers(stranger), ) assert response.status_code == 403 assert response.json()["detail"] == "invalid_password" async def test_join_closed_conference_correct_password_returns_200( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) stranger = await _make_user(db_session) conference = await _make_conference( db_session, owner_id=owner.id, is_closed=True, password="secret1" ) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", json={"password": "secret1"}, headers=_auth_headers(stranger), ) assert response.status_code == 200 assert response.json()["room_name"] == conference.slug # --- Аватар в метаданных LiveKit-токена ----------------------------------------- @pytest.fixture def media_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]: """Изолированный `MEDIA_ROOT` на время теста (см. тот же приём в `test_users_api.py`).""" monkeypatch.setenv("MEDIA_ROOT", str(tmp_path)) get_settings.cache_clear() yield tmp_path get_settings.cache_clear() def _decode_livekit_token(token: str) -> dict[str, object]: """Декодировать JWT LiveKit-токена (тот же секрет, что использует сервис выдачи).""" return jwt.decode(token, get_settings().livekit_api_secret, algorithms=["HS256"]) async def test_join_metadata_contains_avatar_url_for_user_with_avatar( client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path ) -> None: user = await _make_user(db_session) avatar_path = f"avatars/{user.id}.png" (media_root / "avatars").mkdir(parents=True, exist_ok=True) (media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) user.avatar_path = avatar_path conference = await _make_conference(db_session) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user) ) assert response.status_code == 200, response.text payload = _decode_livekit_token(response.json()["token"]) metadata = json.loads(str(payload["metadata"])) assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=") async def test_join_metadata_absent_for_user_without_avatar( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: user = await _make_user(db_session) conference = await _make_conference(db_session) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user) ) assert response.status_code == 200, response.text payload = _decode_livekit_token(response.json()["token"]) assert "metadata" not in payload async def test_join_metadata_contains_is_organizer_for_owner( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner) ) assert response.status_code == 200, response.text payload = _decode_livekit_token(response.json()["token"]) metadata = json.loads(str(payload["metadata"])) assert metadata["is_organizer"] is True async def test_join_metadata_absent_is_organizer_for_non_owner( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) other = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other) ) assert response.status_code == 200, response.text payload = _decode_livekit_token(response.json()["token"]) assert "metadata" not in payload async def test_guest_join_metadata_is_absent( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Bob"}, ) assert response.status_code == 200, response.text payload = _decode_livekit_token(response.json()["token"]) assert "metadata" not in payload # --- Гостевой вход -------------------------------------------------------------- async def test_guest_join_creates_guest_access_and_returns_join( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Alice", "email": "alice-guest@example.com"}, ) assert response.status_code == 200, response.text body = response.json() assert body["room_name"] == conference.slug assert body["token"] guests = ( await db_session.scalars( select(GuestAccess).where(GuestAccess.conference_id == conference.id) ) ).all() assert len(guests) == 1 assert guests[0].display_name == "Guest Alice" assert guests[0].email == "alice-guest@example.com" async def test_guest_join_without_email_is_allowed( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest No-Email"}, ) assert response.status_code == 200 async def test_guest_join_closed_conference_without_password_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, is_closed=True, password="secret1") await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Bob"} ) assert response.status_code == 403 assert response.json()["detail"] == "password_required" async def test_guest_join_closed_conference_with_correct_password_returns_200( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, is_closed=True, password="secret1") await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Bob", "password": "secret1"}, ) assert response.status_code == 200 async def test_guest_join_ended_conference_returns_410( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC)) await db_session.commit() response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Late"} ) assert response.status_code == 410 assert response.json()["detail"] == "conference_ended" async def test_guest_join_allows_a_whole_conference_to_enter( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Успешные гостевые входы не упираются в лимит промахов. На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не пускало внутрь именно это ограничение — счётчик не различал легитимный массовый вход и перебор. """ conference = await _make_conference(db_session) await db_session.commit() headers = _ip_headers() for i in range(30): response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": f"Guest {i}"}, headers=headers, ) assert response.status_code == 200, response.text async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None: """Перебор идентификатора конференции по-прежнему упирается в лимит.""" headers = _ip_headers() missing_id = uuid.uuid4() for _ in range(10): response = await client.post( f"/api/v1/conferences/{missing_id}/guest-join", json={"display_name": "Bruteforce"}, headers=headers, ) assert response.status_code == 404 limited = await client.post( f"/api/v1/conferences/{missing_id}/guest-join", json={"display_name": "Bruteforce"}, headers=headers, ) assert limited.status_code == 429 async def test_guest_join_wrong_password_is_rate_limited( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Подбор пароля закрытой конференции считается тем же жёстким счётчиком.""" conference = await _make_conference(db_session, is_closed=True, password="right-password") await db_session.commit() headers = _ip_headers() for _ in range(10): response = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guesser", "password": "wrong"}, headers=headers, ) assert response.status_code == 403 limited = await client.post( f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guesser", "password": "wrong"}, headers=headers, ) assert limited.status_code == 429 # --- Правки/удаление ------------------------------------------------------------- async def test_patch_conference_by_owner_returns_200( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Original", "is_pinned": True}, headers=_auth_headers(owner), ) conference_id = created.json()["id"] response = await client.patch( f"/api/v1/conferences/{conference_id}", json={"title": "Renamed"}, headers=_auth_headers(owner), ) assert response.status_code == 200 assert response.json()["title"] == "Renamed" async def test_patch_conference_by_stranger_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) stranger = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Mine", "is_pinned": True}, headers=_auth_headers(owner), ) conference_id = created.json()["id"] response = await client.patch( f"/api/v1/conferences/{conference_id}", json={"title": "Hijack"}, headers=_auth_headers(stranger), ) assert response.status_code == 403 assert response.json()["detail"] == "not_owner" async def test_patch_unpin_conference_with_recurrence_clears_it( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Регрессия: открепление не должно требовать явного `recurrence: null`.""" owner = await _make_user(db_session) await db_session.commit() recurrence = { "type": "weekly", "weekdays": [0], "anchor_date": "2026-08-03", "time_local": "09:00", "timezone": "UTC", "duration_minutes": 30, } created = await client.post( "/api/v1/conferences", json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence}, headers=_auth_headers(owner), ) assert created.status_code == 201 assert created.json()["recurrence"] is not None conference_id = created.json()["id"] response = await client.patch( f"/api/v1/conferences/{conference_id}", json={"is_pinned": False}, headers=_auth_headers(owner), ) assert response.status_code == 200, response.text body = response.json() assert body["is_pinned"] is False assert body["recurrence"] is None # Проверяем, что в БД именно SQL NULL (а не JSON `null`), иначе следующий # PATCH/повторное чтение сломается на CHECK-constraint. stored = await db_session.scalar( select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id)) ) assert stored is None async def test_patch_explicit_recurrence_null_clears_it_and_keeps_pinned( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Явный `recurrence: null` при `is_pinned=true` очищает правило, но не открепляет.""" owner = await _make_user(db_session) await db_session.commit() recurrence = { "type": "weekly", "weekdays": [0], "anchor_date": "2026-08-03", "time_local": "09:00", "timezone": "UTC", "duration_minutes": 30, } created = await client.post( "/api/v1/conferences", json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence}, headers=_auth_headers(owner), ) conference_id = created.json()["id"] response = await client.patch( f"/api/v1/conferences/{conference_id}", json={"recurrence": None}, headers=_auth_headers(owner), ) assert response.status_code == 200, response.text body = response.json() assert body["is_pinned"] is True assert body["recurrence"] is None stored = await db_session.scalar( select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id)) ) assert stored is None async def test_delete_scheduled_conference_by_owner_returns_204( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "To delete", "scheduled_at": _iso(FUTURE)}, headers=_auth_headers(owner), ) conference_id = created.json()["id"] response = await client.delete( f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner) ) assert response.status_code == 204 async def test_delete_conference_by_stranger_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: """Матрица прав: удалить/отменить конференцию может только организатор.""" owner = await _make_user(db_session) stranger = 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/conferences/{conference.id}", headers=_auth_headers(stranger) ) assert response.status_code == 403 assert response.json()["detail"] == "not_owner" async def test_delete_active_conference_returns_409( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={"title": "Active"}, headers=_auth_headers(owner) ) conference_id = created.json()["id"] response = await client.delete( f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner) ) assert response.status_code == 409 assert response.json()["detail"] == "conference_active" async def test_delete_conference_by_admin_returns_204( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) admin = await _make_user(db_session, role="admin") conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled") await db_session.commit() response = await client.delete( f"/api/v1/conferences/{conference.id}", headers=_auth_headers(admin) ) assert response.status_code == 204 async def test_patch_conference_without_token_returns_401( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, status="scheduled") await db_session.commit() response = await client.patch(f"/api/v1/conferences/{conference.id}", json={"title": "x"}) assert response.status_code == 401 async def test_delete_conference_without_token_returns_401( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, status="scheduled") await db_session.commit() response = await client.delete(f"/api/v1/conferences/{conference.id}") assert response.status_code == 401 # --- Детальная карточка (GET /{id}) и участники (ADR-003) ----------- async def test_get_conference_detail_by_owner_returns_participants( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) invitee = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={ "title": "With participants", "scheduled_at": _iso(FUTURE), "participants": [{"user_id": str(invitee.id)}, {"email": "external@example.com"}], }, headers=_auth_headers(owner), ) assert created.status_code == 201, created.text conference_id = created.json()["id"] response = await client.get( f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner) ) assert response.status_code == 200, response.text body = response.json() assert body["owner_id"] == str(owner.id) assert body["is_owner"] is True assert body["organizer_name"] == owner.name_user user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None} emails = {p["email"] for p in body["participants"] if p["email"] is not None} assert str(owner.id) in user_ids assert str(invitee.id) in user_ids assert "external@example.com" in emails organizer_entries = [p for p in body["participants"] if p["is_organizer"]] assert len(organizer_entries) == 1 assert organizer_entries[0]["user_id"] == str(owner.id) async def test_get_conference_detail_by_stranger_returns_403( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) stranger = await _make_user(db_session) conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled") await db_session.commit() response = await client.get( f"/api/v1/conferences/{conference.id}", headers=_auth_headers(stranger) ) assert response.status_code == 403 assert response.json()["detail"] == "not_owner" async def test_get_conference_detail_unknown_id_returns_404( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() response = await client.get(f"/api/v1/conferences/{uuid.uuid4()}", headers=_auth_headers(owner)) assert response.status_code == 404 async def test_get_conference_detail_without_token_returns_401( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: conference = await _make_conference(db_session, status="scheduled") await db_session.commit() response = await client.get(f"/api/v1/conferences/{conference.id}") assert response.status_code == 401 async def test_create_with_unknown_participant_user_id_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() response = await client.post( "/api/v1/conferences", json={ "title": "Bad invitee", "scheduled_at": _iso(FUTURE), "participants": [{"user_id": str(uuid.uuid4())}], }, headers=_auth_headers(owner), ) assert response.status_code == 422 assert response.json()["detail"] == "invitee_user_not_found" async def test_invitee_both_user_id_and_email_returns_422( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) await db_session.commit() response = await client.post( "/api/v1/conferences", json={ "title": "Ambiguous invitee", "participants": [{"user_id": str(owner.id), "email": "x@example.com"}], }, headers=_auth_headers(owner), ) assert response.status_code == 422 async def test_patch_replaces_participant_set_and_returns_it( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) first_invitee = await _make_user(db_session) second_invitee = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={ "title": "Roster", "scheduled_at": _iso(FUTURE), "participants": [{"user_id": str(first_invitee.id)}], }, headers=_auth_headers(owner), ) conference_id = created.json()["id"] response = await client.patch( f"/api/v1/conferences/{conference_id}", json={"participants": [{"user_id": str(second_invitee.id)}]}, headers=_auth_headers(owner), ) assert response.status_code == 200, response.text user_ids = {p["user_id"] for p in response.json()["participants"] if p["user_id"] is not None} assert user_ids == {str(owner.id), str(second_invitee.id)} async def test_my_conferences_list_does_not_include_participants( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: owner = await _make_user(db_session) invitee = await _make_user(db_session) await db_session.commit() created = await client.post( "/api/v1/conferences", json={ "title": "Pinned with roster", "is_pinned": True, "participants": [{"user_id": str(invitee.id)}], }, headers=_auth_headers(owner), ) conference_id = created.json()["id"] my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner)) item = next(c for c in my_response.json() if c["id"] == conference_id) assert item["participants"] == [] assert item["is_owner"] is True assert item["organizer_name"] == owner.name_user