"""Webhook delivery. Two ways a notification silently went nowhere before 0.67.1: an ntfy title with a non-ASCII character (a destination called "NAS Büro", an em dash) raised UnicodeEncodeError inside httpx, and any error that was not an ``httpx.HTTPError`` escaped the per-webhook handler and aborted delivery to every webhook after it. """ from __future__ import annotations import asyncio import email.header import httpx def test_a_non_ascii_ntfy_title_is_rfc2047_encoded(): from services.notify_service import _build_request kwargs = _build_request("ntfy", "http://ntfy/t", "backup_failed", "Backup to NAS Büro — failed", "m") request = httpx.Request("POST", kwargs["url"], headers=kwargs["headers"], content=kwargs["content"]) (decoded, charset), = email.header.decode_header(request.headers["title"]) assert decoded.decode(charset) == "Backup to NAS Büro — failed" def test_an_ascii_title_is_sent_as_is(): from services.notify_service import _header_value assert _header_value("Stack 'x' started") == "Stack 'x' started" def test_one_broken_webhook_does_not_stop_the_others(monkeypatch): from services import notify_service delivered = [] class Client: async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def post(self, url, timeout=None, **kwargs): if "broken" in url: raise ValueError("not an httpx error") delivered.append(url) return httpx.Response(200, request=httpx.Request("POST", url)) monkeypatch.setattr(notify_service.httpx, "AsyncClient", lambda **kw: Client()) monkeypatch.setattr( notify_service, "_targets_for_event", lambda session, event: [("generic", "http://broken/"), ("generic", "http://fine/")], ) count = asyncio.run(notify_service.notify("stack_start", "t", "m", session=object())) assert count == 1 assert delivered == ["http://fine/"]