"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки», повторный показ с пометкой «обновлено», снятие с показа и удаление.""" from __future__ import annotations from datetime import datetime, timedelta, timezone import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select from app.models import AnnouncementView, User from app.services import notify from app.services.announcement_service import sanitize_body from tests.conftest import csrf_headers, login def _iso(dt: datetime) -> str: return dt.astimezone(timezone.utc).isoformat() def _now() -> datetime: return datetime.now(timezone.utc) def _admin_login(client: TestClient, make_admin) -> None: make_admin("admin", "secret123") r = client.post( "/api/admin/auth/login", json={"username": "admin", "password": "secret123"}, headers=csrf_headers(client), ) assert r.status_code == 200, r.text def _create(client: TestClient, **over) -> dict: body = { "title": "Турнир", "body_html": "

Суббота, 11:00

", "starts_at": _iso(_now() - timedelta(hours=1)), "ends_at": _iso(_now() + timedelta(days=1)), "show_to_new_players": True, } body.update(over) r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) assert r.status_code == 200, r.text return r.json() def _update(client: TestClient, ann: dict, **over) -> dict: body = { "title": ann["title"], "body_html": ann["body_html"], "starts_at": ann["starts_at"], "ends_at": ann["ends_at"], "show_to_new_players": ann["show_to_new_players"], "reshow": False, } body.update(over) r = client.put( f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client) ) assert r.status_code == 200, r.text return r.json() def _pending(client: TestClient) -> list[dict]: r = client.get("/api/announcements/pending") assert r.status_code == 200, r.text return r.json() def _ack(client: TestClient, ann_id: int, revision: int): return client.post( f"/api/announcements/{ann_id}/ack", json={"revision": revision}, headers=csrf_headers(client), ) def _admin_item(client: TestClient, ann_id: int) -> dict: return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id) # ─── Очистка HTML ──────────────────────────────────────────────────────────── def test_sanitize_keeps_allowed_and_strips_everything_else(): raw = ( '
Привет мир
' '

курсив красный ' 'золотой

' "" 'ссылка' "svg-текст<b>" ) html, text = sanitize_body(raw) assert html == ( "

Привет мир

" '

курсив красный золотой

' "ссылка<b>" ) # Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает. assert text == "Привет миркурсив красный золотойссылка" def test_sanitize_fixes_nesting_and_keeps_line_breaks(): html, _ = sanitize_body("
один
два
жирный
хвост") # Вложенные
из contenteditable → плоские абзацы без пустых

; #
внутри не путается с самим ; незакрытое закрывается. assert html == "

один

два

жирный
хвост
" # ─── Права ─────────────────────────────────────────────────────────────────── def test_admin_endpoints_are_closed_to_players(client: TestClient): assert client.get("/api/admin/announcements").status_code == 401 login(client, "Игрок") assert client.get("/api/admin/announcements").status_code == 401 r = client.post( "/api/admin/announcements", json={ "title": "x", "body_html": "y", "starts_at": _iso(_now()), "ends_at": _iso(_now() + timedelta(days=1)), }, headers=csrf_headers(client), ) assert r.status_code == 401 def test_pending_requires_player_session(client: TestClient): assert client.get("/api/announcements/pending").status_code == 401 # ─── Показ ─────────────────────────────────────────────────────────────────── def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch): events: list[dict] = [] monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev)) login(client, "Игрок") _admin_login(client, make_admin) ann = _create(client, body_html="

Сбор в 11:00

") assert ann["body_html"] == "

Сбор в 11:00

" assert ann["status"] == "live" assert (ann["seen_count"], ann["audience_count"]) == (0, 1) assert {"type": "announcements"} in events items = _pending(client) assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)] assert _ack(client, ann["id"], 1).status_code == 200 assert _pending(client) == [] assert _admin_item(client, ann["id"])["seen_count"] == 1 logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"] assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])] def test_pending_respects_period_and_order(client: TestClient, make_admin): login(client, "Игрок") _admin_login(client, make_admin) later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30))) earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3))) planned = _create( client, title="Завтра", starts_at=_iso(_now() + timedelta(days=1)), ends_at=_iso(_now() + timedelta(days=2)), ) assert planned["status"] == "planned" # Пересекающиеся периоды — от старого к новому; запланированного пока нет. assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"] assert earlier["id"] != later["id"] # Период, который уже закончился, создать нельзя. r = client.post( "/api/admin/announcements", json={ "title": "Прошлое", "body_html": "текст", "starts_at": _iso(_now() - timedelta(days=2)), "ends_at": _iso(_now() - timedelta(days=1)), }, headers=csrf_headers(client), ) assert r.status_code == 422 def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine): login(client, "Старожил") with Session(engine) as s: old = s.exec(select(User).where(User.nickname == "Старожил")).one() old.created_at = datetime.now(timezone.utc) - timedelta(days=2) s.add(old) s.commit() _admin_login(client, make_admin) ann = _create(client, show_to_new_players=False) assert ann["audience_count"] == 1 assert [a["id"] for a in _pending(client)] == [ann["id"]] login(client, "Новичок") # зарегистрирован уже после начала показа assert _pending(client) == [] # Адресаты — по-прежнему только старожил. assert _admin_item(client, ann["id"])["audience_count"] == 1 def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin): login(client, "Игрок") _admin_login(client, make_admin) ann = _create(client) assert _ack(client, ann["id"], 1).status_code == 200 # Правка без «показать заново» — закрывшие её не видят. ann = _update(client, ann, body_html="

Сбор в 12:00

") assert ann["revision"] == 1 assert _pending(client) == [] ann = _update(client, ann, body_html="

Сбор в 13:00

", reshow=True) assert ann["revision"] == 2 assert ann["seen_count"] == 0 # закрывших новую версию ещё нет items = _pending(client) assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [ (2, True, "

Сбор в 13:00

") ] # Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью. ann = _update(client, ann, reshow=True) assert _ack(client, ann["id"], 2).status_code == 200 assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)] assert _ack(client, ann["id"], 3).status_code == 200 assert _pending(client) == [] def test_stop_and_delete(client: TestClient, make_admin, engine): login(client, "Игрок") _admin_login(client, make_admin) live = _create(client) planned = _create( client, starts_at=_iso(_now() + timedelta(days=1)), ends_at=_iso(_now() + timedelta(days=2)), ) r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client)) assert r.status_code == 422 # снять можно только идущее r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client)) assert r.status_code == 200, r.text assert r.json()["status"] == "finished" assert _pending(client) == [] assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)) assert r.status_code == 200 with Session(engine) as s: assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом assert client.delete( f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client) ).status_code == 404 assert _ack(client, live["id"], 1).status_code == 404 @pytest.mark.parametrize( ("over", "message_part"), [ ({"title": " "}, "Заголовок"), ({"title": "Я" * 61}, "Заголовок"), ({"body_html": "

"}, "пустым"), ({"body_html": "

" + "а" * 601 + "

"}, "600"), ({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"), ], ) def test_validation(client: TestClient, make_admin, over: dict, message_part: str): _admin_login(client, make_admin) body = { "title": "Заголовок", "body_html": "

текст

", "starts_at": _iso(_now() - timedelta(hours=1)), "ends_at": _iso(_now() + timedelta(days=1)), } body.update(over) r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) assert r.status_code == 422, r.text err = r.json()["error"] assert err["code"] == "VALIDATION_ERROR" assert message_part in err["message"]