"""Unit tests for PushService: payload, cooldown, dead-subscription pruning."""
import asyncio
from types import SimpleNamespace
from navi.push.service import PushService, _preview
class FakeStore:
def __init__(self, subs=None):
self.subs = subs or []
self.deleted: list[str] = []
self.pushed: list[str] = []
async def upsert(self, user_id, endpoint, p256dh, auth, user_agent=None):
self.upserts = getattr(self, "upserts", [])
self.upserts.append((user_id, endpoint, p256dh, auth, user_agent))
return SimpleNamespace(id="new-id", user_id=user_id, endpoint=endpoint,
p256dh=p256dh, auth=auth, user_agent=user_agent)
async def list_for_user(self, user_id):
return list(self.subs)
async def delete(self, endpoint):
self.deleted.append(endpoint)
async def mark_pushed(self, sub_id):
self.pushed.append(sub_id)
def make_settings(**over):
defaults = dict(
navi_push_vapid_public_key="PUB",
navi_push_vapid_private_key="PRIV",
navi_push_vapid_subject="mailto:test@navi.local",
navi_push_cooldown_sec=30,
)
defaults.update(over)
return SimpleNamespace(**defaults)
def make_sub(endpoint="https://push.example/ep1", sub_id="s1"):
return SimpleNamespace(
id=sub_id, user_id="u1", endpoint=endpoint, p256dh="P", auth="A",
user_agent=None,
)
async def drain():
"""Let scheduled create_task fan-outs finish (to_thread needs real time)."""
for _ in range(20):
await asyncio.sleep(0.01)
def test_preview_strips_markdown_and_truncates():
text = _preview("# Heading\n\n**bold** and `code` [link](http://x)\n\n```py\nblock = 1\n```\nplain")
assert "Heading" in text
assert "**" not in text
assert "`" not in text
assert "block" not in text # fenced code dropped
long = _preview("x" * 500)
assert len(long) <= 140
assert long.endswith("…")
async def test_disabled_service_is_noop(monkeypatch):
store = FakeStore([make_sub()])
service = PushService(store, make_settings(navi_push_vapid_public_key=""))
await service.notify_turn_complete("sess1", "u1", "hello")
await drain()
assert store.pushed == []
async def test_no_user_id_is_noop():
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
await service.notify_turn_complete("sess1", None, "hello")
await drain()
assert store.pushed == []
async def test_send_ok_marks_pushed(monkeypatch):
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
calls = []
def fake_webpush(subscription_info, data, vapid_private_key, vapid_claims):
calls.append((subscription_info, data, vapid_private_key, vapid_claims))
import pywebpush
monkeypatch.setattr(pywebpush, "webpush", fake_webpush)
await service.notify_turn_complete("sess1", "u1", "Ответ готов")
await drain()
assert len(calls) == 1
assert calls[0][2] == "PRIV"
assert calls[0][3] == {"sub": "mailto:test@navi.local"}
assert store.pushed == ["s1"]
async def test_404_prunes_dead_subscription(monkeypatch):
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
class FakeResponse:
status_code = 404
class WebPushException(Exception):
def __init__(self):
self.response = FakeResponse()
import pywebpush
def failing_webpush(*a, **kw):
raise WebPushException()
monkeypatch.setattr(pywebpush, "webpush", failing_webpush)
await service.notify_turn_complete("sess1", "u1", "hello")
await drain()
assert store.deleted == ["https://push.example/ep1"]
assert store.pushed == []
async def test_other_errors_keep_subscription(monkeypatch):
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
import pywebpush
def failing_webpush(*a, **kw):
raise ConnectionError("boom")
monkeypatch.setattr(pywebpush, "webpush", failing_webpush)
await service.notify_turn_complete("sess1", "u1", "hello")
await drain()
assert store.deleted == []
assert store.pushed == []
async def test_cooldown_suppresses_rapid_fire(monkeypatch):
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
import pywebpush
monkeypatch.setattr(pywebpush, "webpush", lambda *a, **kw: None)
await service.notify_turn_complete("sess1", "u1", "one")
await drain()
await service.notify_turn_complete("sess1", "u1", "two")
await drain()
# Different session is not suppressed.
await service.notify_turn_complete("sess2", "u1", "three")
await drain()
assert len(store.pushed) == 2
async def test_empty_content_is_noop():
store = FakeStore([make_sub()])
service = PushService(store, make_settings())
await service.notify_turn_complete("sess1", "u1", " ")
await drain()
assert store.pushed == []
async def test_subscribe_and_unsubscribe_roundtrip():
store = FakeStore()
service = PushService(store, make_settings())
sub_id = await service.subscribe("u1", "ep", "p", "a", "UA")
assert sub_id == "new-id"
assert store.upserts == [("u1", "ep", "p", "a", "UA")]
await service.unsubscribe("ep")
assert store.deleted == ["ep"]