Ревью: закрыть дыры в собственном покрытии

Прогон /code-review по тестам показал, что проверка обхода каталога ачивок
ничего не проверяла: httpx нормализует «..» в URL до отправки, запрос уходил
на /api/admin/ и до обработчика не доходил — тест был бы зелёным и без
защиты. Теперь percent-кодированная форма плюс проверка конверта ошибки,
чтобы промах роутинга не выдавал себя за отказ.

Добавлено недостающее: передача владения группой (обратная сторона защиты
последнего владельца), сдвиг версии партии при загрузке вложения, совпадение
кэш-бастера аватара между профилем и лидербордом. Проверка выживания группы
после отказа в удалении теперь смотрит на саму группу и её партии, а не
только на код ответа.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
This commit is contained in:
2026-09-09 18:32:47 +03:00
co-authored by Claude Opus 5
parent c4b72cca39
commit 1831a3e033
4 changed files with 101 additions and 2 deletions
+48
View File
@@ -3,7 +3,9 @@
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,
@@ -399,3 +401,49 @@ def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
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"])