Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026. 1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx, поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей. Проверено на проде: в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту» действовал на весь инстанс разом, а не на клиента. 2. Считались все запросы подряд, включая успешные. Одиннадцатый человек, открывший ссылку на конференцию в течение минуты, получал 429 — и видел «Не удалось найти конференцию» для существующей и активной конференции. Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно. Что изменилось: - адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному клиентом, и лимит обходился бы одним заголовком; - жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ: конференция не найдена или пароль неверен. Именно так выглядит перебор номера, от которого лимит и защищает по ADR-001, п.4; - на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше правдоподобного числа участников одной конференции. Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30 подряд); перебор номера, несуществующий идентификатор и подбор пароля по-прежнему упираются; лимит одного клиента не задевает другого.
1341 lines
48 KiB
Python
1341 lines
48 KiB
Python
"""Интеграционные тесты `/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
|