test_notify_client.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """Notify wire contracts, including failures that must not create duplicate tiles."""
  2. import json
  3. import logging
  4. from unittest.mock import AsyncMock, patch
  5. import httpx
  6. import pytest
  7. from backend.app.models.notification import NotificationProvider
  8. from backend.app.schemas.notification import NotificationProviderCreate
  9. from backend.app.services.notification_service import NotificationService
  10. from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials
  11. @pytest.mark.parametrize(
  12. "device_id", ["ABC12345", "IO12345678901234", "WB12345678901234", "MC12345678901234", "GRP12345"]
  13. )
  14. async def test_push_uses_unified_json_endpoint(device_id):
  15. def handle(request):
  16. assert request.method == "POST"
  17. assert request.url.path == f"/notify-json/{device_id}"
  18. assert request.url.params["token"] == "a+b&c"
  19. assert request.headers["content-type"] == "application/json"
  20. expected = {
  21. "title": "Print complete",
  22. "text": "Pièce terminée 🖨",
  23. "groupType": "bambuddy",
  24. "iconUrl": "https://example.com/icon.png",
  25. "imageUrl": "https://example.com/photo.jpg",
  26. }
  27. if device_id.startswith(("WB", "GRP")):
  28. expected.pop("imageUrl")
  29. assert json.loads(request.content) == expected
  30. return httpx.Response(200, json={"success": True})
  31. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  32. await NotifyClient(http).send_notification(
  33. device_id,
  34. "a+b&c",
  35. title="Print complete",
  36. text="Pièce terminée 🖨",
  37. group_type="bambuddy",
  38. icon_url="https://example.com/icon.png",
  39. image_url="https://example.com/photo.jpg",
  40. )
  41. async def test_photo_on_http_only_installation_still_delivers_text():
  42. def handle(request):
  43. assert "imageUrl" not in json.loads(request.content)
  44. return httpx.Response(200, json={"success": True})
  45. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  46. await NotifyClient(http).send_notification(
  47. "ABC12345", "secret", title="Done", text="Done", image_url="http://lan/photo"
  48. )
  49. async def test_activity_start_is_explicit_and_updates_use_precise_id():
  50. requests = []
  51. def handle(request):
  52. requests.append(request)
  53. return httpx.Response(200, json={"success": True, "activityId": "LA123456", "pushed": False})
  54. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  55. client = NotifyClient(http)
  56. await client.start_activity("ABC12345", "secret", {"title": "Printer", "endsIn": 3600})
  57. result = await client.update_activity("LA123456", "secret", {"progress": 55, "endsIn": None})
  58. await client.end_activity("LA123456", "secret", {"progress": 100, "keepFor": 300})
  59. assert requests[0].url.params["new"] == "1"
  60. assert requests[1].url.path == requests[2].url.path == "/live-activity/LA123456"
  61. assert "new" not in requests[1].url.params
  62. assert json.loads(requests[1].content)["endsIn"] is None
  63. assert requests[2].method == "DELETE"
  64. assert result["pushed"] is False # Stored while the tile token rotates: not a failed update.
  65. @pytest.mark.parametrize(
  66. ("status", "body", "headers", "retry", "activity_id"),
  67. [
  68. (502, {"deliveryState": "unknown", "activityId": "LA123456"}, {}, None, "LA123456"),
  69. (502, {"deliveryState": "not-delivered", "retryAfterSeconds": 900}, {}, 900, None),
  70. (429, {"retryAfterSeconds": 1043, "openingTheAppMayHelp": False}, {}, 1043, None),
  71. (429, {}, {"Retry-After": "60"}, 60, None),
  72. (400, {}, {}, None, None),
  73. (410, {"endReason": "dismissed"}, {}, None, None),
  74. ],
  75. )
  76. async def test_gateway_recovery_hints_are_preserved_without_retrying(status, body, headers, retry, activity_id):
  77. calls = []
  78. def handle(request):
  79. calls.append(request)
  80. return httpx.Response(status, json={**body, "message": "secret"}, headers=headers)
  81. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  82. with pytest.raises(NotifyError) as error:
  83. await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
  84. assert len(calls) == 1
  85. assert error.value.status_code == status
  86. assert error.value.retry_after_seconds == retry
  87. assert error.value.activity_id == activity_id
  88. assert error.value.delivery_state == body.get("deliveryState")
  89. assert "secret" not in str(error.value)
  90. async def test_transport_exception_is_opaque_and_start_is_uncertain():
  91. def handle(request):
  92. raise httpx.ReadTimeout(f"Timed out: {request.url}", request=request)
  93. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  94. with pytest.raises(NotifyError) as error:
  95. await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
  96. assert error.value.delivery_state == "unknown"
  97. assert "secret" not in str(error.value)
  98. assert error.value.__suppress_context__
  99. async def test_httpx_logs_redact_query_token(caplog):
  100. caplog.set_level(logging.INFO, logger="httpx")
  101. async with httpx.AsyncClient(
  102. transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"success": True}))
  103. ) as http:
  104. await NotifyClient(http).send_notification("ABC12345", "top-secret", title="Done", text="Done")
  105. assert "top-secret" not in caplog.text
  106. assert "token=[REDACTED]" in caplog.text
  107. async def test_end_never_accepts_device_dialect():
  108. http = AsyncMock(spec=httpx.AsyncClient)
  109. with pytest.raises(NotifyError, match="precise activity ID"):
  110. await NotifyClient(http).end_activity("ABC12345", "secret")
  111. http.request.assert_not_called()
  112. @pytest.mark.parametrize("body", [{}, [], {"success": True}, {"success": True, "activityId": "bad/id"}])
  113. async def test_start_missing_handle_is_uncertain(body):
  114. async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
  115. with pytest.raises(NotifyError) as error:
  116. await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
  117. assert error.value.delivery_state == "unknown"
  118. @pytest.mark.parametrize("body", [{"success": False}, {"success": True, "failureCount": 2}, {"message": "ok"}])
  119. async def test_push_does_not_claim_delivery_for_application_errors_or_partial_groups(body):
  120. async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
  121. with pytest.raises(NotifyError):
  122. await NotifyClient(http).send_notification("GRP12345", "secret", title="Done", text="Done")
  123. async def test_rejected_browser_push_does_not_suggest_live_activity_setup():
  124. async with httpx.AsyncClient(
  125. transport=httpx.MockTransport(lambda _: httpx.Response(400, json={"error": "bad"}))
  126. ) as http:
  127. with pytest.raises(NotifyError) as error:
  128. await NotifyClient(http).send_notification(
  129. "WB12345678901234", "secret", title="Error", text="Printer error"
  130. )
  131. assert "Live Activity" not in str(error.value)
  132. assert "device registration" in str(error.value)
  133. @pytest.mark.parametrize(
  134. "config",
  135. [
  136. {"device_id": "../../etc", "token": "secret"},
  137. {"device_id": "ABC12345", "token": ""},
  138. {"device_id": "GRP12345", "token": "secret", "live_activities": True},
  139. ],
  140. )
  141. def test_invalid_credentials_and_group_live_activities_are_rejected(config):
  142. with pytest.raises(NotifyError):
  143. notify_credentials(config)
  144. @pytest.mark.parametrize(
  145. "extra",
  146. [
  147. {"live_activities": "false"},
  148. {"live_activity_privacy": "true"},
  149. {"icon_url": "http://example.com/icon.png"},
  150. {"live_activity_button_url": "https://user:secret@example.com"},
  151. {"live_activity_style": "broken"},
  152. {"live_activity_tint": "green"},
  153. {"live_activity_metrics": "layers"},
  154. {"live_activity_metrics": [["layers"]]},
  155. {"device_id": "WB12345678901234", "live_activities": True},
  156. {"device_id": "MC12345678901234", "live_activities": True},
  157. ],
  158. )
  159. def test_invalid_display_config_is_rejected_before_it_reaches_worker(extra):
  160. with pytest.raises(NotifyError):
  161. notify_credentials({"device_id": "ABC12345", "token": "secret", **extra})
  162. @pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "MC12345678901234"])
  163. def test_non_ios_targets_remain_eligible_for_push_notifications(device_id):
  164. assert notify_credentials({"device_id": device_id, "token": "secret", "live_activities": False}) == (
  165. device_id,
  166. "secret",
  167. )
  168. async def test_notify_provider_dispatch_and_test_use_existing_photo_toggle():
  169. service = NotificationService()
  170. provider = NotificationProvider(
  171. name="Notify",
  172. provider_type="notify",
  173. config=json.dumps({"device_id": "ABC12345", "token": "secret"}),
  174. attach_photo=False,
  175. quiet_hours_enabled=False,
  176. )
  177. with patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send:
  178. await service._send_to_provider(provider, "Title", "Body", image_data=b"image")
  179. send.assert_awaited_once_with(
  180. json.loads(provider.config), "Title", "Body", image_url=None, event_type=None, printer_id=None
  181. )
  182. send.reset_mock()
  183. await service.send_test_notification("notify", json.loads(provider.config), attach_photo=False)
  184. assert send.await_count == 1
  185. assert send.call_args.kwargs["image_url"] is None
  186. @pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "grp12345", "wb12345678901234"])
  187. async def test_browser_and_group_error_alerts_never_publish_or_reuse_camera_photos(device_id):
  188. service = NotificationService()
  189. config = {"device_id": device_id, "token": "secret"}
  190. provider = NotificationProvider(
  191. name="Notify", provider_type="notify", config=json.dumps(config), attach_photo=True, quiet_hours_enabled=False
  192. )
  193. with (
  194. patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send,
  195. patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as photo,
  196. patch("backend.app.services.notification_service._load_sample_notification_image") as sample,
  197. ):
  198. await service._send_to_provider(
  199. provider,
  200. "Printer error",
  201. "Filament runout",
  202. image_data=b"camera",
  203. event_type="printer_error",
  204. photo_cache={"url": "https://example.com/camera.jpg"},
  205. printer_id=42,
  206. )
  207. assert send.call_args.kwargs["image_url"] is None
  208. await service.send_test_notification("notify", config, attach_photo=True)
  209. assert send.call_args.kwargs["image_url"] is None
  210. photo.assert_not_awaited()
  211. sample.assert_not_called()
  212. async def test_ios_error_alert_includes_camera_photo_when_enabled():
  213. service = NotificationService()
  214. config = {"device_id": "IO12345678901234", "token": "secret"}
  215. provider = NotificationProvider(
  216. name="Notify", provider_type="notify", config=json.dumps(config), attach_photo=True, quiet_hours_enabled=False
  217. )
  218. with (
  219. patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send,
  220. patch.object(
  221. service, "_get_or_build_photo_url", new_callable=AsyncMock, return_value="https://example.com/camera.jpg"
  222. ) as photo,
  223. ):
  224. await service._send_to_provider(
  225. provider,
  226. "Printer error",
  227. "Filament runout",
  228. image_data=b"camera",
  229. event_type="printer_error",
  230. printer_id=42,
  231. )
  232. photo.assert_awaited_once()
  233. assert send.call_args.kwargs["image_url"] == "https://example.com/camera.jpg"
  234. async def test_notify_push_still_respects_quiet_hours():
  235. service = NotificationService()
  236. provider = NotificationProvider(name="Notify", provider_type="notify", config="{}")
  237. with (
  238. patch.object(service, "_is_in_quiet_hours", return_value=True),
  239. patch.object(service, "_send_notify", new_callable=AsyncMock) as send,
  240. ):
  241. result = await service._send_to_provider(provider, "Title", "Body")
  242. send.assert_not_awaited()
  243. assert result == (True, "Skipped - quiet hours")
  244. def test_provider_schema_accepts_notify():
  245. provider = NotificationProviderCreate(
  246. name="Notify", provider_type="notify", config={"device_id": "ABC12345", "token": "x"}
  247. )
  248. assert provider.provider_type == "notify"
  249. @pytest.mark.parametrize(
  250. ("event", "time_sensitive"),
  251. [
  252. ("print_failed", True),
  253. ("print_stopped", True),
  254. ("printer_error", True),
  255. ("print_complete", False),
  256. (None, False),
  257. ],
  258. )
  259. async def test_problem_alert_priority_and_per_printer_thread(event, time_sensitive):
  260. service = NotificationService()
  261. config = {"device_id": "ABC12345", "token": "secret", "time_sensitive": True}
  262. with patch.object(NotifyClient, "send_notification", new_callable=AsyncMock) as send:
  263. try:
  264. success, _ = await service._send_notify(config, "Title", "Body", event_type=event, printer_id=42)
  265. finally:
  266. await service.close()
  267. assert success
  268. assert send.call_args.kwargs["time_sensitive"] is time_sensitive
  269. assert send.call_args.kwargs["group_type"] == "bambuddy-printer-42"