"""Сквозной тест ядра: вход → группа → участники → партия → статистика → админ.""" 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