"""Тесты службы отправки (ТЗ 3.21).
Наружу не выходим: `webpush` подменён, а push-сервис отвечает так, как отвечает
настоящий — 201 на успех, 404/410 на отозванную подписку, 500 на сбой.
"""
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import AppSetting, PushSubscription, Task, User
from app.services import push as push_service
from app.services.push_texts import render, user_language
from tests.conftest import _test_session_factory # type: ignore[attr-defined]
ENDPOINT = "https://push.example.com/sub/abc123"
@pytest.fixture
def db(monkeypatch: Any) -> Iterator[Any]:
monkeypatch.setattr(push_service, "get_session_factory", lambda: _test_session_factory)
session = _test_session_factory()
try:
yield session
finally:
session.close()
@pytest.fixture(autouse=True)
def vapid() -> Iterator[None]:
settings = get_settings()
old = (settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject)
settings.vapid_public_key = "pub-key"
settings.vapid_private_key = "priv-key"
settings.vapid_subject = "mailto:test@example.com"
try:
yield
finally:
settings.vapid_public_key, settings.vapid_private_key, settings.vapid_subject = old
@pytest.fixture
def sent(monkeypatch: Any) -> list[dict[str, Any]]:
"""Записанные вызовы webpush: словарь подписки + параметры отправки."""
calls: list[dict[str, Any]] = []
def fake_webpush(info: Any, **kwargs: Any) -> Any:
calls.append({"info": info, **kwargs})
return SimpleNamespace(status_code=201)
monkeypatch.setattr(push_service, "webpush", fake_webpush)
return calls
def _fail_with(status: int) -> Any:
def raiser(info: Any, **kwargs: Any) -> Any:
from pywebpush import WebPushException
raise WebPushException("сбой", response=SimpleNamespace(status_code=status, text=""))
return raiser
def _device(
db: Any, user_id: str = "1", endpoint: str = ENDPOINT, **extra: Any
) -> PushSubscription:
sub = PushSubscription(
user_id=user_id, endpoint=endpoint, p256dh="p256dh", auth="auth", **extra
)
db.add(sub)
db.commit()
return sub
def _payload() -> str:
return push_service.build_payload(
kind="task.review", title="Отчёт", body="Посмотрите, что сделано", url="/tasks/1", tag="t1"
)
def test_push_is_off_without_keys(db: Any, sent: list[dict[str, Any]], monkeypatch: Any) -> None:
settings = get_settings()
settings.vapid_public_key = ""
settings.vapid_private_key = ""
_device(db)
assert push_service.send_to_user("1", _payload()) == 0
assert sent == []
def test_successful_delivery_marks_subscription(db: Any, sent: list[dict[str, Any]]) -> None:
sub = _device(db)
assert push_service.send_to_user("1", _payload()) == 1
assert len(sent) == 1
call = sent[0]
assert call["info"]["endpoint"] == ENDPOINT
assert call["info"]["keys"] == {"p256dh": "p256dh", "auth": "auth"}
assert call["content_encoding"] == "aes128gcm"
assert call["vapid_claims"]["sub"] == "mailto:test@example.com"
db.refresh(sub)
assert sub.last_success_at is not None
assert sub.failure_count == 0
def test_gone_subscription_is_removed(db: Any, monkeypatch: Any) -> None:
"""404/410 — браузер отозвал подписку: держать её больше некому."""
_device(db)
monkeypatch.setattr(push_service, "webpush", _fail_with(410))
assert push_service.send_to_user("1", _payload()) == 0
assert db.scalars(select(PushSubscription)).all() == []
def test_transient_failure_keeps_subscription(db: Any, monkeypatch: Any) -> None:
sub = _device(db)
monkeypatch.setattr(push_service, "webpush", _fail_with(500))
assert push_service.send_to_user("1", _payload()) == 0
db.refresh(sub)
assert sub.failure_count == 1
assert db.scalars(select(PushSubscription)).all() != []
def test_subscription_dies_after_repeated_failures(db: Any, monkeypatch: Any) -> None:
"""Пять неудач подряд — устройство потеряно, шестого письма ему не нужно."""
_device(db)
monkeypatch.setattr(push_service, "webpush", _fail_with(500))
for _ in range(push_service.MAX_FAILURES):
push_service.send_to_user("1", _payload())
assert db.scalars(select(PushSubscription)).all() == []
def test_resubscribe_resets_failure_counter(db: Any, monkeypatch: Any) -> None:
"""Вернувшееся устройство не должно умереть от старых ошибок."""
sub = _device(db, failure_count=4)
push_service.save_subscription(db, "1", ENDPOINT, "p256dh", "auth", "Phone/1.0")
db.commit()
assert sub.failure_count == 0
def test_delivery_goes_only_to_owner_devices(db: Any, sent: list[dict[str, Any]]) -> None:
_device(db, user_id="1", endpoint=f"{ENDPOINT}-mine")
_device(db, user_id="2", endpoint=f"{ENDPOINT}-foreign")
assert push_service.send_to_user("1", _payload()) == 1
assert sent[0]["info"]["endpoint"] == f"{ENDPOINT}-mine"
def test_payload_is_truncated_to_push_limit() -> None:
"""~4 КБ — потолок push-сервиса: тело усекаем, а не теряем уведомление."""
import json
long_body = "я" * 5000
data = push_service.build_payload(
kind="task.review", title="Отчёт", body=long_body, url="/tasks/1", tag="t1"
)
assert len(data.encode()) <= push_service.MAX_PAYLOAD
decoded = json.loads(data)
# заголовок и ссылка на месте — усекли только тело
assert decoded["title"] == "Отчёт"
assert decoded["url"] == "/tasks/1"
assert decoded["body"].endswith("…")
# --- Тексты и язык ---
def test_language_chain_settings_then_sso_then_default(db: Any) -> None:
db.add(User(id="u1", email="a@b.c", locale="en-US"))
db.add(User(id="u2", email="a@b.c", locale="de"))
db.commit()
# настройка перебивает SSO; регион отбрасывается; неизвестный язык → ru
db.add(AppSetting(user_id="u1", key="language", value="uk"))
db.commit()
assert user_language(db, "u1") == "uk"
assert user_language(db, "u2") == "ru"
assert user_language(db, "нет-такого") == "ru"
def test_render_fills_title_and_agent_name() -> None:
title, body = render("task.claimed", "ru", title="Починить забор", name="Нави") or ("", "")
assert title and "Починить забор" in body and "Нави" in body
# неизвестный язык падает на ru, а неизвестный kind — не выдумывается
assert render("task.claimed", "de", title="X", name="N") == render(
"task.claimed", "ru", title="X", name="N"
)
assert render("task.unknown", "ru", title="X") is None
def test_render_clips_long_title() -> None:
"""Заголовок приходит от пользователя (до 500 символов), а уведомление — не место для него."""
_, body = render("task.review", "ru", title="Заголовок " * 100) or ("", "")
assert "…" in body
assert len(body) <= 140
def test_notify_agent_review_sends_rendered_notification(
db: Any, sent: list[dict[str, Any]]
) -> None:
"""Полный путь: задача в БД → текст на языке владельца → push на устройство."""
import json
db.add(User(id="1", email="a@b.c", locale="en"))
db.add(Task(id=1, user_id="1", title="Финансовый отчёт"))
db.commit()
_device(db)
push_service.notify_agent_review(1)
assert len(sent) == 1
data = json.loads(sent[0]["data"])
assert data["kind"] == "task.review"
assert data["url"] == "/tasks/1"
assert "Финансовый отчёт" in data["body"]
assert data["title"] == "Work awaits review" # язык взят из SSO-локали
def test_notify_skips_unknown_task(db: Any, sent: list[dict[str, Any]]) -> None:
_device(db)
push_service.notify_agent_review(999)
assert sent == []