"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар (загрузка/отдача/удаление), публичный профиль.""" from __future__ import annotations from fastapi.testclient import TestClient from sqlmodel import Session from app.models import User from tests.conftest import ( add_group_member, create_finished_match, csrf_headers, login, start_match, ) # Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 def _use_tmp_uploads(monkeypatch, tmp_path) -> None: from app.core.config import settings monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) def _finished_match_for(client: TestClient, engine, me: dict) -> int: """Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд).""" exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) ).json()["id"] p2 = add_group_member(engine, gid, "Соперник") fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[0], "place": 1}, {"user_id": p2, "faction_id": fids[1], "place": 2}, ], ) return gid def test_update_bio(client: TestClient): login(client, "Игрок") r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client)) assert r.status_code == 200, r.text assert r.json()["bio"] == "Люблю орков" # Слишком длинное «о себе» → 422. r = client.patch( "/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client) ) assert r.status_code == 422, r.text def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path): _use_tmp_uploads(monkeypatch, tmp_path) me = login(client, "Аватарыч") # Загрузка валидного PNG → avatar_url выставлен. r = client.put( "/api/users/me/avatar", files={"file": ("a.png", PNG, "image/png")}, headers=csrf_headers(client), ) assert r.status_code == 200, r.text avatar_url = r.json()["avatar_url"] assert avatar_url and "/avatar?v=" in avatar_url # Отдача файла. g = client.get(f"/api/users/{me['id']}/avatar") assert g.status_code == 200, g.text assert g.content == PNG assert g.headers["content-type"] == "image/png" # Удаление → avatar_url пропадает, файл больше не отдаётся. r = client.delete("/api/users/me/avatar", headers=csrf_headers(client)) assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404 def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path): _use_tmp_uploads(monkeypatch, tmp_path) login(client, "Игрок") r = client.put( "/api/users/me/avatar", files={"file": ("x.txt", b"not an image", "text/plain")}, headers=csrf_headers(client), ) assert r.status_code == 422, r.text big = PNG + b"\x00" * (2 * 1024 * 1024 + 10) r = client.put( "/api/users/me/avatar", files={"file": ("big.png", big, "image/png")}, headers=csrf_headers(client), ) assert r.status_code == 422, r.text def test_public_profile(client: TestClient): me = login(client, "Публичный") client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client)) r = client.get(f"/api/users/{me['id']}/profile") assert r.status_code == 200, r.text body = r.json() assert body["nickname"] == "Публичный" assert body["bio"] == "Привет" assert body["avatar_url"] is None assert "overall" in body["stats"] assert client.get("/api/users/999999/profile").status_code == 404 def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch, tmp_path): _use_tmp_uploads(monkeypatch, tmp_path) me = login(client, "Топчик") _finished_match_for(client, engine, me) def entry_for(uid: int) -> dict: board = client.get("/api/stats/leaderboard").json() return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid) assert entry_for(me["id"])["avatar_url"] is None # без аватара — null client.put( "/api/users/me/avatar", files={"file": ("a.png", PNG, "image/png")}, headers=csrf_headers(client), ) assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка def test_main_faction_counts_random_games(client: TestClient, engine): """«Чаще всего играет на» — по всей истории, включая рандомные раздачи.""" me = login(client, "Фракционер") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) ).json()["id"] p2 = add_group_member(engine, gid, "Партнёр") fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] # Дважды играл фракцией A по рандому, один раз фракцией B — по своему выбору. for _ in range(2): create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True}, {"user_id": p2, "faction_id": fids[1], "place": 2}, ], ) create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[2], "place": 1}, {"user_id": p2, "faction_id": fids[1], "place": 2}, ], ) stats = client.get("/api/users/me/stats").json() # Условия выбора не важны: A сыграна чаще, пусть и по рандому. assert stats["main_faction"]["faction_id"] == fids[0] assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена # Любимая больше не выводится из истории — она задаётся игроком вручную. assert stats["favorite_faction"] is None def test_main_faction_present_for_random_only_player(client: TestClient, engine): """Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается.""" me = login(client, "Рандомщик") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) ).json()["id"] p2 = add_group_member(engine, gid, "Оппонент") fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True}, {"user_id": p2, "faction_id": fids[1], "place": 2, "was_random": True}, ], ) stats = client.get("/api/users/me/stats").json() assert stats["main_faction"]["faction_id"] == fids[0] def test_favorite_faction_set_and_clear(client: TestClient): """Любимая фракция — личный выбор игрока: сохраняется и снимается.""" login(client, "Выбирающий") fid = client.get("/api/factions").json()[0]["id"] r = client.patch( "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) ) assert r.status_code == 200, r.text assert r.json()["favorite_faction_id"] == fid assert client.get("/api/users/me").json()["favorite_faction_id"] == fid # Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики. assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid r = client.patch( "/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client) ) assert r.status_code == 200 assert r.json()["favorite_faction_id"] is None assert client.get("/api/users/me/stats").json()["favorite_faction"] is None def test_favorite_faction_rejects_unknown(client: TestClient): login(client, "Фантазёр") r = client.patch( "/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client) ) assert r.status_code == 422 def test_bio_update_keeps_favorite_faction(client: TestClient): """Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию.""" login(client, "Аккуратный") fid = client.get("/api/factions").json()[0]["id"] client.patch( "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) ) r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client)) assert r.status_code == 200 assert r.json()["bio"] == "Только био" assert r.json()["favorite_faction_id"] == fid def test_prepositional_dictionary_and_fallback(): """Падежная форма берётся из словаря; незнакомый код — именительный как есть.""" from app.services import faction_service assert faction_service.prepositional("orks", "Орки") == "Орках" assert ( faction_service.prepositional("astra_militarum", "Имперская гвардия") == "Имперской гвардии" ) # Фракция, заведённая админом мимо словаря, не роняет вывод. assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы" # ─── История партий в профиле (#1) ─────────────────────────────────────────── def _history(client: TestClient, user_id: int) -> dict: r = client.get(f"/api/users/{user_id}/matches") assert r.status_code == 200, r.text return r.json() def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]: """Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id).""" exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) ).json()["id"] mates = [add_group_member(engine, gid, nick) for nick in nicknames] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] return gid, mates, fids def test_history_lists_only_own_finished_matches(client: TestClient, engine): """В историю идут только завершённые партии этого игрока.""" me = login(client, "Историк") gid, (mate,), fids = _group_with(client, engine, "Сосед") create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[0], "place": 1}, {"user_id": mate, "faction_id": fids[1], "place": 2}, ], ) # Незавершённая партия мест не имеет и в историю попадать не должна. assert start_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[2]}, {"user_id": mate, "faction_id": fids[3]}, ], ).status_code == 200 data = _history(client, me["id"]) assert data["total"] == 1 assert [m["status"] for m in data["items"]] == ["finished"] # Значения по умолчанию едут вместе со списком — гостю хватает одного запроса. assert data["mode"] == "all" assert data["detail"] == "compact" def test_history_excludes_matches_without_the_player(client: TestClient, engine): """Чужая партия в историю игрока не попадает, даже внутри его группы.""" me = login(client, "Наблюдатель") gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3") # Партию заводит сосед, сам игрок в ней не участвует. login(client, "Игрок2") create_finished_match( client, gid, [ {"user_id": mate, "faction_id": fids[0], "place": 1}, {"user_id": third, "faction_id": fids[1], "place": 2}, ], ) assert _history(client, me["id"])["total"] == 0 assert _history(client, mate)["total"] == 1 def test_history_best_mode_picks_highest_points(client: TestClient, engine): """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую. Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место в дуэли — отнимает.""" me = login(client, "Лучший") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") create_finished_match( client, gid, [ {"user_id": a, "faction_id": fids[0], "place": 1}, {"user_id": me["id"], "faction_id": fids[1], "place": 2}, {"user_id": b, "faction_id": fids[2], "place": 3}, {"user_id": c, "faction_id": fids[3], "place": 4}, ], ) # Свежее, но по очкам хуже — последнее место на двоих. create_finished_match( client, gid, [ {"user_id": a, "faction_id": fids[0], "place": 1}, {"user_id": me["id"], "faction_id": fids[1], "place": 2}, ], ) client.patch( "/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client) ) data = _history(client, me["id"]) assert data["mode"] == "best" assert data["total"] == 1 assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками def test_history_prefs_saved_and_validated(client: TestClient): """Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое.""" login(client, "Настройщик") r = client.patch( "/api/users/me/profile", json={"history_mode": "best", "history_detail": "full"}, headers=csrf_headers(client), ) assert r.status_code == 200, r.text assert r.json()["history_mode"] == "best" assert r.json()["history_detail"] == "full" assert client.get("/api/users/me").json()["history_detail"] == "full" bad = client.patch( "/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client) ) assert bad.status_code == 422 assert client.get("/api/users/me").json()["history_mode"] == "best" def test_history_uses_owner_mode_for_guests(client: TestClient, engine): """Гость видит историю в том режиме, который выбрал владелец профиля.""" me = login(client, "Витрина") gid, (mate,), fids = _group_with(client, engine, "Партнёр") for _ in range(2): create_finished_match( client, gid, [ {"user_id": me["id"], "faction_id": fids[0], "place": 1}, {"user_id": mate, "faction_id": fids[1], "place": 2}, ], ) client.patch( "/api/users/me/profile", json={"history_mode": "best", "history_detail": "full"}, headers=csrf_headers(client), ) login(client, "Прохожий") data = _history(client, me["id"]) assert data["mode"] == "best" assert data["detail"] == "full" assert data["total"] == 1 def test_avatar_version_is_stable_across_surfaces(client: TestClient, engine, monkeypatch, tmp_path): """Кэш-бастер аватара одинаков в профиле и в лидерборде, и меняется при перезаливке. Регрессия: версию профиля считал Python из наивного времени как из локального, а лидерборд — SQL как из UTC, и браузер тянул одну картинку дважды. Плюс при том же расширении файла updated_at не двигался и ссылка оставалась прежней.""" _use_tmp_uploads(monkeypatch, tmp_path) me = login(client, "Версия") _finished_match_for(client, engine, me) def version_in(url: str) -> str: return url.split("?v=")[1] def leaderboard_url() -> str: board = client.get("/api/stats/leaderboard").json() entry = next( e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"] ) return entry["avatar_url"] first = client.put( "/api/users/me/avatar", files={"file": ("a.png", PNG, "image/png")}, headers=csrf_headers(client), ) assert first.status_code == 200, first.text v_profile = version_in(first.json()["avatar_url"]) assert version_in(leaderboard_url()) == v_profile # Повторная загрузка с тем же расширением: avatar_path не меняется, поэтому UPDATE # строки сам собой не эмитится — updated_at должен двигаться явно, иначе кэш-бастер # замирает и браузер час показывает прежнюю картинку. Версия в ссылке считается с # точностью до секунды, поэтому сдвиг проверяем по времени в БД. with Session(engine) as s: before = s.get(User, me["id"]).updated_at second = client.put( "/api/users/me/avatar", files={"file": ("a.png", PNG + b"\x00", "image/png")}, headers=csrf_headers(client), ) assert second.status_code == 200, second.text with Session(engine) as s: assert s.get(User, me["id"]).updated_at > before assert version_in(leaderboard_url()) == version_in(second.json()["avatar_url"])