Files
ForbiddenStarsApp/backend/tests/test_notifications.py
T
NotBigGhostandClaude Opus 4.8 f4286ffa6c Уведомления: persistent + live (push-to-invalidate → pull)
Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).

Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).

Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:16:40 +03:00

159 lines
5.9 KiB
Python

"""Уведомления: сервис (create/list/mark_read/purge) и триггеры (инвайт, старт/финиш партии)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.models import Notification, User
from app.services import notification_service
from tests.conftest import (
add_group_member,
csrf_headers,
finish_match,
login,
start_match,
)
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 _make_user(engine, nick: str) -> int:
with Session(engine) as s:
u = User(nickname=nick, role="player", auth_provider="stub")
s.add(u)
s.commit()
s.refresh(u)
return u.id # type: ignore[return-value]
# ─── Сервис ────────────────────────────────────────────────────────────────────
def test_create_list_mark_read(engine):
uid = _make_user(engine, "Алиса")
with Session(engine) as s:
notification_service.create(s, uid, type="test", title="Привет", link="/account")
data = notification_service.list_for_user(s, uid)
assert data["unread_count"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "Привет"
assert data["items"][0]["link"] == "/account"
assert data["items"][0]["read_at"] is None
marked = notification_service.mark_read(s, uid)
assert marked == 1
after = notification_service.list_for_user(s, uid)
assert after["unread_count"] == 0
assert after["items"][0]["read_at"] is not None
def test_purge_expired(engine):
uid = _make_user(engine, "Боб")
with Session(engine) as s:
n = Notification(user_id=uid, type="test", title="Старое")
s.add(n)
s.commit()
s.refresh(n)
# Состарим запись за пределы окна хранения.
n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
hours=notification_service.RETENTION_HOURS + 1
)
s.add(n)
s.commit()
removed = notification_service.purge_expired(s)
assert removed == 1
assert notification_service.list_for_user(s, uid)["items"] == []
def test_notifications_require_auth(client: TestClient):
assert client.get("/api/notifications").status_code == 401
# ─── Триггеры через HTTP ─────────────────────────────────────────────────────────
def test_invitation_creates_notification(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
login(client, "Гость")
data = client.get("/api/notifications").json()
invites = [n for n in data["items"] if n["type"] == "invite"]
assert len(invites) == 1
assert invites[0]["link"] == "/group"
assert "Группа" in invites[0]["title"]
assert data["unread_count"] >= 1
def test_match_notifications_exclude_actor(client: TestClient, engine):
me = login(client, "Капитан")
gid = _group(client)
member_id = add_group_member(engine, gid, "Боец")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
started = start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0]},
{"user_id": member_id, "faction_id": fids[1]},
],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
# Инициатор (Капитан) себе уведомление не шлёт.
mine = client.get("/api/notifications").json()
assert all(n["type"] != "match_started" for n in mine["items"])
# Участник получает уведомление о старте со ссылкой на партию.
login(client, "Боец")
his = client.get("/api/notifications").json()
started_notifs = [n for n in his["items"] if n["type"] == "match_started"]
assert len(started_notifs) == 1
assert started_notifs[0]["link"] == f"/match/{mid}"
# Финиш (инициатор снова Капитан) → участник получает уведомление о завершении.
login(client, "Капитан")
fin = finish_match(
client,
mid,
[{"user_id": me["id"], "place": 1}, {"user_id": member_id, "place": 2}],
)
assert fin.status_code == 200, fin.text
login(client, "Боец")
his2 = client.get("/api/notifications").json()
assert any(
n["type"] == "match_finished" and n["link"] == f"/match/{mid}" for n in his2["items"]
)
def test_mark_read_endpoint(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")
assert client.get("/api/notifications").json()["unread_count"] >= 1
r = client.post("/api/notifications/read", json={}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert client.get("/api/notifications").json()["unread_count"] == 0