v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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 # (для единообразия импорта)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user