22
33from __future__ import annotations
44
5+ import base64
6+ import re
7+
58import httpx
69import pytest
710import respx
811
9- from clayde .webhook .notify import NotificationPayload , send_ntfy
12+ from clayde .webhook .notify import NotificationPayload , _encode_header_value , send_ntfy
1013
1114
1215def test_notification_payload_clamps_length ():
@@ -15,41 +18,57 @@ def test_notification_payload_clamps_length():
1518 assert len (p .body ) == 300
1619
1720
21+ def test_notification_payload_clamps_length_with_unicode ():
22+ # Character-count clamp, not byte-count — verify multibyte chars still
23+ # count as one position.
24+ p = NotificationPayload (title = "ü" * 100 , body = "ß" * 1000 , success = True )
25+ assert len (p .title ) == 40
26+ assert len (p .body ) == 300
27+ assert p .title == "ü" * 40
28+
29+
1830def test_notification_payload_accepts_short ():
1931 p = NotificationPayload (title = "hi" , body = "all good" , success = True )
2032 assert p .title == "hi"
2133 assert p .body == "all good"
2234 assert p .success is True
2335
2436
25- def test_notification_payload_em_dash_in_title_normalised ():
26- # Real prod failure: em dash in title raised UnicodeEncodeError when
27- # httpx serialised the header as latin-1.
28- p = NotificationPayload (title = "Thomas Stegger — plant prefs saved" , body = "ok" , success = True )
29- assert "—" not in p .title
30- assert p .title == "Thomas Stegger - plant prefs saved"
31- # Must round-trip cleanly through latin-1 (the header codec httpx uses).
32- p .title .encode ("latin-1" )
37+ def test_notification_payload_preserves_unicode ():
38+ # Raw Unicode is kept as-is; RFC 2047 encoding happens in send_ntfy.
39+ p = NotificationPayload (title = "Müller — Notiz" , body = "ok" , success = True )
40+ assert p .title == "Müller — Notiz"
3341
3442
35- def test_notification_payload_smart_quotes_in_title_normalised ():
36- p = NotificationPayload (title = "“hi” ‘there’" , body = "ok" , success = True )
37- assert p .title == '"hi" \' there\' '
43+ def test_encode_header_value_passes_ascii_through ():
44+ assert _encode_header_value ("plain ascii" ) == "plain ascii"
3845
3946
40- def test_notification_payload_unknown_unicode_in_title_replaced ():
41- p = NotificationPayload (title = "emoji \U0001f600 tail" , body = "ok" , success = True )
42- assert "\U0001f600 " not in p .title
43- p .title .encode ("ascii" )
47+ _RFC2047_WORD = re .compile (r"=\?utf-8\?[bq]\?[^?]*\?=" , re .IGNORECASE )
4448
4549
46- def test_notification_payload_ascii_coercion_runs_before_clamp ():
47- # "..." (3 chars) replaces "…" (1 char); clamp comes after, so a
48- # title that fit pre-replacement may not fit after — and that's fine.
49- long = "a" * 38 + "…" # 39 chars in, 41 chars after replacement
50- p = NotificationPayload (title = long , body = "ok" , success = True )
51- assert len (p .title ) == 40
52- p .title .encode ("ascii" )
50+ def test_encode_header_value_rfc2047_encodes_unicode ():
51+ out = _encode_header_value ("Thomas Stegger — plant prefs saved" )
52+ # email.header.Header emits =?utf-8?[bq]?...?= encoded words; B and Q
53+ # are both valid RFC 2047 forms and ntfy decodes either.
54+ assert _RFC2047_WORD .search (out )
55+ decoded = _decode_rfc2047 (out )
56+ assert decoded == "Thomas Stegger — plant prefs saved"
57+ # Result must be ASCII-only so httpx can serialise it as a header.
58+ out .encode ("ascii" )
59+
60+
61+ def _decode_rfc2047 (encoded : str ) -> str :
62+ """Decode an RFC 2047 encoded-word string back to its Unicode form."""
63+ from email .header import decode_header
64+ parts = decode_header (encoded )
65+ out = []
66+ for chunk , charset in parts :
67+ if isinstance (chunk , bytes ):
68+ out .append (chunk .decode (charset or "ascii" ))
69+ else :
70+ out .append (chunk )
71+ return "" .join (out )
5372
5473
5574@pytest .mark .asyncio
@@ -68,6 +87,7 @@ async def test_send_ntfy_success_headers():
6887 )
6988 assert route .called
7089 req = route .calls .last .request
90+ # ASCII title passes through verbatim.
7191 assert req .headers ["title" ] == "pong"
7292 assert req .headers ["priority" ] == "3"
7393 assert req .headers ["tags" ] == "white_check_mark"
@@ -93,6 +113,69 @@ async def test_send_ntfy_uses_failure_priority_and_tags_when_success_false():
93113 assert req .headers ["tags" ] == "rotating_light"
94114
95115
116+ @pytest .mark .asyncio
117+ @respx .mock
118+ async def test_send_ntfy_encodes_unicode_title_as_rfc2047 ():
119+ route = respx .post ("https://ntfy.sh/abc123" ).mock (
120+ return_value = httpx .Response (200 , json = {"id" : "msg1" })
121+ )
122+ title = "Thomas Stegger — plant prefs saved"
123+ await send_ntfy (
124+ title = title ,
125+ body = "ok" ,
126+ success = True ,
127+ base_url = "https://ntfy.sh" ,
128+ topic = "abc123" ,
129+ timeout_s = 5 ,
130+ )
131+ req = route .calls .last .request
132+ header = req .headers ["title" ]
133+ # Must be ASCII-only so httpx can transmit it.
134+ header .encode ("ascii" )
135+ assert _RFC2047_WORD .search (header )
136+ assert _decode_rfc2047 (header ) == title
137+
138+
139+ @pytest .mark .asyncio
140+ @respx .mock
141+ async def test_send_ntfy_handles_emoji_title ():
142+ route = respx .post ("https://ntfy.sh/abc123" ).mock (
143+ return_value = httpx .Response (200 , json = {"id" : "msg1" })
144+ )
145+ title = "\U0001f600 done"
146+ await send_ntfy (
147+ title = title ,
148+ body = "ok" ,
149+ success = True ,
150+ base_url = "https://ntfy.sh" ,
151+ topic = "abc123" ,
152+ timeout_s = 5 ,
153+ )
154+ req = route .calls .last .request
155+ header = req .headers ["title" ]
156+ header .encode ("ascii" )
157+ assert _decode_rfc2047 (header ) == title
158+
159+
160+ @pytest .mark .asyncio
161+ @respx .mock
162+ async def test_send_ntfy_handles_german_umlauts_title ():
163+ route = respx .post ("https://ntfy.sh/abc123" ).mock (
164+ return_value = httpx .Response (200 , json = {"id" : "msg1" })
165+ )
166+ title = "Müller — Notiz gespeichert"
167+ await send_ntfy (
168+ title = title ,
169+ body = "ok" ,
170+ success = True ,
171+ base_url = "https://ntfy.sh" ,
172+ topic = "abc123" ,
173+ timeout_s = 5 ,
174+ )
175+ req = route .calls .last .request
176+ assert _decode_rfc2047 (req .headers ["title" ]) == title
177+
178+
96179@pytest .mark .asyncio
97180@respx .mock
98181async def test_send_ntfy_swallows_errors ():
0 commit comments