test_notify_provider_api.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Notify configuration validation and lifecycle cleanup through the public API."""
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
  5. from backend.app.services.notify_live_activities import _credential_key
  6. CONFIG = {
  7. "device_id": "IO12345678901234",
  8. "token": "example-token",
  9. "live_activities": True,
  10. "lock_screen_widgets": True,
  11. }
  12. # Responses show the token as the placeholder (write-only secret).
  13. SHOWN = {**CONFIG, "token": "********"}
  14. async def create_provider(client):
  15. response = await client.post(
  16. "/api/v1/notifications/", json={"name": "Notify", "provider_type": "notify", "config": CONFIG}
  17. )
  18. assert response.status_code == 200
  19. return response.json()["id"]
  20. async def test_notify_settings_round_trip_and_push_flags_remain_independent(async_client):
  21. provider_id = await create_provider(async_client)
  22. response = await async_client.get(f"/api/v1/notifications/{provider_id}")
  23. assert response.json()["config"] == SHOWN
  24. assert response.json()["on_print_start"] is False
  25. assert response.json()["on_print_progress"] is False
  26. async def test_notify_crud_refreshes_worker_configuration_without_remote_work(async_client):
  27. with (
  28. patch("backend.app.api.routes.notifications.notify_live_activities.providers_changed") as live_changed,
  29. patch("backend.app.api.routes.notifications.notify_widgets.providers_changed") as widgets_changed,
  30. patch("backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock),
  31. patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
  32. patch(
  33. "backend.app.api.routes.notifications.notify_live_activities.cleanup_provider", new_callable=AsyncMock
  34. ) as live_http,
  35. patch(
  36. "backend.app.api.routes.notifications.notify_widgets.cleanup_provider", new_callable=AsyncMock
  37. ) as widget_http,
  38. ):
  39. provider_id = await create_provider(async_client)
  40. assert live_changed.call_count == widgets_changed.call_count == 1
  41. response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"enabled": False})
  42. assert response.status_code == 200
  43. assert live_changed.call_count == widgets_changed.call_count == 2
  44. response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
  45. assert response.status_code == 200
  46. assert live_changed.call_count == widgets_changed.call_count == 3
  47. live_http.assert_not_awaited()
  48. widget_http.assert_not_awaited()
  49. @pytest.mark.parametrize(
  50. "config",
  51. [
  52. {},
  53. {**CONFIG, "device_id": "GRP12345"},
  54. {**CONFIG, "device_id": "WB12345678901234"},
  55. {**CONFIG, "live_activities": "false"},
  56. {**CONFIG, "live_activities": False, "lock_screen_widgets": "false"},
  57. {**CONFIG, "device_id": "GRP12345", "live_activities": False},
  58. {**CONFIG, "device_id": "WB12345678901234", "live_activities": False},
  59. ],
  60. )
  61. async def test_invalid_notify_configuration_cannot_be_created(async_client, config):
  62. response = await async_client.post(
  63. "/api/v1/notifications/", json={"name": "Notify", "provider_type": "notify", "config": config}
  64. )
  65. assert response.status_code == 422
  66. async def test_empty_config_update_is_rejected_and_keeps_previous_credentials(async_client):
  67. provider_id = await create_provider(async_client)
  68. response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"config": {}})
  69. assert response.status_code == 422
  70. saved = await async_client.get(f"/api/v1/notifications/{provider_id}")
  71. assert saved.json()["config"] == SHOWN
  72. async def test_credential_change_queues_old_device_cleanup_with_old_credentials(async_client):
  73. provider_id = await create_provider(async_client)
  74. with (
  75. patch(
  76. "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
  77. ) as cleanup,
  78. patch(
  79. "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
  80. ) as widget_cleanup,
  81. ):
  82. response = await async_client.patch(
  83. f"/api/v1/notifications/{provider_id}", json={"config": {**CONFIG, "token": "replacement-token"}}
  84. )
  85. assert response.status_code == 200
  86. cleanup.assert_awaited_once_with(provider_id, CONFIG)
  87. widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
  88. assert response.json()["config"]["token"] == "********"
  89. async def test_disabling_or_changing_push_thread_leaves_cleanup_to_durable_worker(async_client):
  90. provider_id = await create_provider(async_client)
  91. with (
  92. patch(
  93. "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
  94. ) as cleanup,
  95. patch(
  96. "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
  97. ) as widget_cleanup,
  98. ):
  99. response = await async_client.patch(
  100. f"/api/v1/notifications/{provider_id}",
  101. json={"enabled": False, "config": {**CONFIG, "group_type": "workshop"}},
  102. )
  103. assert response.status_code == 200
  104. cleanup.assert_not_awaited()
  105. widget_cleanup.assert_not_awaited()
  106. async def test_delete_queues_cleanup_before_forgetting_credentials(async_client):
  107. provider_id = await create_provider(async_client)
  108. with (
  109. patch(
  110. "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
  111. ) as cleanup,
  112. patch(
  113. "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
  114. ) as widget_cleanup,
  115. ):
  116. response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
  117. assert response.status_code == 200
  118. cleanup.assert_awaited_once_with(provider_id, CONFIG)
  119. widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
  120. assert (await async_client.get(f"/api/v1/notifications/{provider_id}")).status_code == 404
  121. async def test_lock_screen_widgets_can_be_enabled_without_live_activities_or_push_events(async_client):
  122. config = {**CONFIG, "live_activities": False}
  123. response = await async_client.post(
  124. "/api/v1/notifications/",
  125. json={
  126. "name": "Printer widget",
  127. "provider_type": "notify",
  128. "config": config,
  129. "on_print_start": False,
  130. "on_print_complete": False,
  131. "on_print_failed": False,
  132. },
  133. )
  134. assert response.status_code == 200
  135. assert response.json()["config"] == {**config, "token": "********"}
  136. assert response.json()["on_print_start"] is False
  137. assert response.json()["on_print_complete"] is False
  138. assert response.json()["on_print_failed"] is False
  139. async def test_switching_provider_type_cleans_up_both_owned_notify_resources(async_client):
  140. provider_id = await create_provider(async_client)
  141. with (
  142. patch(
  143. "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
  144. ) as cleanup,
  145. patch(
  146. "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
  147. ) as widget_cleanup,
  148. ):
  149. response = await async_client.patch(
  150. f"/api/v1/notifications/{provider_id}",
  151. json={"provider_type": "ntfy", "config": {"topic": "prints"}},
  152. )
  153. assert response.status_code == 200
  154. cleanup.assert_awaited_once_with(provider_id, CONFIG)
  155. widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
  156. @pytest.mark.parametrize("disable", [{"config": {**CONFIG, "lock_screen_widgets": False}}, {"enabled": False}])
  157. async def test_quick_widget_disable_and_reenable_preserves_cleanup_intent(async_client, db_session, disable):
  158. provider_id = await create_provider(async_client)
  159. row = NotificationLockScreenWidget(
  160. provider_id=provider_id,
  161. printer_id=42,
  162. credential_key=_credential_key(CONFIG),
  163. widget_id="WG123456",
  164. state="active",
  165. content="{}",
  166. )
  167. db_session.add(row)
  168. await db_session.commit()
  169. response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json=disable)
  170. assert response.status_code == 200
  171. await db_session.refresh(row)
  172. assert row.state == "deleting"
  173. response = await async_client.patch(
  174. f"/api/v1/notifications/{provider_id}", json={"config": CONFIG, "enabled": True}
  175. )
  176. assert response.status_code == 200
  177. await db_session.refresh(row)
  178. assert row.state == "deleting"
  179. assert row.widget_id == "WG123456"