Files
ForbiddenStarsApp/backend/tests/test_profile.py
NotBigGhostandClaude Opus 5 6ea4a67672 Рейтинг: витрины статистики на проигрывании истории
SQL-агрегат League Points (SCORED_CTE, сглаженное среднее) заменён
проигрыванием завершённых партий по порядку played_at, finished_at, id:
load_history — один запрос на партии с участниками, scoring.replay считает
рейтинг. Цепочки две: общая (все партии) и групповая (только партии группы,
K — по партиям внутри группы). Правка или удаление прошлой партии
пересчитывает всё после неё без отдельной логики.

Топ, профиль, главная и статистика группы берут игры, победы, винрейт
и среднее место из той же истории; score — рейтинг целым числом, сортировка
по неокруглённому. Главная грузит историю один раз. Лучшая партия — наибольший
ΔR в общей цепочке, лучшая/худшая фракция — средний S − E. League Points
из scoring.py удалён.

Тесты: дуэль новичков ±32, ранжирование после 10 партий, пересчёт после
правки прошлой партии, отдельная групповая цепочка, лучшая партия по ΔR,
одна загрузка истории на главной. #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-14 22:38:57 +03:00

451 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Профиль: «о себе» (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"])