test_notify_widget_client.py 9.5 KB

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