Группы: приглашения вместо авто-добавления (согласиться/отказаться)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,13 @@ os.environ["FS_STARTUP_BOOTSTRAP"] = "0" # тесты сами поднимаю
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine # noqa: E402
|
||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||
from app.core.security import hash_password # noqa: E402
|
||||
from app.db.session import get_session # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.models import User # noqa: E402
|
||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||
from app.seed.reference_data import seed_reference_data # noqa: E402
|
||||
|
||||
|
||||
@@ -86,6 +86,32 @@ def login(client: TestClient, nickname: str) -> dict:
|
||||
return r.json()
|
||||
|
||||
|
||||
def add_group_member(engine, group_id: int, nickname: str, role: str = "member") -> int:
|
||||
"""Подготовка данных: добавить игрока в группу напрямую (в обход приглашений).
|
||||
|
||||
Создаёт пользователя при отсутствии. Возвращает user_id. Нужен, т.к. через HTTP
|
||||
прямого добавления больше нет — только приглашения (см. invitation_service)."""
|
||||
with Session(engine) as s:
|
||||
user = s.exec(select(User).where(User.nickname == nickname)).first()
|
||||
if user is None:
|
||||
user = User(nickname=nickname, role="player", auth_provider="stub")
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
# AuthIdentity нужен, чтобы dev-вход (login) нашёл того же игрока, а не создал дубль.
|
||||
s.add(AuthIdentity(user_id=user.id, provider="stub", external_id=nickname))
|
||||
s.commit()
|
||||
exists = s.exec(
|
||||
select(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id == user.id
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
s.add(GroupMember(group_id=group_id, user_id=user.id, role=role))
|
||||
s.commit()
|
||||
return user.id # type: ignore[return-value]
|
||||
|
||||
|
||||
def start_match(client: TestClient, group_id: int, roster: list[dict]):
|
||||
"""roster: [{user_id, faction_id, was_random?}] → ответ старта (in_progress)."""
|
||||
return client.post(
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login, start_match
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||
|
||||
|
||||
def _exp_id(client: TestClient, code: str) -> int:
|
||||
@@ -20,26 +20,25 @@ def _admin_login(client: TestClient, make_admin) -> None:
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_group_size_limit(client: TestClient):
|
||||
def test_group_invite_capacity_limit(client: TestClient):
|
||||
login(client, "Owner")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Большая", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
# owner = 1; добавляем до 10, 10-й участник сверх лимита → 409.
|
||||
for i in range(1, 11):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"U{i}"}, headers=csrf_headers(client))
|
||||
statuses = []
|
||||
for i in range(1, 11):
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": f"U{i}"}, headers=csrf_headers(client)
|
||||
)
|
||||
statuses.append(r.status_code)
|
||||
# 9 успешных (итого 10 с владельцем), 10-й — отказ.
|
||||
# owner(1) + 9 приглашений = 10 (лимит мест); 10-е приглашение сверх лимита → 409.
|
||||
assert statuses[:9] == [200] * 9
|
||||
assert statuses[9] == 409
|
||||
|
||||
|
||||
def test_match_player_limit(client: TestClient):
|
||||
def test_match_player_limit(client: TestClient, engine):
|
||||
me = login(client, "Owner")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
@@ -47,12 +46,7 @@ def test_match_player_limit(client: TestClient):
|
||||
).json()["id"]
|
||||
ids = [me["id"]]
|
||||
for i in range(1, 7):
|
||||
client.post("/api/auth/dev/users", json={"nickname": f"P{i}"}, headers=csrf_headers(client))
|
||||
ids.append(
|
||||
client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": f"P{i}"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
)
|
||||
ids.append(add_group_member(engine, gid, f"P{i}"))
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
fids = [f["id"] for f in factions]
|
||||
# 7 игроков на старте → 422 (максимум 6).
|
||||
@@ -62,15 +56,12 @@ def test_match_player_limit(client: TestClient):
|
||||
assert "6" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
||||
def test_admin_edit_match_and_filters(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Аня")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
@@ -129,17 +120,14 @@ def test_admin_rename_account(client: TestClient, make_admin):
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
|
||||
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin, engine):
|
||||
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||
его созданные партии и группы переназначаются на админа."""
|
||||
me = login(client, "Аня") # владелец группы и создатель партии
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
@@ -187,15 +175,12 @@ def test_dev_delete_account_rejects_admin(client: TestClient, make_admin):
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin):
|
||||
def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine):
|
||||
me = login(client, "Кто-то")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Друг"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Друг"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Друг")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import csrf_headers, finish_match, login, start_match
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _expansion_id(client: TestClient, code: str) -> int:
|
||||
@@ -12,7 +12,7 @@ def _expansion_id(client: TestClient, code: str) -> int:
|
||||
return next(e["id"] for e in r.json() if e["code"] == code)
|
||||
|
||||
|
||||
def test_full_flow(client: TestClient, make_admin):
|
||||
def test_full_flow(client: TestClient, make_admin, engine):
|
||||
# 1) Вход игрока A — групп ещё нет.
|
||||
me = login(client, "Иван")
|
||||
assert me["groups"] == []
|
||||
@@ -44,18 +44,8 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
|
||||
# 4) Создать игрока B и добавить в группу по нику.
|
||||
rb = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Олег"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/members",
|
||||
json={"nickname": "Олег"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
b_id = r.json()["user_id"]
|
||||
# 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()
|
||||
@@ -150,15 +140,12 @@ def test_full_flow(client: TestClient, make_admin):
|
||||
assert {"Иван", "Олег"}.issubset(nicks)
|
||||
|
||||
|
||||
def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
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"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Гость2"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
).json()["user_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"]
|
||||
|
||||
@@ -202,16 +189,13 @@ def test_in_progress_excluded_and_win_reason_required(client: TestClient):
|
||||
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):
|
||||
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"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Согрупник"}, headers=csrf_headers(client))
|
||||
b = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Согрупник"}, headers=csrf_headers(client)
|
||||
).json()["user_id"]
|
||||
b = add_group_member(engine, gid, "Согрупник")
|
||||
factions = client.get(f"/api/groups/{gid}/factions").json()
|
||||
f1, f2 = factions[0]["id"], factions[1]["id"]
|
||||
|
||||
@@ -236,16 +220,14 @@ def test_owner_can_finish_member_started_match(client: TestClient):
|
||||
assert fin.status_code == 200, fin.text
|
||||
|
||||
|
||||
def test_any_member_can_finish_and_manage(client: TestClient):
|
||||
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"]
|
||||
for nick in ("Бэ", "Цэ"):
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "Бэ"}, headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "Цэ"}, headers=csrf_headers(client)).json()["user_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.
|
||||
@@ -305,18 +287,14 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
|
||||
assert guest_dev["is_active"] is False
|
||||
|
||||
|
||||
def test_tie_ranking_and_points(client: TestClient):
|
||||
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"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "B"}, headers=csrf_headers(client))
|
||||
client.post("/api/auth/dev/users", json={"nickname": "C"}, headers=csrf_headers(client))
|
||||
b = client.post(f"/api/groups/{gid}/members", json={"nickname": "B"},
|
||||
headers=csrf_headers(client)).json()["user_id"]
|
||||
c = client.post(f"/api/groups/{gid}/members", json={"nickname": "C"},
|
||||
headers=csrf_headers(client)).json()["user_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"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Приглашения в группу: создание, просмотр, принятие/отклонение, права участника."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, login
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Группа") -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
|
||||
def _dev_user(client: TestClient, nick: str) -> None:
|
||||
client.post("/api/auth/dev/users", json={"nickname": nick}, headers=csrf_headers(client))
|
||||
|
||||
|
||||
def test_invite_accept_flow(client: TestClient):
|
||||
login(client, "Овнер")
|
||||
gid = _group(client)
|
||||
_dev_user(client, "Гость")
|
||||
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["invited_by_nickname"] == "Овнер"
|
||||
# Повторное приглашение → 409.
|
||||
dup = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert dup.status_code == 409, dup.text
|
||||
|
||||
# Приглашённый видит приглашение и принимает.
|
||||
login(client, "Гость")
|
||||
invs = client.get("/api/invitations").json()
|
||||
assert len(invs) == 1
|
||||
assert invs[0]["group_id"] == gid and invs[0]["group_name"] == "Группа"
|
||||
inv_id = invs[0]["id"]
|
||||
acc = client.post(f"/api/invitations/{inv_id}/accept", headers=csrf_headers(client))
|
||||
assert acc.status_code == 200, acc.text
|
||||
# Стал участником; первая группа стала активной; приглашение исчезло.
|
||||
me = client.get("/api/users/me").json()
|
||||
assert me["active_group_id"] == gid
|
||||
assert client.get("/api/invitations").json() == []
|
||||
|
||||
# Теперь он участник → приглашать его повторно нельзя.
|
||||
login(client, "Овнер")
|
||||
again = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert again.status_code == 409, again.text
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert any(m["nickname"] == "Гость" for m in members)
|
||||
|
||||
|
||||
def test_invite_decline(client: TestClient):
|
||||
login(client, "Овнер2")
|
||||
gid = _group(client)
|
||||
_dev_user(client, "Гость2")
|
||||
client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Гость2"}, headers=csrf_headers(client)
|
||||
)
|
||||
|
||||
login(client, "Гость2")
|
||||
inv_id = client.get("/api/invitations").json()[0]["id"]
|
||||
dec = client.post(f"/api/invitations/{inv_id}/decline", headers=csrf_headers(client))
|
||||
assert dec.status_code == 200, dec.text
|
||||
assert client.get("/api/invitations").json() == []
|
||||
|
||||
login(client, "Овнер2")
|
||||
members = client.get(f"/api/groups/{gid}/members").json()
|
||||
assert all(m["nickname"] != "Гость2" for m in members)
|
||||
|
||||
|
||||
def test_any_member_can_invite(client: TestClient, engine):
|
||||
login(client, "Овнер3")
|
||||
gid = _group(client)
|
||||
add_group_member(engine, gid, "Участник") # обычный member (не владелец)
|
||||
_dev_user(client, "Новичок")
|
||||
|
||||
# Обычный участник входит и приглашает — должно быть можно (assert_member, не owner).
|
||||
login(client, "Участник")
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "Новичок"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_invite_unknown_nick_404(client: TestClient):
|
||||
login(client, "Овнер4")
|
||||
gid = _group(client)
|
||||
r = client.post(
|
||||
f"/api/groups/{gid}/invitations", json={"nickname": "НетТакого"}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import create_finished_match, csrf_headers, login
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
@@ -15,16 +15,13 @@ def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
def _finished_match_for(client: TestClient, me: dict) -> int:
|
||||
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"]
|
||||
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
|
||||
p2 = client.post(
|
||||
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
|
||||
).json()["user_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,
|
||||
@@ -110,10 +107,10 @@ def test_public_profile(client: TestClient):
|
||||
assert client.get("/api/users/999999/profile").status_code == 404
|
||||
|
||||
|
||||
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
|
||||
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, me)
|
||||
_finished_match_for(client, engine, me)
|
||||
|
||||
def entry_for(uid: int) -> dict:
|
||||
board = client.get("/api/stats/leaderboard").json()
|
||||
|
||||
Reference in New Issue
Block a user