test_notifications_api.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. """Integration tests for Notifications API endpoints.
  2. Tests the full request/response cycle for /api/v1/notifications/ endpoints.
  3. """
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestNotificationsAPI:
  7. """Integration tests for /api/v1/notifications/ endpoints."""
  8. # ========================================================================
  9. # List endpoints
  10. # ========================================================================
  11. @pytest.mark.asyncio
  12. @pytest.mark.integration
  13. async def test_list_notification_providers_empty(
  14. self, async_client: AsyncClient
  15. ):
  16. """Verify empty list is returned when no providers exist."""
  17. response = await async_client.get("/api/v1/notifications/")
  18. assert response.status_code == 200
  19. assert response.json() == []
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_list_notification_providers_with_data(
  23. self, async_client: AsyncClient, notification_provider_factory, db_session
  24. ):
  25. """Verify list returns existing providers."""
  26. provider = await notification_provider_factory(name="Test Provider")
  27. response = await async_client.get("/api/v1/notifications/")
  28. assert response.status_code == 200
  29. data = response.json()
  30. assert len(data) >= 1
  31. assert any(p["name"] == "Test Provider" for p in data)
  32. # ========================================================================
  33. # Create endpoints
  34. # ========================================================================
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_create_callmebot_provider(self, async_client: AsyncClient):
  38. """Verify callmebot notification provider can be created."""
  39. data = {
  40. "name": "Test CallMeBot",
  41. "provider_type": "callmebot",
  42. "enabled": True,
  43. "config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
  44. "on_print_start": True,
  45. "on_print_complete": True,
  46. "on_print_failed": True,
  47. "on_print_stopped": False,
  48. }
  49. response = await async_client.post("/api/v1/notifications/", json=data)
  50. assert response.status_code == 200
  51. result = response.json()
  52. assert result["name"] == "Test CallMeBot"
  53. assert result["provider_type"] == "callmebot"
  54. assert result["on_print_start"] is True
  55. assert result["on_print_stopped"] is False
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_create_ntfy_provider(self, async_client: AsyncClient):
  59. """Verify ntfy notification provider can be created."""
  60. data = {
  61. "name": "Test Ntfy",
  62. "provider_type": "ntfy",
  63. "enabled": True,
  64. "config": {
  65. "server": "https://ntfy.sh",
  66. "topic": "test-topic",
  67. },
  68. "on_print_complete": True,
  69. }
  70. response = await async_client.post("/api/v1/notifications/", json=data)
  71. assert response.status_code == 200
  72. result = response.json()
  73. assert result["provider_type"] == "ntfy"
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. async def test_create_provider_with_printer(
  77. self, async_client: AsyncClient, printer_factory, db_session
  78. ):
  79. """Verify provider can be linked to specific printer."""
  80. printer = await printer_factory(name="Test Printer")
  81. data = {
  82. "name": "Printer Ntfy",
  83. "provider_type": "ntfy",
  84. "config": {"server": "https://ntfy.sh", "topic": "test-topic"},
  85. "printer_id": printer.id,
  86. }
  87. response = await async_client.post("/api/v1/notifications/", json=data)
  88. assert response.status_code == 200
  89. result = response.json()
  90. assert result["printer_id"] == printer.id
  91. # ========================================================================
  92. # Get single endpoint
  93. # ========================================================================
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_get_notification_provider(
  97. self, async_client: AsyncClient, notification_provider_factory, db_session
  98. ):
  99. """Verify single provider can be retrieved."""
  100. provider = await notification_provider_factory(name="Get Test Provider")
  101. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  102. assert response.status_code == 200
  103. result = response.json()
  104. assert result["id"] == provider.id
  105. assert result["name"] == "Get Test Provider"
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_get_provider_not_found(self, async_client: AsyncClient):
  109. """Verify 404 for non-existent provider."""
  110. response = await async_client.get("/api/v1/notifications/9999")
  111. assert response.status_code == 404
  112. # ========================================================================
  113. # Update endpoints (CRITICAL - toggle persistence)
  114. # ========================================================================
  115. @pytest.mark.asyncio
  116. @pytest.mark.integration
  117. async def test_update_event_toggles(
  118. self, async_client: AsyncClient, notification_provider_factory, db_session
  119. ):
  120. """CRITICAL: Verify notification event toggles persist correctly."""
  121. provider = await notification_provider_factory(
  122. on_print_start=True,
  123. on_print_complete=True,
  124. on_print_stopped=False,
  125. )
  126. # Toggle on_print_stopped to True
  127. response = await async_client.patch(
  128. f"/api/v1/notifications/{provider.id}",
  129. json={"on_print_stopped": True}
  130. )
  131. assert response.status_code == 200
  132. assert response.json()["on_print_stopped"] is True
  133. # Verify change persisted
  134. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  135. assert response.json()["on_print_stopped"] is True
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_update_ams_alarm_toggles(
  139. self, async_client: AsyncClient, notification_provider_factory, db_session
  140. ):
  141. """CRITICAL: Verify AMS alarm toggles persist correctly."""
  142. provider = await notification_provider_factory(
  143. on_ams_humidity_high=False,
  144. on_ams_temperature_high=False,
  145. )
  146. # Enable AMS alarms
  147. response = await async_client.patch(
  148. f"/api/v1/notifications/{provider.id}",
  149. json={
  150. "on_ams_humidity_high": True,
  151. "on_ams_temperature_high": True,
  152. }
  153. )
  154. assert response.status_code == 200
  155. result = response.json()
  156. assert result["on_ams_humidity_high"] is True
  157. assert result["on_ams_temperature_high"] is True
  158. # Verify persistence
  159. response = await async_client.get(f"/api/v1/notifications/{provider.id}")
  160. result = response.json()
  161. assert result["on_ams_humidity_high"] is True
  162. assert result["on_ams_temperature_high"] is True
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_enable_disable_provider(
  166. self, async_client: AsyncClient, notification_provider_factory, db_session
  167. ):
  168. """Verify provider can be enabled/disabled."""
  169. provider = await notification_provider_factory(enabled=True)
  170. # Disable
  171. response = await async_client.patch(
  172. f"/api/v1/notifications/{provider.id}",
  173. json={"enabled": False}
  174. )
  175. assert response.status_code == 200
  176. assert response.json()["enabled"] is False
  177. # Enable
  178. response = await async_client.patch(
  179. f"/api/v1/notifications/{provider.id}",
  180. json={"enabled": True}
  181. )
  182. assert response.status_code == 200
  183. assert response.json()["enabled"] is True
  184. @pytest.mark.asyncio
  185. @pytest.mark.integration
  186. async def test_update_quiet_hours(
  187. self, async_client: AsyncClient, notification_provider_factory, db_session
  188. ):
  189. """Verify quiet hours can be configured."""
  190. provider = await notification_provider_factory(quiet_hours_enabled=False)
  191. response = await async_client.patch(
  192. f"/api/v1/notifications/{provider.id}",
  193. json={
  194. "quiet_hours_enabled": True,
  195. "quiet_hours_start": "22:00",
  196. "quiet_hours_end": "07:00",
  197. }
  198. )
  199. assert response.status_code == 200
  200. result = response.json()
  201. assert result["quiet_hours_enabled"] is True
  202. assert result["quiet_hours_start"] == "22:00"
  203. assert result["quiet_hours_end"] == "07:00"
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_update_daily_digest(
  207. self, async_client: AsyncClient, notification_provider_factory, db_session
  208. ):
  209. """Verify daily digest can be configured."""
  210. provider = await notification_provider_factory(daily_digest_enabled=False)
  211. response = await async_client.patch(
  212. f"/api/v1/notifications/{provider.id}",
  213. json={
  214. "daily_digest_enabled": True,
  215. "daily_digest_time": "09:00",
  216. }
  217. )
  218. assert response.status_code == 200
  219. result = response.json()
  220. assert result["daily_digest_enabled"] is True
  221. assert result["daily_digest_time"] == "09:00"
  222. @pytest.mark.asyncio
  223. @pytest.mark.integration
  224. async def test_update_multiple_event_toggles(
  225. self, async_client: AsyncClient, notification_provider_factory, db_session
  226. ):
  227. """Verify multiple event toggles can be updated at once."""
  228. provider = await notification_provider_factory(
  229. on_print_start=True,
  230. on_print_complete=True,
  231. on_print_failed=True,
  232. on_print_stopped=False,
  233. on_printer_offline=False,
  234. )
  235. response = await async_client.patch(
  236. f"/api/v1/notifications/{provider.id}",
  237. json={
  238. "on_print_start": False,
  239. "on_print_stopped": True,
  240. "on_printer_offline": True,
  241. }
  242. )
  243. assert response.status_code == 200
  244. result = response.json()
  245. assert result["on_print_start"] is False
  246. assert result["on_print_stopped"] is True
  247. assert result["on_printer_offline"] is True
  248. # Unchanged fields should remain
  249. assert result["on_print_complete"] is True
  250. assert result["on_print_failed"] is True
  251. # ========================================================================
  252. # Test notification endpoint
  253. # ========================================================================
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_test_notification(
  257. self, async_client: AsyncClient, notification_provider_factory,
  258. mock_httpx_client, db_session
  259. ):
  260. """Verify test notification can be sent."""
  261. provider = await notification_provider_factory()
  262. response = await async_client.post(
  263. f"/api/v1/notifications/{provider.id}/test"
  264. )
  265. assert response.status_code == 200
  266. result = response.json()
  267. assert result["success"] is True
  268. @pytest.mark.asyncio
  269. @pytest.mark.integration
  270. async def test_test_notification_disabled_provider(
  271. self, async_client: AsyncClient, notification_provider_factory, db_session
  272. ):
  273. """Verify test notification works even for disabled provider."""
  274. provider = await notification_provider_factory(enabled=False)
  275. response = await async_client.post(
  276. f"/api/v1/notifications/{provider.id}/test"
  277. )
  278. # Test should still work for disabled providers
  279. assert response.status_code == 200
  280. # ========================================================================
  281. # Delete endpoint
  282. # ========================================================================
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_delete_notification_provider(
  286. self, async_client: AsyncClient, notification_provider_factory, db_session
  287. ):
  288. """Verify notification provider can be deleted."""
  289. provider = await notification_provider_factory()
  290. provider_id = provider.id
  291. response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
  292. assert response.status_code == 200
  293. # Verify deleted
  294. response = await async_client.get(f"/api/v1/notifications/{provider_id}")
  295. assert response.status_code == 404
  296. @pytest.mark.asyncio
  297. @pytest.mark.integration
  298. async def test_delete_nonexistent_provider(self, async_client: AsyncClient):
  299. """Verify deleting non-existent provider returns 404."""
  300. response = await async_client.delete("/api/v1/notifications/9999")
  301. assert response.status_code == 404
  302. class TestNotificationTemplatesAPI:
  303. """Integration tests for /api/v1/notification-templates/ endpoints."""
  304. @pytest.fixture
  305. async def seeded_templates(self, db_session):
  306. """Seed notification templates for tests."""
  307. from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
  308. templates = []
  309. for template_data in DEFAULT_TEMPLATES:
  310. template = NotificationTemplate(**template_data)
  311. db_session.add(template)
  312. templates.append(template)
  313. await db_session.commit()
  314. for template in templates:
  315. await db_session.refresh(template)
  316. return templates
  317. @pytest.mark.asyncio
  318. @pytest.mark.integration
  319. async def test_list_templates(self, async_client: AsyncClient, seeded_templates):
  320. """Verify default templates are seeded and can be listed."""
  321. response = await async_client.get("/api/v1/notification-templates/")
  322. assert response.status_code == 200
  323. templates = response.json()
  324. # Should have default templates seeded
  325. assert len(templates) >= 1
  326. @pytest.mark.asyncio
  327. @pytest.mark.integration
  328. async def test_get_template_by_id(self, async_client: AsyncClient, seeded_templates):
  329. """Verify template can be retrieved by ID."""
  330. # Get first template ID from seeded data
  331. template_id = seeded_templates[0].id
  332. response = await async_client.get(
  333. f"/api/v1/notification-templates/{template_id}"
  334. )
  335. assert response.status_code == 200
  336. template = response.json()
  337. assert template["id"] == template_id
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_update_template(self, async_client: AsyncClient, seeded_templates):
  341. """Verify template can be updated."""
  342. # Get first template
  343. template_id = seeded_templates[0].id
  344. # Update it (route uses PUT, not PATCH)
  345. response = await async_client.put(
  346. f"/api/v1/notification-templates/{template_id}",
  347. json={
  348. "title_template": "Custom Title: {printer}",
  349. "body_template": "Custom body for {filename}",
  350. }
  351. )
  352. assert response.status_code == 200
  353. result = response.json()
  354. assert result["title_template"] == "Custom Title: {printer}"
  355. assert result["body_template"] == "Custom body for {filename}"
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_reset_template_to_default(self, async_client: AsyncClient, seeded_templates):
  359. """Verify template can be reset to default."""
  360. template_id = seeded_templates[0].id
  361. response = await async_client.post(
  362. f"/api/v1/notification-templates/{template_id}/reset"
  363. )
  364. assert response.status_code == 200
  365. result = response.json()
  366. assert result["is_default"] is True