Раньше уведомления удалялись через 72 часа после создания независимо от прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени чистятся только прочитанные (72ч после прочтения), а от роста у незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются старейшие сверх лимита. Механика доставки не менялась: запись в БД + pull при заходе, SSE лишь ускоряет обновление открытых вкладок. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
7.4 KiB
Python
185 lines
7.4 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_removes_only_long_read(engine):
|
|
"""Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно
|
|
дождаться игрока, сколько бы тот ни отсутствовал."""
|
|
uid = _make_user(engine, "Боб")
|
|
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
|
|
hours=notification_service.RETENTION_HOURS + 1
|
|
)
|
|
with Session(engine) as s:
|
|
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
|
|
s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old))
|
|
s.add(
|
|
Notification(
|
|
user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old
|
|
)
|
|
)
|
|
s.add(
|
|
Notification(
|
|
user_id=uid,
|
|
type="test",
|
|
title="Свежепрочитанное",
|
|
created_at=old,
|
|
read_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
|
)
|
|
)
|
|
s.commit()
|
|
|
|
removed = notification_service.purge_expired(s)
|
|
assert removed == 1
|
|
titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]}
|
|
assert titles == {"Непрочитанное", "Свежепрочитанное"}
|
|
|
|
|
|
def test_cap_per_user(engine):
|
|
"""Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие."""
|
|
uid = _make_user(engine, "Копитель")
|
|
with Session(engine) as s:
|
|
for i in range(notification_service.MAX_PER_USER + 5):
|
|
notification_service.create(s, uid, type="test", title=f"n{i}")
|
|
data = notification_service.list_for_user(s, uid)
|
|
assert len(data["items"]) == notification_service.MAX_PER_USER
|
|
titles = {n["title"] for n in data["items"]}
|
|
assert "n0" not in titles # старейшие срезаны
|
|
assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте
|
|
|
|
|
|
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
|