test_notification_service.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  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 TestNotificationVariableFallbacks:
  534. """Tests for notification variable fallback values."""
  535. @pytest.fixture
  536. def service(self):
  537. return NotificationService()
  538. def test_format_duration_with_valid_seconds(self, service):
  539. """Verify duration formats correctly with valid input."""
  540. result = service._format_duration(3661) # 1h 1m 1s
  541. assert "1h" in result
  542. def test_format_duration_with_none_returns_unknown(self, service):
  543. """CRITICAL: Verify None duration returns 'Unknown' fallback."""
  544. result = service._format_duration(None)
  545. assert result == "Unknown"
  546. def test_format_duration_with_zero(self, service):
  547. """Verify zero duration formats correctly."""
  548. result = service._format_duration(0)
  549. # Should return some valid string, not "Unknown"
  550. assert result is not None
  551. assert isinstance(result, str)
  552. def test_format_duration_hours_and_minutes(self, service):
  553. """Verify duration formats hours and minutes."""
  554. result = service._format_duration(5400) # 1h 30m
  555. assert "1h" in result
  556. assert "30m" in result
  557. def test_format_duration_minutes_only(self, service):
  558. """Verify duration formats minutes only when < 1 hour."""
  559. result = service._format_duration(1800) # 30m
  560. assert "30m" in result or "30" in result
  561. @pytest.mark.asyncio
  562. async def test_print_complete_fallback_values(self, service):
  563. """CRITICAL: Verify fallback values when archive_data is missing."""
  564. mock_db = AsyncMock()
  565. with patch.object(
  566. service, '_get_providers_for_event', new_callable=AsyncMock
  567. ) as mock_get, \
  568. patch.object(
  569. service, '_send_to_providers', new_callable=AsyncMock
  570. ) as mock_send, \
  571. patch.object(
  572. service, '_build_message_from_template', new_callable=AsyncMock
  573. ) as mock_build:
  574. mock_get.return_value = [] # No providers, just testing variable setup
  575. mock_build.return_value = ("Test", "Test")
  576. await service.on_print_complete(
  577. printer_id=1,
  578. printer_name="Test",
  579. status="completed",
  580. data={"subtask_name": "test_print"},
  581. db=mock_db,
  582. archive_data=None, # No archive data - should use fallbacks
  583. )
  584. # Test passes if no exception is raised with missing archive_data
  585. @pytest.mark.asyncio
  586. async def test_print_complete_with_archive_data(self, service):
  587. """Verify archive data values are used when provided."""
  588. mock_db = AsyncMock()
  589. captured_variables = {}
  590. async def capture_build(db, event_type, variables):
  591. captured_variables.update(variables)
  592. return ("Test", "Test")
  593. with patch.object(
  594. service, '_get_providers_for_event', new_callable=AsyncMock
  595. ) as mock_get, \
  596. patch.object(
  597. service, '_send_to_providers', new_callable=AsyncMock
  598. ), \
  599. patch.object(
  600. service, '_build_message_from_template', side_effect=capture_build
  601. ):
  602. mock_get.return_value = []
  603. await service.on_print_complete(
  604. printer_id=1,
  605. printer_name="Test",
  606. status="completed",
  607. data={"subtask_name": "test_print"},
  608. db=mock_db,
  609. archive_data={
  610. "print_time_seconds": 3600,
  611. "actual_filament_grams": 50.5,
  612. },
  613. )
  614. # When archive data is provided, duration should not be "Unknown"
  615. if captured_variables.get("duration"):
  616. assert captured_variables["duration"] != "Unknown"
  617. @pytest.mark.asyncio
  618. async def test_print_start_estimated_time_fallback(self, service):
  619. """Verify estimated time shows 'Unknown' when not available."""
  620. mock_db = AsyncMock()
  621. mock_provider = MagicMock()
  622. mock_provider.id = 1
  623. captured_variables = {}
  624. async def capture_build(db, event_type, variables):
  625. captured_variables.update(variables)
  626. return ("Test", "Test")
  627. with patch.object(
  628. service, '_get_providers_for_event', new_callable=AsyncMock
  629. ) as mock_get, \
  630. patch.object(
  631. service, '_send_to_providers', new_callable=AsyncMock
  632. ), \
  633. patch.object(
  634. service, '_build_message_from_template', side_effect=capture_build
  635. ):
  636. # Need at least one provider to trigger message building
  637. mock_get.return_value = [mock_provider]
  638. await service.on_print_start(
  639. printer_id=1,
  640. printer_name="Test",
  641. data={
  642. "subtask_name": "test",
  643. # No estimated_time or mc_remaining_time
  644. },
  645. db=mock_db,
  646. )
  647. # When no time data, should show "Unknown"
  648. assert captured_variables.get("estimated_time") == "Unknown"
  649. @pytest.mark.asyncio
  650. async def test_print_progress_remaining_time_fallback(self, service):
  651. """Verify remaining time shows 'Unknown' when not available."""
  652. mock_db = AsyncMock()
  653. mock_provider = MagicMock()
  654. mock_provider.id = 1
  655. captured_variables = {}
  656. async def capture_build(db, event_type, variables):
  657. captured_variables.update(variables)
  658. return ("Test", "Test")
  659. with patch.object(
  660. service, '_get_providers_for_event', new_callable=AsyncMock
  661. ) as mock_get, \
  662. patch.object(
  663. service, '_send_to_providers', new_callable=AsyncMock
  664. ), \
  665. patch.object(
  666. service, '_build_message_from_template', side_effect=capture_build
  667. ):
  668. # Need at least one provider to trigger message building
  669. mock_get.return_value = [mock_provider]
  670. await service.on_print_progress(
  671. printer_id=1,
  672. printer_name="Test",
  673. progress=50,
  674. remaining_time=None, # No remaining time
  675. filename="test.3mf",
  676. db=mock_db,
  677. )
  678. # When no remaining time, should show "Unknown"
  679. assert captured_variables.get("remaining_time") == "Unknown"
  680. @pytest.mark.asyncio
  681. async def test_filename_fallback_to_unknown(self, service):
  682. """Verify filename defaults to 'Unknown' when not provided."""
  683. mock_db = AsyncMock()
  684. mock_provider = MagicMock()
  685. mock_provider.id = 1
  686. captured_variables = {}
  687. async def capture_build(db, event_type, variables):
  688. captured_variables.update(variables)
  689. return ("Test", "Test")
  690. with patch.object(
  691. service, '_get_providers_for_event', new_callable=AsyncMock
  692. ) as mock_get, \
  693. patch.object(
  694. service, '_send_to_providers', new_callable=AsyncMock
  695. ), \
  696. patch.object(
  697. service, '_build_message_from_template', side_effect=capture_build
  698. ):
  699. # Need at least one provider to trigger message building
  700. mock_get.return_value = [mock_provider]
  701. await service.on_print_complete(
  702. printer_id=1,
  703. printer_name="Test",
  704. status="completed",
  705. data={}, # No subtask_name or filename
  706. db=mock_db,
  707. )
  708. # Filename should default to something (either "Unknown" or cleaned empty)
  709. assert "filename" in captured_variables
  710. @pytest.mark.asyncio
  711. async def test_print_start_uses_archive_print_time_seconds(self, service):
  712. """Verify print_time_seconds from archive_data is used for estimated_time."""
  713. mock_db = AsyncMock()
  714. mock_provider = MagicMock()
  715. mock_provider.id = 1
  716. captured_variables = {}
  717. async def capture_build(db, event_type, variables):
  718. captured_variables.update(variables)
  719. return ("Test", "Test")
  720. with patch.object(
  721. service, '_get_providers_for_event', new_callable=AsyncMock
  722. ) as mock_get, \
  723. patch.object(
  724. service, '_send_to_providers', new_callable=AsyncMock
  725. ), \
  726. patch.object(
  727. service, '_build_message_from_template', side_effect=capture_build
  728. ):
  729. mock_get.return_value = [mock_provider]
  730. # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
  731. await service.on_print_start(
  732. printer_id=1,
  733. printer_name="Test",
  734. data={"subtask_name": "test"},
  735. db=mock_db,
  736. archive_data={"print_time_seconds": 7200},
  737. )
  738. # Should use archive's print_time_seconds: 7200 seconds = 2h 0m
  739. assert captured_variables.get("estimated_time") == "2h 0m"
  740. @pytest.mark.asyncio
  741. async def test_print_start_archive_data_overrides_mqtt(self, service):
  742. """Verify archive_data takes priority over MQTT remaining_time."""
  743. mock_db = AsyncMock()
  744. mock_provider = MagicMock()
  745. mock_provider.id = 1
  746. captured_variables = {}
  747. async def capture_build(db, event_type, variables):
  748. captured_variables.update(variables)
  749. return ("Test", "Test")
  750. with patch.object(
  751. service, '_get_providers_for_event', new_callable=AsyncMock
  752. ) as mock_get, \
  753. patch.object(
  754. service, '_send_to_providers', new_callable=AsyncMock
  755. ), \
  756. patch.object(
  757. service, '_build_message_from_template', side_effect=capture_build
  758. ):
  759. mock_get.return_value = [mock_provider]
  760. # Both archive_data and MQTT remaining_time provided
  761. # Archive says 2 hours, MQTT says 30 minutes (wrong at start)
  762. await service.on_print_start(
  763. printer_id=1,
  764. printer_name="Test",
  765. data={
  766. "subtask_name": "test",
  767. "remaining_time": 1800, # 30 minutes from MQTT
  768. },
  769. db=mock_db,
  770. archive_data={"print_time_seconds": 7200}, # 2 hours from 3MF
  771. )
  772. # Should use archive's print_time_seconds (more reliable)
  773. assert captured_variables.get("estimated_time") == "2h 0m"
  774. @pytest.mark.asyncio
  775. async def test_print_start_falls_back_to_mqtt_when_no_archive(self, service):
  776. """Verify MQTT remaining_time is used when archive_data not provided."""
  777. mock_db = AsyncMock()
  778. mock_provider = MagicMock()
  779. mock_provider.id = 1
  780. captured_variables = {}
  781. async def capture_build(db, event_type, variables):
  782. captured_variables.update(variables)
  783. return ("Test", "Test")
  784. with patch.object(
  785. service, '_get_providers_for_event', new_callable=AsyncMock
  786. ) as mock_get, \
  787. patch.object(
  788. service, '_send_to_providers', new_callable=AsyncMock
  789. ), \
  790. patch.object(
  791. service, '_build_message_from_template', side_effect=capture_build
  792. ):
  793. mock_get.return_value = [mock_provider]
  794. # Only MQTT remaining_time provided (1800 seconds = 30 minutes)
  795. await service.on_print_start(
  796. printer_id=1,
  797. printer_name="Test",
  798. data={
  799. "subtask_name": "test",
  800. "remaining_time": 1800,
  801. },
  802. db=mock_db,
  803. # No archive_data
  804. )
  805. # Should use MQTT remaining_time
  806. assert captured_variables.get("estimated_time") == "30m"
  807. class TestNotificationTemplates:
  808. """Tests for notification message template rendering."""
  809. @pytest.fixture
  810. def service(self):
  811. return NotificationService()
  812. @pytest.mark.asyncio
  813. async def test_template_renders_variables(self, service):
  814. """Verify template variables are replaced correctly."""
  815. template_title = "Print {progress}% Complete"
  816. template_body = "{printer}: {filename}\nRemaining: {remaining_time}"
  817. variables = {
  818. "printer": "Test Printer",
  819. "filename": "test.3mf",
  820. "progress": "50",
  821. "remaining_time": "1h 30m",
  822. }
  823. title = template_title.format(**variables)
  824. body = template_body.format(**variables)
  825. assert title == "Print 50% Complete"
  826. assert "Test Printer" in body
  827. assert "test.3mf" in body
  828. assert "1h 30m" in body
  829. @pytest.mark.asyncio
  830. async def test_template_handles_missing_variables(self, service):
  831. """Verify missing template variables don't cause crashes."""
  832. template = "{printer}: {unknown_var}"
  833. variables = {"printer": "Test"}
  834. # Should handle gracefully - either leave placeholder or skip
  835. try:
  836. result = template.format_map(
  837. {**variables, "unknown_var": "{unknown_var}"}
  838. )
  839. assert "Test" in result
  840. except KeyError:
  841. pytest.fail("Template should handle missing variables gracefully")