test_notify_provider_api.py 8.5 KB

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