test_notification_service.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. """Unit tests for NotificationService.
  2. Tests event-based notifications and toggle behavior.
  3. """
  4. import pytest
  5. import json
  6. from datetime import datetime
  7. from unittest.mock import AsyncMock, MagicMock, patch
  8. from backend.app.services.notification_service import NotificationService
  9. class TestNotificationService:
  10. """Tests for NotificationService class."""
  11. @pytest.fixture
  12. def service(self):
  13. """Create a fresh NotificationService instance."""
  14. return NotificationService()
  15. @pytest.fixture
  16. def mock_provider(self):
  17. """Create a mock notification provider."""
  18. provider = MagicMock()
  19. provider.id = 1
  20. provider.name = "Test Provider"
  21. provider.provider_type = "webhook"
  22. provider.enabled = True
  23. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  24. provider.on_print_start = True
  25. provider.on_print_complete = True
  26. provider.on_print_failed = True
  27. provider.on_print_stopped = False
  28. provider.on_print_progress = False
  29. provider.on_printer_offline = False
  30. provider.on_printer_error = False
  31. provider.on_filament_low = False
  32. provider.on_maintenance_due = False
  33. provider.on_ams_humidity_high = False
  34. provider.on_ams_temperature_high = False
  35. provider.quiet_hours_enabled = False
  36. provider.quiet_hours_start = None
  37. provider.quiet_hours_end = None
  38. provider.daily_digest_enabled = False
  39. provider.daily_digest_time = None
  40. provider.printer_id = None
  41. return provider
  42. @pytest.fixture
  43. def mock_db(self):
  44. """Create a mock database session."""
  45. db = AsyncMock()
  46. db.commit = AsyncMock()
  47. return db
  48. # ========================================================================
  49. # Tests for on_print_start
  50. # ========================================================================
  51. @pytest.mark.asyncio
  52. async def test_on_print_start_sends_notification(
  53. self, service, mock_provider, mock_db
  54. ):
  55. """Verify notification is sent when print starts."""
  56. with patch.object(
  57. service, '_get_providers_for_event', new_callable=AsyncMock
  58. ) as mock_get, \
  59. patch.object(
  60. service, '_send_to_providers', new_callable=AsyncMock
  61. ) as mock_send, \
  62. patch.object(
  63. service, '_build_message_from_template', new_callable=AsyncMock
  64. ) as mock_build:
  65. mock_get.return_value = [mock_provider]
  66. mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
  67. await service.on_print_start(
  68. printer_id=1,
  69. printer_name="Test Printer",
  70. data={"filename": "test.3mf", "subtask_name": "test"},
  71. db=mock_db,
  72. )
  73. mock_get.assert_called_once()
  74. mock_send.assert_called_once()
  75. @pytest.mark.asyncio
  76. async def test_on_print_start_skipped_when_no_providers(
  77. self, service, mock_db
  78. ):
  79. """Verify no error when no providers are configured for event."""
  80. with patch.object(
  81. service, '_get_providers_for_event', new_callable=AsyncMock
  82. ) as mock_get, \
  83. patch.object(
  84. service, '_send_to_providers', new_callable=AsyncMock
  85. ) as mock_send:
  86. mock_get.return_value = []
  87. await service.on_print_start(
  88. printer_id=1,
  89. printer_name="Test Printer",
  90. data={},
  91. db=mock_db,
  92. )
  93. mock_send.assert_not_called()
  94. # ========================================================================
  95. # Tests for on_print_complete (status routing)
  96. # ========================================================================
  97. @pytest.mark.asyncio
  98. async def test_on_print_complete_routes_completed_status(
  99. self, service, mock_provider, mock_db
  100. ):
  101. """Verify completed status uses on_print_complete field."""
  102. with patch.object(
  103. service, '_get_providers_for_event', new_callable=AsyncMock
  104. ) as mock_get, \
  105. patch.object(
  106. service, '_send_to_providers', new_callable=AsyncMock
  107. ), \
  108. patch.object(
  109. service, '_build_message_from_template', new_callable=AsyncMock
  110. ) as mock_build:
  111. mock_get.return_value = [mock_provider]
  112. mock_build.return_value = ("Test", "Test")
  113. await service.on_print_complete(
  114. printer_id=1,
  115. printer_name="Test",
  116. status="completed",
  117. data={},
  118. db=mock_db,
  119. )
  120. # Verify the correct event field was queried
  121. call_args = mock_get.call_args
  122. assert call_args[0][1] == "on_print_complete"
  123. @pytest.mark.asyncio
  124. async def test_on_print_complete_routes_failed_status(
  125. self, service, mock_provider, mock_db
  126. ):
  127. """Verify failed status uses on_print_failed field."""
  128. with patch.object(
  129. service, '_get_providers_for_event', new_callable=AsyncMock
  130. ) as mock_get, \
  131. patch.object(
  132. service, '_send_to_providers', new_callable=AsyncMock
  133. ), \
  134. patch.object(
  135. service, '_build_message_from_template', new_callable=AsyncMock
  136. ) as mock_build:
  137. mock_get.return_value = [mock_provider]
  138. mock_build.return_value = ("Test", "Test")
  139. await service.on_print_complete(
  140. printer_id=1,
  141. printer_name="Test",
  142. status="failed",
  143. data={},
  144. db=mock_db,
  145. )
  146. call_args = mock_get.call_args
  147. assert call_args[0][1] == "on_print_failed"
  148. @pytest.mark.asyncio
  149. async def test_on_print_complete_routes_stopped_status(
  150. self, service, mock_provider, mock_db
  151. ):
  152. """Verify stopped status uses on_print_stopped field."""
  153. with patch.object(
  154. service, '_get_providers_for_event', new_callable=AsyncMock
  155. ) as mock_get, \
  156. patch.object(
  157. service, '_send_to_providers', new_callable=AsyncMock
  158. ), \
  159. patch.object(
  160. service, '_build_message_from_template', new_callable=AsyncMock
  161. ) as mock_build:
  162. mock_get.return_value = [mock_provider]
  163. mock_build.return_value = ("Test", "Test")
  164. await service.on_print_complete(
  165. printer_id=1,
  166. printer_name="Test",
  167. status="stopped",
  168. data={},
  169. db=mock_db,
  170. )
  171. call_args = mock_get.call_args
  172. assert call_args[0][1] == "on_print_stopped"
  173. @pytest.mark.asyncio
  174. async def test_on_print_complete_routes_aborted_status(
  175. self, service, mock_provider, mock_db
  176. ):
  177. """Verify aborted status uses on_print_stopped field."""
  178. with patch.object(
  179. service, '_get_providers_for_event', new_callable=AsyncMock
  180. ) as mock_get, \
  181. patch.object(
  182. service, '_send_to_providers', new_callable=AsyncMock
  183. ), \
  184. patch.object(
  185. service, '_build_message_from_template', new_callable=AsyncMock
  186. ) as mock_build:
  187. mock_get.return_value = [mock_provider]
  188. mock_build.return_value = ("Test", "Test")
  189. await service.on_print_complete(
  190. printer_id=1,
  191. printer_name="Test",
  192. status="aborted",
  193. data={},
  194. db=mock_db,
  195. )
  196. call_args = mock_get.call_args
  197. assert call_args[0][1] == "on_print_stopped"
  198. # ========================================================================
  199. # Tests for provider filtering
  200. # ========================================================================
  201. @pytest.mark.asyncio
  202. async def test_disabled_provider_not_returned(self, service, mock_provider, mock_db):
  203. """CRITICAL: Verify disabled providers don't receive notifications."""
  204. mock_provider.enabled = False
  205. # The actual filtering happens in _get_providers_for_event
  206. # which queries only enabled providers
  207. with patch.object(
  208. service, '_get_providers_for_event', new_callable=AsyncMock
  209. ) as mock_get:
  210. # Simulate the query filtering out disabled providers
  211. mock_get.return_value = []
  212. result = await service._get_providers_for_event(
  213. mock_db, "on_print_start", printer_id=1
  214. )
  215. assert len(result) == 0
  216. @pytest.mark.asyncio
  217. async def test_provider_filtered_by_printer_id(self, service, mock_provider, mock_db):
  218. """Verify providers can be filtered by specific printer."""
  219. mock_provider.printer_id = 2 # Linked to printer 2
  220. with patch.object(
  221. service, '_get_providers_for_event', new_callable=AsyncMock
  222. ) as mock_get:
  223. # When querying for printer 1, provider linked to printer 2 is excluded
  224. mock_get.return_value = []
  225. result = await service._get_providers_for_event(
  226. mock_db, "on_print_start", printer_id=1
  227. )
  228. assert len(result) == 0
  229. # ========================================================================
  230. # Tests for quiet hours
  231. # ========================================================================
  232. def test_is_in_quiet_hours_during_quiet_period(self, service, mock_provider):
  233. """Verify notifications are blocked during quiet hours."""
  234. mock_provider.quiet_hours_enabled = True
  235. mock_provider.quiet_hours_start = "22:00"
  236. mock_provider.quiet_hours_end = "07:00"
  237. with patch(
  238. 'backend.app.services.notification_service.datetime'
  239. ) as mock_datetime:
  240. # Test during quiet hours (23:00)
  241. mock_now = MagicMock()
  242. mock_now.hour = 23
  243. mock_now.minute = 0
  244. mock_datetime.now.return_value = mock_now
  245. result = service._is_in_quiet_hours(mock_provider)
  246. assert result is True
  247. def test_is_in_quiet_hours_outside_quiet_period(self, service, mock_provider):
  248. """Verify notifications are allowed outside quiet hours."""
  249. mock_provider.quiet_hours_enabled = True
  250. mock_provider.quiet_hours_start = "22:00"
  251. mock_provider.quiet_hours_end = "07:00"
  252. with patch(
  253. 'backend.app.services.notification_service.datetime'
  254. ) as mock_datetime:
  255. # Test outside quiet hours (12:00)
  256. mock_now = MagicMock()
  257. mock_now.hour = 12
  258. mock_now.minute = 0
  259. mock_datetime.now.return_value = mock_now
  260. result = service._is_in_quiet_hours(mock_provider)
  261. assert result is False
  262. def test_is_in_quiet_hours_disabled(self, service, mock_provider):
  263. """Verify quiet hours check returns False when disabled."""
  264. mock_provider.quiet_hours_enabled = False
  265. result = service._is_in_quiet_hours(mock_provider)
  266. assert result is False
  267. def test_is_in_quiet_hours_early_morning(self, service, mock_provider):
  268. """Verify quiet hours work across midnight (early morning)."""
  269. mock_provider.quiet_hours_enabled = True
  270. mock_provider.quiet_hours_start = "22:00"
  271. mock_provider.quiet_hours_end = "07:00"
  272. with patch(
  273. 'backend.app.services.notification_service.datetime'
  274. ) as mock_datetime:
  275. # Test early morning (03:00) - should be in quiet hours
  276. mock_now = MagicMock()
  277. mock_now.hour = 3
  278. mock_now.minute = 0
  279. mock_datetime.now.return_value = mock_now
  280. result = service._is_in_quiet_hours(mock_provider)
  281. assert result is True
  282. # ========================================================================
  283. # Tests for AMS alarms
  284. # ========================================================================
  285. @pytest.mark.asyncio
  286. async def test_on_ams_humidity_high_sends_notification(
  287. self, service, mock_provider, mock_db
  288. ):
  289. """Verify AMS humidity alarm sends notification."""
  290. mock_provider.on_ams_humidity_high = True
  291. with patch.object(
  292. service, '_get_providers_for_event', new_callable=AsyncMock
  293. ) as mock_get, \
  294. patch.object(
  295. service, '_send_to_providers', new_callable=AsyncMock
  296. ) as mock_send, \
  297. patch.object(
  298. service, '_build_message_from_template', new_callable=AsyncMock
  299. ) as mock_build:
  300. mock_get.return_value = [mock_provider]
  301. mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
  302. await service.on_ams_humidity_high(
  303. printer_id=1,
  304. printer_name="Test Printer",
  305. ams_label="AMS-A",
  306. humidity=75.0,
  307. threshold=60.0,
  308. db=mock_db,
  309. )
  310. mock_send.assert_called_once()
  311. # Verify force_immediate is True for alarms
  312. call_kwargs = mock_send.call_args[1]
  313. assert call_kwargs.get('force_immediate') is True
  314. @pytest.mark.asyncio
  315. async def test_on_ams_temperature_high_sends_notification(
  316. self, service, mock_provider, mock_db
  317. ):
  318. """Verify AMS temperature alarm sends notification."""
  319. mock_provider.on_ams_temperature_high = True
  320. with patch.object(
  321. service, '_get_providers_for_event', new_callable=AsyncMock
  322. ) as mock_get, \
  323. patch.object(
  324. service, '_send_to_providers', new_callable=AsyncMock
  325. ) as mock_send, \
  326. patch.object(
  327. service, '_build_message_from_template', new_callable=AsyncMock
  328. ) as mock_build:
  329. mock_get.return_value = [mock_provider]
  330. mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
  331. await service.on_ams_temperature_high(
  332. printer_id=1,
  333. printer_name="Test Printer",
  334. ams_label="AMS-A",
  335. temperature=40.0,
  336. threshold=35.0,
  337. db=mock_db,
  338. )
  339. mock_send.assert_called_once()
  340. # Verify force_immediate is True for alarms
  341. call_kwargs = mock_send.call_args[1]
  342. assert call_kwargs.get('force_immediate') is True
  343. @pytest.mark.asyncio
  344. async def test_ams_alarm_skipped_when_toggle_disabled(
  345. self, service, mock_provider, mock_db
  346. ):
  347. """CRITICAL: Verify AMS alarms respect toggle setting."""
  348. mock_provider.on_ams_humidity_high = False
  349. with patch.object(
  350. service, '_get_providers_for_event', new_callable=AsyncMock
  351. ) as mock_get, \
  352. patch.object(
  353. service, '_send_to_providers', new_callable=AsyncMock
  354. ) as mock_send:
  355. # Provider with toggle disabled won't be returned
  356. mock_get.return_value = []
  357. await service.on_ams_humidity_high(
  358. printer_id=1,
  359. printer_name="Test",
  360. ams_label="AMS-A",
  361. humidity=75.0,
  362. threshold=60.0,
  363. db=mock_db,
  364. )
  365. mock_send.assert_not_called()
  366. # ========================================================================
  367. # Tests for daily digest
  368. # ========================================================================
  369. @pytest.mark.asyncio
  370. async def test_daily_digest_queues_notification(
  371. self, service, mock_provider, mock_db
  372. ):
  373. """Verify notifications are queued when digest mode is enabled."""
  374. mock_provider.daily_digest_enabled = True
  375. mock_provider.daily_digest_time = "09:00"
  376. with patch.object(
  377. service, '_get_providers_for_event', new_callable=AsyncMock
  378. ) as mock_get, \
  379. patch.object(
  380. service, '_send_to_providers', new_callable=AsyncMock
  381. ) as mock_send, \
  382. patch.object(
  383. service, '_build_message_from_template', new_callable=AsyncMock
  384. ) as mock_build:
  385. mock_get.return_value = [mock_provider]
  386. mock_build.return_value = ("Test", "Test")
  387. await service.on_print_complete(
  388. printer_id=1,
  389. printer_name="Test",
  390. status="completed",
  391. data={},
  392. db=mock_db,
  393. )
  394. # When digest is enabled, _send_to_providers should still be called
  395. # but internally it will queue instead of send immediately
  396. mock_send.assert_called_once()
  397. @pytest.mark.asyncio
  398. async def test_force_immediate_bypasses_digest(
  399. self, service, mock_provider, mock_db
  400. ):
  401. """Verify force_immediate=True bypasses digest mode."""
  402. mock_provider.daily_digest_enabled = True
  403. mock_provider.on_ams_humidity_high = True
  404. with patch.object(
  405. service, '_get_providers_for_event', new_callable=AsyncMock
  406. ) as mock_get, \
  407. patch.object(
  408. service, '_send_to_providers', new_callable=AsyncMock
  409. ) as mock_send, \
  410. patch.object(
  411. service, '_build_message_from_template', new_callable=AsyncMock
  412. ) as mock_build:
  413. mock_get.return_value = [mock_provider]
  414. mock_build.return_value = ("Alert", "Alert message")
  415. await service.on_ams_humidity_high(
  416. printer_id=1,
  417. printer_name="Test",
  418. ams_label="AMS-A",
  419. humidity=75.0,
  420. threshold=60.0,
  421. db=mock_db,
  422. )
  423. # Verify force_immediate is passed
  424. call_kwargs = mock_send.call_args[1]
  425. assert call_kwargs.get('force_immediate') is True
  426. class TestDigestModeAlwaysSendsImmediately:
  427. """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
  428. @pytest.fixture
  429. def service(self):
  430. return NotificationService()
  431. @pytest.mark.asyncio
  432. async def test_notification_sends_immediately_even_with_digest_enabled(self, service):
  433. """CRITICAL: All notifications must be sent immediately, digest is just a summary."""
  434. # Create a mock provider with digest enabled
  435. mock_provider = MagicMock()
  436. mock_provider.id = 1
  437. mock_provider.name = "Test Provider"
  438. mock_provider.provider_type = "ntfy"
  439. mock_provider.enabled = True
  440. mock_provider.daily_digest_enabled = True # Digest enabled
  441. mock_provider.daily_digest_time = "23:59"
  442. mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
  443. mock_db = AsyncMock()
  444. # Mock the _send_to_provider method
  445. with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
  446. mock_send.return_value = (True, None)
  447. with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
  448. with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
  449. with patch.object(service, '_log_notification', new_callable=AsyncMock):
  450. await service._send_to_providers(
  451. providers=[mock_provider],
  452. title="Print Started",
  453. message="Your print has started",
  454. db=mock_db,
  455. event_type="print_start",
  456. )
  457. # CRITICAL: _send_to_provider MUST be called (immediate send)
  458. mock_send.assert_called_once()
  459. # Digest queue should also be called (for daily summary)
  460. mock_queue.assert_called_once()
  461. @pytest.mark.asyncio
  462. async def test_notification_sends_without_digest_queue_when_disabled(self, service):
  463. """When digest is disabled, notification sends but no digest queue."""
  464. mock_provider = MagicMock()
  465. mock_provider.id = 1
  466. mock_provider.name = "Test Provider"
  467. mock_provider.provider_type = "ntfy"
  468. mock_provider.enabled = True
  469. mock_provider.daily_digest_enabled = False # Digest disabled
  470. mock_provider.daily_digest_time = None
  471. mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
  472. mock_db = AsyncMock()
  473. with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
  474. mock_send.return_value = (True, None)
  475. with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
  476. with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
  477. with patch.object(service, '_log_notification', new_callable=AsyncMock):
  478. await service._send_to_providers(
  479. providers=[mock_provider],
  480. title="Print Started",
  481. message="Your print has started",
  482. db=mock_db,
  483. event_type="print_start",
  484. )
  485. # Notification must still be sent immediately
  486. mock_send.assert_called_once()
  487. # Digest queue should NOT be called when digest is disabled
  488. mock_queue.assert_not_called()
  489. class TestNotificationProviderTypes:
  490. """Tests for different notification provider types."""
  491. @pytest.fixture
  492. def service(self):
  493. return NotificationService()
  494. @pytest.mark.asyncio
  495. async def test_webhook_provider_sends_request(self, service):
  496. """Verify webhook provider sends HTTP request."""
  497. config = {
  498. "webhook_url": "http://test.local/webhook",
  499. "field_title": "title",
  500. "field_message": "message",
  501. }
  502. # Create a mock response
  503. mock_response = MagicMock()
  504. mock_response.status_code = 200
  505. # Mock the _get_client method
  506. mock_client = AsyncMock()
  507. mock_client.post = AsyncMock(return_value=mock_response)
  508. with patch.object(service, '_get_client', new_callable=AsyncMock) as mock_get_client:
  509. mock_get_client.return_value = mock_client
  510. success, message = await service._send_webhook(
  511. config, "Test Title", "Test Message"
  512. )
  513. assert success is True
  514. mock_client.post.assert_called_once()
  515. @pytest.mark.asyncio
  516. async def test_webhook_handles_failure(self, service):
  517. """Verify webhook gracefully handles HTTP errors."""
  518. config = {
  519. "webhook_url": "http://test.local/webhook",
  520. }
  521. with patch('httpx.AsyncClient') as mock_client_class:
  522. mock_instance = AsyncMock()
  523. mock_instance.post.side_effect = Exception("Connection failed")
  524. mock_client_class.return_value.__aenter__ = AsyncMock(
  525. return_value=mock_instance
  526. )
  527. mock_client_class.return_value.__aexit__ = AsyncMock()
  528. success, message = await service._send_webhook(
  529. config, "Test", "Test"
  530. )
  531. assert success is False
  532. assert "Connection failed" in message or "error" in message.lower()
  533. class TestNotificationTemplates:
  534. """Tests for notification message template rendering."""
  535. @pytest.fixture
  536. def service(self):
  537. return NotificationService()
  538. @pytest.mark.asyncio
  539. async def test_template_renders_variables(self, service):
  540. """Verify template variables are replaced correctly."""
  541. template_title = "Print {progress}% Complete"
  542. template_body = "{printer}: {filename}\nRemaining: {remaining_time}"
  543. variables = {
  544. "printer": "Test Printer",
  545. "filename": "test.3mf",
  546. "progress": "50",
  547. "remaining_time": "1h 30m",
  548. }
  549. title = template_title.format(**variables)
  550. body = template_body.format(**variables)
  551. assert title == "Print 50% Complete"
  552. assert "Test Printer" in body
  553. assert "test.3mf" in body
  554. assert "1h 30m" in body
  555. @pytest.mark.asyncio
  556. async def test_template_handles_missing_variables(self, service):
  557. """Verify missing template variables don't cause crashes."""
  558. template = "{printer}: {unknown_var}"
  559. variables = {"printer": "Test"}
  560. # Should handle gracefully - either leave placeholder or skip
  561. try:
  562. result = template.format_map(
  563. {**variables, "unknown_var": "{unknown_var}"}
  564. )
  565. assert "Test" in result
  566. except KeyError:
  567. pytest.fail("Template should handle missing variables gracefully")