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
451 lines
19 KiB
Python
451 lines
19 KiB
Python
"""Профиль: «о себе» (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"])
|