342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""Сквозной тест ядра: вход → группа → участники → партия → статистика → админ."""
|
||
from __future__ import annotations
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from tests.conftest import add_group_member, 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, engine):
|
||
# 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 в группе (прямое добавление — подготовка данных; UX-путь теперь приглашения).
|
||
b_id = add_group_member(engine, gid, "Олег")
|
||
|
||
# 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, engine):
|
||
me = login(client, "Хост")
|
||
gid = client.post(
|
||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||
).json()["id"]
|
||
b = add_group_member(engine, gid, "Гость2")
|
||
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, engine):
|
||
"""Владелец группы может завершить партию, начатую другим участником (can_modify с бэкенда)."""
|
||
a = login(client, "Хозяин")
|
||
gid = client.post(
|
||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||
).json()["id"]
|
||
b = add_group_member(engine, gid, "Согрупник")
|
||
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, engine):
|
||
"""Рядовой участник (не создатель, не владелец) может завершать партии и менять дополнения."""
|
||
a = login(client, "Овнер")
|
||
gid = client.post(
|
||
"/api/groups", json={"name": "Компания", "expansion_ids": []}, headers=csrf_headers(client)
|
||
).json()["id"]
|
||
b = add_group_member(engine, gid, "Бэ")
|
||
c = add_group_member(engine, gid, "Цэ")
|
||
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, engine):
|
||
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
|
||
me = login(client, "A")
|
||
r = client.post("/api/groups", json={"name": "Группа", "expansion_ids": []},
|
||
headers=csrf_headers(client))
|
||
gid = r.json()["id"]
|
||
b = add_group_member(engine, gid, "B")
|
||
c = add_group_member(engine, gid, "C")
|
||
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
|