v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
View File
+126
View File
@@ -0,0 +1,126 @@
"""Фикстуры тестов: изолированная in-memory БД + TestClient + помощники."""
from __future__ import annotations
import os
# Тесты НИКОГДА не работают с БД дева/прода. Форсируем тестовое окружение и
# in-memory БД для ГЛОБАЛЬНОГО движка ещё ДО импорта приложения — даже случайное
# обращение к app.db.session.engine не затронет файлы дева/прода, какой бы
# APP_ENV ни был унаследован из окружения.
os.environ["APP_ENV"] = "development"
os.environ["DEV_DATABASE_URL"] = "sqlite://"
os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимают схему/данные
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
from sqlmodel import Session, SQLModel, create_engine # noqa: E402
import app.models # noqa: F401,E402 (регистрация моделей)
from app.core.security import hash_password # noqa: E402
from app.db.session import get_session # noqa: E402
from app.main import app # noqa: E402
from app.models import User # noqa: E402
from app.seed.reference_data import seed_reference_data # noqa: E402
@pytest.fixture()
def engine():
eng = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(eng)
with Session(eng) as s:
seed_reference_data(s)
yield eng
# In-memory БД исчезает с закрытием соединения StaticPool; drop_all не нужен
# (и невозможен из-за циклической FK users↔groups в SQLite).
eng.dispose()
@pytest.fixture()
def client(engine):
def _get_session():
with Session(engine) as s:
yield s
app.dependency_overrides[get_session] = _get_session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture()
def make_admin(engine):
def _make(username: str = "admin", password: str = "secret123") -> int:
with Session(engine) as s:
admin = User(
nickname=username,
role="admin",
auth_provider="local",
password_hash=hash_password(password),
)
s.add(admin)
s.commit()
s.refresh(admin)
return admin.id
return _make
# ─── Помощники ────────────────────────────────────────────────────────────────
def csrf_headers(client: TestClient) -> dict:
token = client.cookies.get("csrf_token")
return {"X-CSRF-Token": token} if token else {}
def login(client: TestClient, nickname: str) -> dict:
# csrf_headers пуст при первом входе и содержит токен при смене пользователя
# (когда уже есть сессионная cookie и middleware требует заголовок).
r = client.post("/api/auth/dev/login", json={"nickname": nickname}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
return r.json()
def start_match(client: TestClient, group_id: int, roster: list[dict]):
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
return client.post(
"/api/matches",
json={"group_id": group_id, "participants": roster},
headers=csrf_headers(client),
)
def finish_match(
client: TestClient,
match_id: int,
results: list[dict],
win_reason: str = "objectives",
overall_comment: str | None = None,
):
"""results: [{user_id, place, comment?, faction_id?}]."""
body: dict = {"participants": results, "win_reason": win_reason}
if overall_comment is not None:
body["overall_comment"] = overall_comment
return client.post(
f"/api/matches/{match_id}/finish", json=body, headers=csrf_headers(client)
)
def create_finished_match(
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
) -> dict:
"""players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
started = start_match(client, group_id, roster)
assert started.status_code == 200, started.text
results = [
{"user_id": p["user_id"], "place": p["place"], "comment": p.get("comment")}
for p in players
]
r = finish_match(client, started.json()["id"], results, win_reason)
assert r.status_code == 200, r.text
return r.json()
+139
View File
@@ -0,0 +1,139 @@
"""Тесты: лимиты (группа/партия), правка партии админом, переименование фракции, фильтры."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import create_finished_match, csrf_headers, login, start_match
def _exp_id(client: TestClient, code: str) -> int:
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
def _admin_login(client: TestClient, make_admin) -> None:
make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def test_group_size_limit(client: TestClient):
login(client, "Owner")
gid = client.post(
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
for i in range(1, 11):
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
statuses = []
for i in range(1, 11):
r = client.post(
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
)
statuses.append(r.status_code)
# 9 успешных (итого 10 с владельцем), 10-й — отказ.
assert statuses[:9] == [200] * 9
assert statuses[9] == 409
def test_match_player_limit(client: TestClient):
me = login(client, "Owner")
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]}, headers=csrf_headers(client)
).json()["id"]
ids = [me["id"]]
for i in range(1, 7):
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client))
ids.append(
client.post(
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
).json()["user_id"]
)
factions = client.get(f"/api/groups/{gid}/factions").json()
fids = [f["id"] for f in factions]
# 7 игроков на старте → 422 (максимум 6).
roster = [{"user_id": uid, "faction_id": fids[i]} for i, uid in enumerate(ids)]
r = start_match(client, gid, roster)
assert r.status_code == 422
assert "6" in r.json()["error"]["message"]
def test_admin_edit_match_and_filters(client: TestClient, make_admin):
me = login(client, "Аня")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
],
)["id"]
_admin_login(client, make_admin)
# Фильтры: по игроку и по фракции находят партию.
assert any(m["id"] == mid for m in client.get(f"/api/admin/matches?user_id={b}").json())
assert any(
m["id"] == mid for m in client.get(f"/api/admin/matches?faction_id={factions['orks']}").json()
)
# Правка: меняем фракцию Бори eldar → chaos.
r = client.patch(
f"/api/admin/matches/{mid}",
json={
"participants": [
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
{"user_id": b, "faction_id": factions["chaos"], "place": 2},
]
},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
detail = client.get(f"/api/admin/matches/{mid}").json()
bp = next(p for p in detail["participants"] if p["user_id"] == b)
assert bp["faction_name"] == "Хаоситы"
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
me = login(client, "Кто-то")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client))
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
).json()["user_id"]
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
mid = create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
{"user_id": b, "faction_id": factions["eldar"], "place": 2},
],
)["id"]
_admin_login(client, make_admin)
r = client.patch(
f"/api/admin/factions/{factions['orks']}",
json={"name_ru": "Орки WAAAGH"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
# Изменение отражается в существующей партии (имя хранится один раз).
detail = client.get(f"/api/admin/matches/{mid}").json()
ap = next(p for p in detail["participants"] if p["user_id"] == me["id"])
assert ap["faction_name"] == "Орки WAAAGH"
+81
View File
@@ -0,0 +1,81 @@
"""Аутентификация: методы по окружению, dev-вход, Telegram (проверка подписи)."""
from __future__ import annotations
import hashlib
import hmac
import time
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers
def test_auth_config_dev_has_both_methods(client: TestClient):
cfg = client.get("/api/auth/config").json()
assert "telegram" in cfg["methods"]
assert "stub" in cfg["methods"] # dev → доступен вход по нику
def test_enabled_methods_by_env(monkeypatch):
from app.auth.registry import enabled_methods
from app.core.config import settings
monkeypatch.setattr(settings, "app_env", "development")
assert set(enabled_methods()) == {"telegram", "stub"}
monkeypatch.setattr(settings, "app_env", "test")
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
monkeypatch.setattr(settings, "app_env", "production")
assert enabled_methods() == ["telegram"] # prod → только Telegram
def test_env_flags_and_db_path(monkeypatch):
"""dev → файл дева; test и prod → том /data (общая ветвь is_development)."""
from app.core.config import settings
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db")
monkeypatch.setattr(settings, "prod_database_url", "sqlite:////data/prod.db")
monkeypatch.setattr(settings, "app_env", "development")
assert settings.is_development and settings.database_url == "sqlite:///dev.db"
monkeypatch.setattr(settings, "app_env", "test")
assert settings.is_test and settings.database_url == "sqlite:////data/prod.db"
monkeypatch.setattr(settings, "app_env", "production")
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
def test_dev_login_works(client: TestClient):
r = client.post("/api/auth/dev/login", json={"nickname": "Тестер"})
assert r.status_code == 200, r.text
assert r.json()["nickname"] == "Тестер"
def _telegram_payload(token: str, **fields) -> dict:
data = {"id": 777, "first_name": "Иван", "username": "ivan_tg", "auth_date": int(time.time())}
data.update(fields)
secret = hashlib.sha256(token.encode()).digest()
check = "\n".join(f"{k}={data[k]}" for k in sorted(data))
sig = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
return {**data, "hash": sig}
def test_telegram_login_valid_signature(client: TestClient, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
payload = _telegram_payload("TEST_BOT_TOKEN")
r = client.post("/api/auth/telegram", json=payload)
assert r.status_code == 200, r.text
me = r.json()
assert me["nickname"] # пользователь создан
assert me["telegram_id"] == 777
def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
payload = _telegram_payload("TEST_BOT_TOKEN")
payload["hash"] = "deadbeef" # подделка
r = client.post("/api/auth/telegram", json=payload)
assert r.status_code == 401
_ = csrf_headers # (для единообразия импорта)
+363
View File
@@ -0,0 +1,363 @@
"""Сквозной тест ядра: вход → группа → участники → партия → статистика → админ."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers, finish_match, login, start_match
def _expansion_id(client: TestClient, code: str) -> int:
r = client.get("/api/expansions")
assert r.status_code == 200, r.text
return next(e["id"] for e in r.json() if e["code"] == code)
def test_full_flow(client: TestClient, make_admin):
# 1) Вход игрока A — групп ещё нет.
me = login(client, "Иван")
assert me["groups"] == []
assert me["active_group_id"] is None
# 2) Без группы создать партию нельзя (NO_GROUP).
r = client.post(
"/api/matches",
json={"group_id": 1, "played_at": "2026-06-15", "participants": []},
headers=csrf_headers(client),
)
assert r.status_code == 409
assert r.json()["error"]["code"] == "NO_GROUP"
# 3) Создать группу с дополнением Forgotten Worlds.
fw = _expansion_id(client, "forgotten_worlds")
r = client.post(
"/api/groups",
json={"name": "Вечер 40k", "expansion_ids": [fw]},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
group = r.json()
gid = group["id"]
assert group["my_role"] == "owner"
assert fw in group["expansion_ids"]
# Активная группа выставилась.
me = client.get("/api/users/me").json()
assert me["active_group_id"] == gid
# 4) Создать игрока B и добавить в группу по нику.
rb = client.post(
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
)
assert rb.status_code == 200, rb.text
r = client.post(
f"/api/groups/{gid}/members",
json={"nickname": "Олег"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
b_id = r.json()["user_id"]
# 5) Доступные фракции = база (4) + Forgotten Worlds (4) = 8.
factions = client.get(f"/api/groups/{gid}/factions").json()
assert len(factions) == 8
codes = {f["code"] for f in factions}
assert "orks" in codes and "tau" in codes
assert "inquisition" not in codes # Forsaken Voids не подключён
# 6) Рандом фракции — из доступного набора.
r = client.post(
"/api/matches/randomize-faction",
json={"group_id": gid, "exclude_faction_ids": []},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["faction"]["id"] in {f["id"] for f in factions}
# 7) Двухэтапная партия: старт (A=Орки рандом, B=Тау), затем завершение (места).
orks = next(f["id"] for f in factions if f["code"] == "orks")
tau = next(f["id"] for f in factions if f["code"] == "tau")
a_id = me["id"]
started = start_match(
client,
gid,
[
{"user_id": a_id, "faction_id": orks, "was_random": True},
{"user_id": b_id, "faction_id": tau},
],
)
assert started.status_code == 200, started.text
sm = started.json()
assert sm["status"] == "in_progress"
assert all(p["place"] is None for p in sm["participants"])
# Незавершённая партия в статистику пока не идёт.
assert client.get("/api/home").json()["profile"]["overall"]["games"] == 0
fin = finish_match(
client,
sm["id"],
[{"user_id": a_id, "place": 1}, {"user_id": b_id, "place": 2}],
win_reason="objectives",
overall_comment="Хорошая партия",
)
assert fin.status_code == 200, fin.text
match = fin.json()
assert match["status"] == "finished"
assert match["win_reason"] == "objectives"
assert match["duration_minutes"] is not None and match["duration_minutes"] >= 30
assert match["player_count"] == 2
# 8) Список партий группы и статистика.
lst = client.get(f"/api/groups/{gid}/matches").json()
assert lst["total"] == 1
gstats = client.get(f"/api/groups/{gid}/stats").json()
assert gstats["total_matches"] == 1
# Игр меньше MIN_GAMES → попадают в provisional.
prov_ids = {e["user_id"] for e in gstats["provisional"]}
assert a_id in prov_ids and b_id in prov_ids
# 9) Главная: профиль и топ.
home = client.get("/api/home").json()
assert home["profile"]["overall"]["games"] == 1
assert home["profile"]["overall"]["wins"] == 1
assert home["active_group"]["id"] == gid
# 10) Дубликат фракции запрещён уже на старте.
r = start_match(
client,
gid,
[
{"user_id": a_id, "faction_id": orks},
{"user_id": b_id, "faction_id": orks},
],
)
assert r.status_code == 422
assert r.json()["error"]["code"] == "DUPLICATE_PARTICIPANT"
# 11) Админ: вход и доступ; пользовательская сессия в админку не проходит.
make_admin("admin", "secret123")
# Пользователь не может в админку.
assert client.get("/api/admin/users").status_code in (401, 403)
# Вход админа.
ra = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert ra.status_code == 200, ra.text
users = client.get("/api/admin/users").json()
nicks = {u["nickname"] for u in users}
assert {"Иван", "Олег"}.issubset(nicks)
def test_in_progress_excluded_and_win_reason_required(client: TestClient):
me = login(client, "Хост")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client))
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"]
started = start_match(client, gid, [
{"user_id": me["id"], "faction_id": f1},
{"user_id": b, "faction_id": f2},
])
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Незавершённая видна в списке группы (можно дозавершить), но не в топе.
lst = client.get(f"/api/groups/{gid}/matches").json()
assert lst["items"][0]["status"] == "in_progress"
assert client.get("/api/stats/leaderboard").json()["entries"] == []
assert client.get("/api/stats/leaderboard").json()["provisional"] == []
# Незавершённая видна на главной (с именем группы) для быстрого перехода.
home = client.get("/api/home").json()
assert any(x["id"] == mid for x in home["in_progress"])
assert home["in_progress"][0]["group_name"] == "Группа"
# Завершение без причины победы → 422 (поле обязательно).
no_reason = client.post(
f"/api/matches/{mid}/finish",
json={"participants": [
{"user_id": me["id"], "place": 1},
{"user_id": b, "place": 2},
]},
headers=csrf_headers(client),
)
assert no_reason.status_code == 422
# С причиной победы → 200 и теперь учитывается в статистике.
ok = finish_match(client, mid, [
{"user_id": me["id"], "place": 1},
{"user_id": b, "place": 2},
], win_reason="worlds")
assert ok.status_code == 200, ok.text
assert client.get("/api/users/me/stats").json()["overall"]["games"] == 1
# После завершения партия пропадает из «незавершённых» на главной.
assert all(x["id"] != mid for x in client.get("/api/home").json()["in_progress"])
def test_owner_can_finish_member_started_match(client: TestClient):
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
a = login(client, "Хозяин")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client))
b = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"]
# Участник (не владелец) входит и стартует партию.
login(client, "Согрупник")
started = start_match(client, gid, [
{"user_id": a["id"], "faction_id": f1},
{"user_id": b, "faction_id": f2},
])
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Создатель видит can_modify=True.
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
# Владелец возвращается: тоже может завершить (форма должна показываться).
login(client, "Хозяин")
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
fin = finish_match(client, mid, [
{"user_id": a["id"], "place": 1},
{"user_id": b, "place": 2},
], win_reason="objectives")
assert fin.status_code == 200, fin.text
def test_any_member_can_finish_and_manage(client: TestClient):
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
a = login(client, "Овнер")
gid = client.post(
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
for nick in ("Бэ", "Цэ"):
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
# Владелец стартует партию A+B+C.
started = start_match(client, gid, [
{"user_id": a["id"], "faction_id": fids[0]},
{"user_id": b, "faction_id": fids[1]},
{"user_id": c, "faction_id": fids[2]},
])
mid = started.json()["id"]
# Рядовой участник C завершает партию.
login(client, "Цэ")
assert client.get(f"/api/matches/{mid}").json()["can_modify"] is True
fin = finish_match(client, mid, [
{"user_id": a["id"], "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "place": 3},
], win_reason="plastic")
assert fin.status_code == 200, fin.text
# И может менять дополнения группы.
fw = _expansion_id(client, "forgotten_worlds")
r = client.put(
f"/api/groups/{gid}/expansions", json={"expansion_ids": [fw]}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
def test_disabled_account_cannot_login(client: TestClient, make_admin):
# Создаём игрока и узнаём его id.
client.post("/api/auth/dev/users", json={"nickname": "Гость"})
# Вход админа.
make_admin("admin", "secret123")
ra = client.post(
"/api/admin/auth/login", json={"username": "admin", "password": "secret123"}
)
assert ra.status_code == 200, ra.text
users = client.get("/api/admin/users").json()
guest = next(u for u in users if u["nickname"] == "Гость")
# Админ отключает аккаунт.
rp = client.patch(
f"/api/admin/users/{guest['id']}",
json={"is_active": False},
headers=csrf_headers(client),
)
assert rp.status_code == 200, rp.text
# Вход под отключённым — запрещён с понятным кодом.
rl = client.post("/api/auth/dev/login", json={"nickname": "Гость"})
assert rl.status_code == 403
assert rl.json()["error"]["code"] == "ACCOUNT_DISABLED"
# В dev-списке он помечен неактивным (для подсветки серым).
dev_users = client.get("/api/auth/dev/users").json()
guest_dev = next(u for u in dev_users if u["nickname"] == "Гость")
assert guest_dev["is_active"] is False
def test_tie_ranking_and_points(client: TestClient):
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
me = login(client, "A")
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
headers=csrf_headers(client))
gid = r.json()["id"]
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client))
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
headers=csrf_headers(client)).json()["user_id"]
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
headers=csrf_headers(client)).json()["user_id"]
factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2, f3 = (factions[0]["id"], factions[1]["id"], factions[2]["id"])
a = me["id"]
# Старт партии (мест ещё нет) — ранжирование проверяется на завершении.
started = start_match(
client,
gid,
[
{"user_id": a, "faction_id": f1},
{"user_id": b, "faction_id": f2},
{"user_id": c, "faction_id": f3},
],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Некорректная расстановка 1,1,2 → отклоняется на финише.
bad = finish_match(
client,
mid,
[
{"user_id": a, "place": 1},
{"user_id": b, "place": 1},
{"user_id": c, "place": 2},
],
)
assert bad.status_code == 422
assert bad.json()["error"]["code"] == "INVALID_RANKING"
# Корректная ничья за 2-е место: 1,2,2 → партия завершается.
ok = finish_match(
client,
mid,
[
{"user_id": a, "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "place": 2},
],
)
assert ok.status_code == 200, ok.text
# Повторное завершение запрещено.
again = finish_match(client, mid, [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}, {"user_id": c, "place": 2}])
assert again.status_code == 409