test_notify_widget_client.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. """Wire contracts for persistent Notify Lock Screen widgets."""
  2. import json
  3. from unittest.mock import AsyncMock
  4. import httpx
  5. import pytest
  6. from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials
  7. async def test_widgets_create_explicitly_then_use_the_exact_saved_handle():
  8. requests = []
  9. widget = {
  10. "widgetId": "WG123456",
  11. "content": {"title": "X1C", "value": "62", "unit": "%", "progress": 62},
  12. "createdAt": "2026-10-07T00:00:00Z",
  13. "updatedAt": "2026-10-07T00:00:00Z",
  14. }
  15. def handle(request):
  16. requests.append(request)
  17. if request.method == "DELETE":
  18. return httpx.Response(200, json={"success": True, "deleted": True, "widgetId": "WG123456"})
  19. if request.method == "GET" and request.url.path.endswith("ABC12345"):
  20. return httpx.Response(200, json={"widgets": [widget]})
  21. return httpx.Response(201 if "new" in request.url.params else 200, json=widget)
  22. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  23. client = NotifyClient(http)
  24. assert await client.create_widget("ABC12345", "a+b&c", widget["content"]) == widget
  25. assert await client.update_widget("WG123456", "a+b&c", {"value": "Idle", "progress": None}) == widget
  26. assert await client.list_widgets("ABC12345", "a+b&c") == {"widgets": [widget]}
  27. assert (await client.delete_widget("WG123456", "a+b&c"))["deleted"] is True
  28. assert requests[0].url.path == "/widgets/ABC12345"
  29. assert requests[0].url.params["new"] == "1"
  30. assert requests[0].headers["content-type"] == "application/json"
  31. assert json.loads(requests[0].content) == widget["content"]
  32. assert requests[1].url.path == requests[3].url.path == "/widgets/WG123456"
  33. assert json.loads(requests[1].content) == {"value": "Idle", "progress": None}
  34. for request in requests[1:]:
  35. assert dict(request.url.params) == {"token": "a+b&c"}
  36. assert [r.method for r in requests] == ["POST", "POST", "GET", "DELETE"]
  37. assert not requests[2].content and not requests[3].content
  38. async def test_pasted_credentials_are_normalized_for_activity_and_widget_requests():
  39. requests = []
  40. def handle(request):
  41. requests.append(request)
  42. key, handle = (
  43. ("widgetId", "WG123456") if request.url.path.startswith("/widgets/") else ("activityId", "LA123456")
  44. )
  45. return httpx.Response(200, json={key: handle})
  46. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  47. client = NotifyClient(http)
  48. await client.start_activity(" IO12345678901234\n", " a+b&c\n", {"title": "X1C"})
  49. await client.update_activity("LA123456", " a+b&c\n", {"progress": 62})
  50. await client.create_widget(" IO12345678901234\n", " a+b&c\n", {"title": "X1C"})
  51. await client.update_widget("WG123456", " a+b&c\n", {"progress": 62})
  52. assert requests[0].url.path == "/live-activity/IO12345678901234"
  53. assert requests[2].url.path == "/widgets/IO12345678901234"
  54. assert all(request.url.params["token"] == "a+b&c" for request in requests)
  55. @pytest.mark.parametrize(("status", "body"), [(404, {}), (200, {"type": "group"}), (200, {"success": True})])
  56. async def test_ambiguous_la_id_cannot_update_an_unowned_activity(status, body):
  57. requests = []
  58. def handle(request):
  59. requests.append(request)
  60. return httpx.Response(status, json=body)
  61. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  62. with pytest.raises(NotifyError):
  63. await NotifyClient(http).start_activity(" LA123456 ", "secret", {"title": "X1C"})
  64. assert len(requests) == 1
  65. assert requests[0].method == "GET"
  66. assert requests[0].url.path == "/link"
  67. assert requests[0].url.params["id"] == "LA123456"
  68. async def test_legacy_la_prefixed_device_can_start_after_identity_verification():
  69. requests = []
  70. def handle(request):
  71. requests.append(request)
  72. if request.url.path == "/link":
  73. return httpx.Response(200, json={"type": "device", "id": "LA123456"})
  74. return httpx.Response(200, json={"activityId": "LA654321"})
  75. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  76. result = await NotifyClient(http).start_activity("LA123456", "secret", {"title": "X1C"})
  77. assert result["activityId"] == "LA654321"
  78. assert [r.method for r in requests] == ["GET", "POST"]
  79. assert requests[1].url.path == "/live-activity/LA123456"
  80. assert requests[1].url.params["new"] == "1"
  81. @pytest.mark.parametrize("method", ["update_widget", "delete_widget"])
  82. @pytest.mark.parametrize("widget_id", ["ABC12345", "WGbad123", "WG12345", "WG1234567", "../WG123456", None])
  83. async def test_widget_operations_reject_device_dialect_and_invalid_handles(method, widget_id):
  84. http = AsyncMock(spec=httpx.AsyncClient)
  85. args = (widget_id, "secret", {}) if method == "update_widget" else (widget_id, "secret")
  86. with pytest.raises(NotifyError, match="precise widget ID"):
  87. await getattr(NotifyClient(http), method)(*args)
  88. http.request.assert_not_called()
  89. @pytest.mark.parametrize("body", [{}, [], {"success": True}, {"widgetId": "ABC12345"}, {"widgetId": "bad/id"}])
  90. async def test_create_without_a_precise_handle_is_uncertain(body):
  91. async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(201, json=body))) as http:
  92. with pytest.raises(NotifyError) as error:
  93. await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
  94. assert error.value.delivery_state == "unknown"
  95. @pytest.mark.parametrize(
  96. "body",
  97. [{"success": True}, {"widgets": None}, {"widgets": {}}, {"widgets": [None]}, {"widgets": [{"widgetId": "bad"}]}],
  98. )
  99. async def test_malformed_widget_list_cannot_prove_that_owned_widget_was_deleted(body):
  100. async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
  101. with pytest.raises(NotifyError, match="invalid widget list"):
  102. await NotifyClient(http).list_widgets("ABC12345", "secret")
  103. async def test_empty_authenticated_widget_list_is_valid():
  104. async with httpx.AsyncClient(
  105. transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"widgets": []}))
  106. ) as http:
  107. assert await NotifyClient(http).list_widgets("ABC12345", "secret") == {"widgets": []}
  108. @pytest.mark.parametrize(
  109. ("status", "body", "delivery", "widget_id", "retry"),
  110. [
  111. (500, {}, "unknown", None, None),
  112. (502, {"widgetId": "WG123456"}, "unknown", "WG123456", None),
  113. (503, {}, None, None, None),
  114. (429, {"retryAfterSeconds": 180}, None, None, 180),
  115. (502, {"deliveryState": "not-delivered", "retryAfterSeconds": 60}, "not-delivered", None, 60),
  116. (400, {"message": "This device already has the maximum of 10 widgets. Delete one first."}, None, None, None),
  117. (403, {}, None, None, None),
  118. ],
  119. )
  120. async def test_create_preserves_recovery_hints_without_retrying(status, body, delivery, widget_id, retry):
  121. requests = []
  122. def handle(request):
  123. requests.append(request)
  124. return httpx.Response(status, json={**body, "private": "secret"})
  125. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  126. with pytest.raises(NotifyError) as error:
  127. await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
  128. assert len(requests) == 1
  129. assert error.value.status_code == status
  130. assert error.value.delivery_state == delivery
  131. assert error.value.widget_id == widget_id
  132. assert error.value.retry_after_seconds == retry
  133. assert "secret" not in str(error.value)
  134. assert "Live Activity" not in str(error.value)
  135. if status == 400:
  136. assert "ten widget limit" in str(error.value)
  137. async def test_widget_create_timeout_is_uncertain_and_does_not_expose_token():
  138. def handle(request):
  139. raise httpx.ReadTimeout(f"Timed out: {request.url}", request=request)
  140. async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
  141. with pytest.raises(NotifyError) as error:
  142. await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
  143. assert error.value.delivery_state == "unknown"
  144. assert "secret" not in str(error.value)
  145. assert error.value.__suppress_context__
  146. @pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "MC12345678901234"])
  147. def test_bambuddy_widgets_require_ios_while_push_remains_available(device_id):
  148. config = {"device_id": device_id, "token": "secret", "lock_screen_widgets": True}
  149. with pytest.raises(NotifyError, match="Lock Screen widgets require an iOS device ID"):
  150. notify_credentials(config)
  151. config["lock_screen_widgets"] = False
  152. assert notify_credentials(config) == (device_id, "secret")
  153. @pytest.mark.parametrize("value", ["true", "false", 1, 0, None, []])
  154. def test_widget_switch_requires_a_boolean(value):
  155. with pytest.raises(NotifyError, match="lock_screen_widgets must be a boolean"):
  156. notify_credentials({"device_id": "ABC12345", "token": "secret", "lock_screen_widgets": value})
  157. @pytest.mark.parametrize("device_id", ["ABC12345", "IO12345678901234"])
  158. def test_widgets_can_be_enabled_independently_of_live_activities(device_id):
  159. assert notify_credentials(
  160. {"device_id": device_id, "token": "secret", "lock_screen_widgets": True, "live_activities": False}
  161. ) == (device_id, "secret")