test_notification_service.py 100 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548
  1. """Unit tests for NotificationService.
  2. Tests event-based notifications and toggle behavior.
  3. """
  4. import json
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from backend.app.services.notification_service import NotificationService
  8. class TestNotificationService:
  9. """Tests for NotificationService class."""
  10. @pytest.fixture
  11. def service(self):
  12. """Create a fresh NotificationService instance."""
  13. return NotificationService()
  14. @pytest.fixture
  15. def mock_provider(self):
  16. """Create a mock notification provider."""
  17. provider = MagicMock()
  18. provider.id = 1
  19. provider.name = "Test Provider"
  20. provider.provider_type = "webhook"
  21. provider.enabled = True
  22. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  23. provider.on_print_start = True
  24. provider.on_print_complete = True
  25. provider.on_print_failed = True
  26. provider.on_print_stopped = False
  27. provider.on_print_progress = False
  28. provider.on_printer_offline = False
  29. provider.on_printer_error = False
  30. provider.on_filament_low = False
  31. provider.on_maintenance_due = False
  32. provider.on_ams_humidity_high = False
  33. provider.on_ams_temperature_high = False
  34. provider.quiet_hours_enabled = False
  35. provider.quiet_hours_start = None
  36. provider.quiet_hours_end = None
  37. provider.daily_digest_enabled = False
  38. provider.daily_digest_time = None
  39. provider.printer_id = None
  40. return provider
  41. @pytest.fixture
  42. def mock_db(self):
  43. """Create a mock database session."""
  44. db = AsyncMock()
  45. db.commit = AsyncMock()
  46. return db
  47. # ========================================================================
  48. # Tests for on_print_start
  49. # ========================================================================
  50. @pytest.mark.asyncio
  51. async def test_on_print_start_sends_notification(self, service, mock_provider, mock_db):
  52. """Verify notification is sent when print starts."""
  53. with (
  54. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  55. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  56. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  57. ):
  58. mock_get.return_value = [mock_provider]
  59. mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
  60. await service.on_print_start(
  61. printer_id=1,
  62. printer_name="Test Printer",
  63. data={"filename": "test.3mf", "subtask_name": "test"},
  64. db=mock_db,
  65. )
  66. mock_get.assert_called_once()
  67. mock_send.assert_called_once()
  68. @pytest.mark.asyncio
  69. async def test_on_print_start_skipped_when_no_providers(self, service, mock_db):
  70. """Verify no error when no providers are configured for event."""
  71. with (
  72. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  73. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  74. ):
  75. mock_get.return_value = []
  76. await service.on_print_start(
  77. printer_id=1,
  78. printer_name="Test Printer",
  79. data={},
  80. db=mock_db,
  81. )
  82. mock_send.assert_not_called()
  83. # ========================================================================
  84. # Tests for on_print_complete (status routing)
  85. # ========================================================================
  86. @pytest.mark.asyncio
  87. async def test_on_print_complete_routes_completed_status(self, service, mock_provider, mock_db):
  88. """Verify completed status uses on_print_complete field."""
  89. with (
  90. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  91. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  92. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  93. ):
  94. mock_get.return_value = [mock_provider]
  95. mock_build.return_value = ("Test", "Test")
  96. await service.on_print_complete(
  97. printer_id=1,
  98. printer_name="Test",
  99. status="completed",
  100. data={},
  101. db=mock_db,
  102. )
  103. # Verify the correct event field was queried
  104. call_args = mock_get.call_args
  105. assert call_args[0][1] == "on_print_complete"
  106. @pytest.mark.asyncio
  107. async def test_on_print_complete_routes_failed_status(self, service, mock_provider, mock_db):
  108. """Verify failed status uses on_print_failed field."""
  109. with (
  110. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  111. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  112. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  113. ):
  114. mock_get.return_value = [mock_provider]
  115. mock_build.return_value = ("Test", "Test")
  116. await service.on_print_complete(
  117. printer_id=1,
  118. printer_name="Test",
  119. status="failed",
  120. data={},
  121. db=mock_db,
  122. )
  123. call_args = mock_get.call_args
  124. assert call_args[0][1] == "on_print_failed"
  125. @pytest.mark.asyncio
  126. async def test_on_print_complete_routes_stopped_status(self, service, mock_provider, mock_db):
  127. """Verify stopped status uses on_print_stopped field."""
  128. with (
  129. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  130. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  131. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  132. ):
  133. mock_get.return_value = [mock_provider]
  134. mock_build.return_value = ("Test", "Test")
  135. await service.on_print_complete(
  136. printer_id=1,
  137. printer_name="Test",
  138. status="stopped",
  139. data={},
  140. db=mock_db,
  141. )
  142. call_args = mock_get.call_args
  143. assert call_args[0][1] == "on_print_stopped"
  144. @pytest.mark.asyncio
  145. async def test_on_print_complete_routes_aborted_status(self, service, mock_provider, mock_db):
  146. """Verify aborted status uses on_print_stopped field."""
  147. with (
  148. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  149. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  150. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  151. ):
  152. mock_get.return_value = [mock_provider]
  153. mock_build.return_value = ("Test", "Test")
  154. await service.on_print_complete(
  155. printer_id=1,
  156. printer_name="Test",
  157. status="aborted",
  158. data={},
  159. db=mock_db,
  160. )
  161. call_args = mock_get.call_args
  162. assert call_args[0][1] == "on_print_stopped"
  163. # ========================================================================
  164. # Tests for provider filtering
  165. # ========================================================================
  166. @pytest.mark.asyncio
  167. async def test_disabled_provider_not_returned(self, service, mock_provider, mock_db):
  168. """CRITICAL: Verify disabled providers don't receive notifications."""
  169. mock_provider.enabled = False
  170. # The actual filtering happens in _get_providers_for_event
  171. # which queries only enabled providers
  172. with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get:
  173. # Simulate the query filtering out disabled providers
  174. mock_get.return_value = []
  175. result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1)
  176. assert len(result) == 0
  177. @pytest.mark.asyncio
  178. async def test_provider_filtered_by_printer_id(self, service, mock_provider, mock_db):
  179. """Verify providers can be filtered by specific printer."""
  180. mock_provider.printer_id = 2 # Linked to printer 2
  181. with patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get:
  182. # When querying for printer 1, provider linked to printer 2 is excluded
  183. mock_get.return_value = []
  184. result = await service._get_providers_for_event(mock_db, "on_print_start", printer_id=1)
  185. assert len(result) == 0
  186. # ========================================================================
  187. # Tests for quiet hours
  188. # ========================================================================
  189. def test_is_in_quiet_hours_during_quiet_period(self, service, mock_provider):
  190. """Verify notifications are blocked during quiet hours."""
  191. mock_provider.quiet_hours_enabled = True
  192. mock_provider.quiet_hours_start = "22:00"
  193. mock_provider.quiet_hours_end = "07:00"
  194. with patch("backend.app.services.notification_service.datetime") as mock_datetime:
  195. # Test during quiet hours (23:00)
  196. mock_now = MagicMock()
  197. mock_now.hour = 23
  198. mock_now.minute = 0
  199. mock_datetime.now.return_value = mock_now
  200. result = service._is_in_quiet_hours(mock_provider)
  201. assert result is True
  202. def test_is_in_quiet_hours_outside_quiet_period(self, service, mock_provider):
  203. """Verify notifications are allowed outside quiet hours."""
  204. mock_provider.quiet_hours_enabled = True
  205. mock_provider.quiet_hours_start = "22:00"
  206. mock_provider.quiet_hours_end = "07:00"
  207. with patch("backend.app.services.notification_service.datetime") as mock_datetime:
  208. # Test outside quiet hours (12:00)
  209. mock_now = MagicMock()
  210. mock_now.hour = 12
  211. mock_now.minute = 0
  212. mock_datetime.now.return_value = mock_now
  213. result = service._is_in_quiet_hours(mock_provider)
  214. assert result is False
  215. def test_is_in_quiet_hours_disabled(self, service, mock_provider):
  216. """Verify quiet hours check returns False when disabled."""
  217. mock_provider.quiet_hours_enabled = False
  218. result = service._is_in_quiet_hours(mock_provider)
  219. assert result is False
  220. def test_is_in_quiet_hours_early_morning(self, service, mock_provider):
  221. """Verify quiet hours work across midnight (early morning)."""
  222. mock_provider.quiet_hours_enabled = True
  223. mock_provider.quiet_hours_start = "22:00"
  224. mock_provider.quiet_hours_end = "07:00"
  225. with patch("backend.app.services.notification_service.datetime") as mock_datetime:
  226. # Test early morning (03:00) - should be in quiet hours
  227. mock_now = MagicMock()
  228. mock_now.hour = 3
  229. mock_now.minute = 0
  230. mock_datetime.now.return_value = mock_now
  231. result = service._is_in_quiet_hours(mock_provider)
  232. assert result is True
  233. # ========================================================================
  234. # Tests for AMS alarms
  235. # ========================================================================
  236. @pytest.mark.asyncio
  237. async def test_on_ams_humidity_high_sends_notification(self, service, mock_provider, mock_db):
  238. """Verify AMS humidity alarm sends notification."""
  239. mock_provider.on_ams_humidity_high = True
  240. with (
  241. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  242. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  243. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  244. ):
  245. mock_get.return_value = [mock_provider]
  246. mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
  247. await service.on_ams_humidity_high(
  248. printer_id=1,
  249. printer_name="Test Printer",
  250. ams_label="AMS-A",
  251. humidity=75.0,
  252. threshold=60.0,
  253. db=mock_db,
  254. )
  255. mock_send.assert_called_once()
  256. # Verify force_immediate is True for alarms
  257. call_kwargs = mock_send.call_args[1]
  258. assert call_kwargs.get("force_immediate") is True
  259. @pytest.mark.asyncio
  260. async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
  261. """Verify AMS temperature alarm sends notification."""
  262. mock_provider.on_ams_temperature_high = True
  263. with (
  264. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  265. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  266. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  267. ):
  268. mock_get.return_value = [mock_provider]
  269. mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
  270. await service.on_ams_temperature_high(
  271. printer_id=1,
  272. printer_name="Test Printer",
  273. ams_label="AMS-A",
  274. temperature=40.0,
  275. threshold=35.0,
  276. db=mock_db,
  277. )
  278. mock_send.assert_called_once()
  279. # Verify force_immediate is True for alarms
  280. call_kwargs = mock_send.call_args[1]
  281. assert call_kwargs.get("force_immediate") is True
  282. @pytest.mark.asyncio
  283. async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
  284. """CRITICAL: Verify AMS alarms respect toggle setting."""
  285. mock_provider.on_ams_humidity_high = False
  286. with (
  287. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  288. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  289. ):
  290. # Provider with toggle disabled won't be returned
  291. mock_get.return_value = []
  292. await service.on_ams_humidity_high(
  293. printer_id=1,
  294. printer_name="Test",
  295. ams_label="AMS-A",
  296. humidity=75.0,
  297. threshold=60.0,
  298. db=mock_db,
  299. )
  300. mock_send.assert_not_called()
  301. # ========================================================================
  302. # Tests for daily digest
  303. # ========================================================================
  304. @pytest.mark.asyncio
  305. async def test_daily_digest_queues_notification(self, service, mock_provider, mock_db):
  306. """Verify notifications are queued when digest mode is enabled."""
  307. mock_provider.daily_digest_enabled = True
  308. mock_provider.daily_digest_time = "09:00"
  309. with (
  310. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  311. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  312. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  313. ):
  314. mock_get.return_value = [mock_provider]
  315. mock_build.return_value = ("Test", "Test")
  316. await service.on_print_complete(
  317. printer_id=1,
  318. printer_name="Test",
  319. status="completed",
  320. data={},
  321. db=mock_db,
  322. )
  323. # When digest is enabled, _send_to_providers should still be called
  324. # but internally it will queue instead of send immediately
  325. mock_send.assert_called_once()
  326. @pytest.mark.asyncio
  327. async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):
  328. """Verify force_immediate=True bypasses digest mode."""
  329. mock_provider.daily_digest_enabled = True
  330. mock_provider.on_ams_humidity_high = True
  331. with (
  332. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  333. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  334. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  335. ):
  336. mock_get.return_value = [mock_provider]
  337. mock_build.return_value = ("Alert", "Alert message")
  338. await service.on_ams_humidity_high(
  339. printer_id=1,
  340. printer_name="Test",
  341. ams_label="AMS-A",
  342. humidity=75.0,
  343. threshold=60.0,
  344. db=mock_db,
  345. )
  346. # Verify force_immediate is passed
  347. call_kwargs = mock_send.call_args[1]
  348. assert call_kwargs.get("force_immediate") is True
  349. class TestDigestModeAlwaysSendsImmediately:
  350. """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
  351. @pytest.fixture
  352. def service(self):
  353. return NotificationService()
  354. @pytest.mark.asyncio
  355. async def test_notification_sends_immediately_even_with_digest_enabled(self, service):
  356. """CRITICAL: All notifications must be sent immediately, digest is just a summary."""
  357. # Create a mock provider with digest enabled
  358. mock_provider = MagicMock()
  359. mock_provider.id = 1
  360. mock_provider.name = "Test Provider"
  361. mock_provider.provider_type = "ntfy"
  362. mock_provider.enabled = True
  363. mock_provider.daily_digest_enabled = True # Digest enabled
  364. mock_provider.daily_digest_time = "23:59"
  365. mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
  366. mock_db = AsyncMock()
  367. # Mock the _send_to_provider method
  368. with (
  369. patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send,
  370. patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue,
  371. patch.object(service, "_update_provider_status", new_callable=AsyncMock),
  372. patch.object(service, "_log_notification", new_callable=AsyncMock),
  373. ):
  374. mock_send.return_value = (True, None)
  375. await service._send_to_providers(
  376. providers=[mock_provider],
  377. title="Print Started",
  378. message="Your print has started",
  379. db=mock_db,
  380. event_type="print_start",
  381. )
  382. # CRITICAL: _send_to_provider MUST be called (immediate send)
  383. mock_send.assert_called_once()
  384. # Digest queue should also be called (for daily summary)
  385. mock_queue.assert_called_once()
  386. @pytest.mark.asyncio
  387. async def test_notification_sends_without_digest_queue_when_disabled(self, service):
  388. """When digest is disabled, notification sends but no digest queue."""
  389. mock_provider = MagicMock()
  390. mock_provider.id = 1
  391. mock_provider.name = "Test Provider"
  392. mock_provider.provider_type = "ntfy"
  393. mock_provider.enabled = True
  394. mock_provider.daily_digest_enabled = False # Digest disabled
  395. mock_provider.daily_digest_time = None
  396. mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
  397. mock_db = AsyncMock()
  398. with (
  399. patch.object(service, "_send_to_provider", new_callable=AsyncMock) as mock_send,
  400. patch.object(service, "_queue_for_digest", new_callable=AsyncMock) as mock_queue,
  401. patch.object(service, "_update_provider_status", new_callable=AsyncMock),
  402. patch.object(service, "_log_notification", new_callable=AsyncMock),
  403. ):
  404. mock_send.return_value = (True, None)
  405. await service._send_to_providers(
  406. providers=[mock_provider],
  407. title="Print Started",
  408. message="Your print has started",
  409. db=mock_db,
  410. event_type="print_start",
  411. )
  412. # Notification must still be sent immediately
  413. mock_send.assert_called_once()
  414. # Digest queue should NOT be called when digest is disabled
  415. mock_queue.assert_not_called()
  416. class TestNotificationProviderTypes:
  417. """Tests for different notification provider types."""
  418. @pytest.fixture
  419. def service(self):
  420. return NotificationService()
  421. @pytest.mark.asyncio
  422. async def test_webhook_provider_sends_request(self, service):
  423. """Verify webhook provider sends HTTP request."""
  424. config = {
  425. "webhook_url": "http://test.local/webhook",
  426. "field_title": "title",
  427. "field_message": "message",
  428. }
  429. # Create a mock response
  430. mock_response = MagicMock()
  431. mock_response.status_code = 200
  432. # Mock the _get_client method
  433. mock_client = AsyncMock()
  434. mock_client.post = AsyncMock(return_value=mock_response)
  435. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  436. mock_get_client.return_value = mock_client
  437. success, message = await service._send_webhook(config, "Test Title", "Test Message")
  438. assert success is True
  439. mock_client.post.assert_called_once()
  440. @pytest.mark.asyncio
  441. async def test_webhook_handles_failure(self, service):
  442. """Verify webhook gracefully handles HTTP errors."""
  443. config = {
  444. "webhook_url": "http://test.local/webhook",
  445. }
  446. with patch("httpx.AsyncClient") as mock_client_class:
  447. mock_instance = AsyncMock()
  448. mock_instance.post.side_effect = Exception("Connection failed")
  449. mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
  450. mock_client_class.return_value.__aexit__ = AsyncMock()
  451. success, message = await service._send_webhook(config, "Test", "Test")
  452. assert success is False
  453. assert "Connection failed" in message or "error" in message.lower()
  454. @pytest.mark.asyncio
  455. async def test_webhook_slack_format_sends_text_only(self, service):
  456. """Verify Slack/Mattermost format sends only text field."""
  457. config = {
  458. "webhook_url": "http://mattermost.local/hooks/abc123",
  459. "payload_format": "slack",
  460. }
  461. mock_response = MagicMock()
  462. mock_response.status_code = 200
  463. mock_client = AsyncMock()
  464. mock_client.post = AsyncMock(return_value=mock_response)
  465. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  466. mock_get_client.return_value = mock_client
  467. success, message = await service._send_webhook(config, "Test Title", "Test Message")
  468. assert success is True
  469. mock_client.post.assert_called_once()
  470. # Verify payload format is Slack-compatible
  471. call_args = mock_client.post.call_args
  472. payload = call_args.kwargs.get("json") or call_args[1].get("json")
  473. assert "text" in payload
  474. assert "*Test Title*" in payload["text"]
  475. assert "Test Message" in payload["text"]
  476. # Should NOT have generic fields
  477. assert "timestamp" not in payload
  478. assert "source" not in payload
  479. @pytest.mark.asyncio
  480. async def test_webhook_generic_format_includes_image(self, service):
  481. """Verify generic webhook includes base64-encoded image when provided."""
  482. config = {
  483. "webhook_url": "http://test.local/webhook",
  484. "field_title": "title",
  485. "field_message": "message",
  486. }
  487. mock_response = MagicMock()
  488. mock_response.status_code = 200
  489. mock_client = AsyncMock()
  490. mock_client.post = AsyncMock(return_value=mock_response)
  491. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  492. mock_get_client.return_value = mock_client
  493. image_bytes = b"\xff\xd8\xff\xe0fake-jpeg-data"
  494. success, message = await service._send_webhook(config, "Test Title", "Test Message", image_data=image_bytes)
  495. assert success is True
  496. call_args = mock_client.post.call_args
  497. payload = call_args.kwargs.get("json") or call_args[1].get("json")
  498. assert "image" in payload
  499. import base64
  500. assert payload["image"] == base64.b64encode(image_bytes).decode("ascii")
  501. @pytest.mark.asyncio
  502. async def test_webhook_generic_format_no_image_when_none(self, service):
  503. """Verify generic webhook omits image field when no image_data provided."""
  504. config = {
  505. "webhook_url": "http://test.local/webhook",
  506. "field_title": "title",
  507. "field_message": "message",
  508. }
  509. mock_response = MagicMock()
  510. mock_response.status_code = 200
  511. mock_client = AsyncMock()
  512. mock_client.post = AsyncMock(return_value=mock_response)
  513. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  514. mock_get_client.return_value = mock_client
  515. success, message = await service._send_webhook(config, "Test Title", "Test Message")
  516. assert success is True
  517. call_args = mock_client.post.call_args
  518. payload = call_args.kwargs.get("json") or call_args[1].get("json")
  519. assert "image" not in payload
  520. @pytest.mark.asyncio
  521. async def test_webhook_slack_format_excludes_image(self, service):
  522. """Verify Slack format does not include image even when provided."""
  523. config = {
  524. "webhook_url": "http://mattermost.local/hooks/abc123",
  525. "payload_format": "slack",
  526. }
  527. mock_response = MagicMock()
  528. mock_response.status_code = 200
  529. mock_client = AsyncMock()
  530. mock_client.post = AsyncMock(return_value=mock_response)
  531. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  532. mock_get_client.return_value = mock_client
  533. success, message = await service._send_webhook(
  534. config, "Test Title", "Test Message", image_data=b"fake-image"
  535. )
  536. assert success is True
  537. call_args = mock_client.post.call_args
  538. payload = call_args.kwargs.get("json") or call_args[1].get("json")
  539. assert "image" not in payload
  540. class TestDiscordProvider:
  541. """Discord webhook URL host validation (#1363)."""
  542. @pytest.fixture
  543. def service(self):
  544. return NotificationService()
  545. @pytest.mark.asyncio
  546. async def test_discord_accepts_discord_com_url(self, service):
  547. config = {"webhook_url": "https://discord.com/api/webhooks/123/abc"}
  548. mock_response = MagicMock()
  549. mock_response.status_code = 204
  550. mock_client = AsyncMock()
  551. mock_client.post = AsyncMock(return_value=mock_response)
  552. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  553. mock_get_client.return_value = mock_client
  554. success, _ = await service._send_discord(config, "Title", "Body")
  555. assert success is True
  556. mock_client.post.assert_called_once()
  557. @pytest.mark.asyncio
  558. async def test_discord_accepts_legacy_discordapp_com_url(self, service):
  559. """Discord's 'Copy Webhook URL' button emits discordapp.com URLs (#1363)."""
  560. config = {"webhook_url": "https://discordapp.com/api/webhooks/123/abc"}
  561. mock_response = MagicMock()
  562. mock_response.status_code = 204
  563. mock_client = AsyncMock()
  564. mock_client.post = AsyncMock(return_value=mock_response)
  565. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
  566. mock_get_client.return_value = mock_client
  567. success, _ = await service._send_discord(config, "Title", "Body")
  568. assert success is True
  569. mock_client.post.assert_called_once()
  570. @pytest.mark.asyncio
  571. async def test_discord_rejects_non_discord_host(self, service):
  572. config = {"webhook_url": "https://evil.example.com/api/webhooks/123/abc"}
  573. success, message = await service._send_discord(config, "Title", "Body")
  574. assert success is False
  575. assert "Invalid Discord webhook URL" in message
  576. @pytest.mark.asyncio
  577. async def test_discord_rejects_empty_url(self, service):
  578. success, message = await service._send_discord({"webhook_url": ""}, "Title", "Body")
  579. assert success is False
  580. assert "required" in message.lower()
  581. class TestNtfyPriority:
  582. """Per-event ntfy Priority header (#990)."""
  583. @pytest.fixture
  584. def service(self):
  585. return NotificationService()
  586. @staticmethod
  587. def _mock_client(service):
  588. """Patch _get_client and return the mock client + 200 response."""
  589. mock_response = MagicMock()
  590. mock_response.status_code = 200
  591. mock_client = AsyncMock()
  592. mock_client.post = AsyncMock(return_value=mock_response)
  593. mock_client.put = AsyncMock(return_value=mock_response)
  594. return mock_client
  595. @pytest.mark.asyncio
  596. async def test_priority_header_set_for_mapped_event(self, service):
  597. """Mapped event → ntfy Priority header carries the configured value."""
  598. config = {
  599. "topic": "bambuddy",
  600. "event_priorities": {"on_print_failed": 5, "on_print_complete": 2},
  601. }
  602. mock_client = self._mock_client(service)
  603. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  604. mock_get.return_value = mock_client
  605. success, _ = await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
  606. assert success is True
  607. headers = mock_client.post.call_args.kwargs["headers"]
  608. assert headers.get("Priority") == "5"
  609. @pytest.mark.asyncio
  610. async def test_priority_header_omitted_for_unmapped_event(self, service):
  611. """Unmapped event → no Priority header so ntfy uses its server default."""
  612. config = {
  613. "topic": "bambuddy",
  614. "event_priorities": {"on_print_failed": 5},
  615. }
  616. mock_client = self._mock_client(service)
  617. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  618. mock_get.return_value = mock_client
  619. await service._send_ntfy(config, "Title", "Body", event_type="on_print_complete")
  620. headers = mock_client.post.call_args.kwargs["headers"]
  621. assert "Priority" not in headers
  622. @pytest.mark.asyncio
  623. async def test_priority_header_omitted_when_no_priorities_set(self, service):
  624. """Existing setups (no event_priorities key) keep current behaviour."""
  625. config = {"topic": "bambuddy"}
  626. mock_client = self._mock_client(service)
  627. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  628. mock_get.return_value = mock_client
  629. await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
  630. headers = mock_client.post.call_args.kwargs["headers"]
  631. assert "Priority" not in headers
  632. @pytest.mark.asyncio
  633. async def test_priority_header_omitted_when_event_type_missing(self, service):
  634. """Test sends (no event_type) must not emit a Priority header."""
  635. config = {
  636. "topic": "bambuddy",
  637. "event_priorities": {"on_print_failed": 5},
  638. }
  639. mock_client = self._mock_client(service)
  640. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  641. mock_get.return_value = mock_client
  642. await service._send_ntfy(config, "Title", "Body")
  643. headers = mock_client.post.call_args.kwargs["headers"]
  644. assert "Priority" not in headers
  645. @pytest.mark.asyncio
  646. async def test_priority_out_of_range_is_ignored(self, service):
  647. """Values outside 1-5 (or non-numeric) are dropped, not clamped."""
  648. for bad in (0, 6, 99, -1, "not-a-number", None):
  649. config = {
  650. "topic": "bambuddy",
  651. "event_priorities": {"on_print_failed": bad},
  652. }
  653. mock_client = self._mock_client(service)
  654. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  655. mock_get.return_value = mock_client
  656. await service._send_ntfy(config, "Title", "Body", event_type="on_print_failed")
  657. headers = mock_client.post.call_args.kwargs["headers"]
  658. assert "Priority" not in headers, f"unexpected header for bad value {bad!r}"
  659. @pytest.mark.asyncio
  660. async def test_priority_header_set_on_attachment_path(self, service):
  661. """Image-attachment path (PUT) must also carry the Priority header."""
  662. config = {
  663. "topic": "bambuddy",
  664. "event_priorities": {"on_first_layer_complete": 4},
  665. }
  666. mock_client = self._mock_client(service)
  667. with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get:
  668. mock_get.return_value = mock_client
  669. await service._send_ntfy(
  670. config,
  671. "Title",
  672. "Body",
  673. image_data=b"\xff\xd8\xff\xe0fake-jpeg",
  674. event_type="on_first_layer_complete",
  675. )
  676. headers = mock_client.put.call_args.kwargs["headers"]
  677. assert headers.get("Priority") == "4"
  678. class TestHomeAssistantProvider:
  679. """Tests for Home Assistant notification provider."""
  680. @pytest.fixture
  681. def service(self):
  682. return NotificationService()
  683. @pytest.mark.asyncio
  684. async def test_send_homeassistant_success(self, service):
  685. """Verify HA provider sends persistent notification to correct endpoint."""
  686. mock_response = MagicMock()
  687. mock_response.status_code = 200
  688. mock_client = AsyncMock()
  689. mock_client.post = AsyncMock(return_value=mock_response)
  690. mock_db = AsyncMock()
  691. with (
  692. patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
  693. patch(
  694. "backend.app.api.routes.settings.get_homeassistant_settings",
  695. new_callable=AsyncMock,
  696. ) as mock_ha_settings,
  697. ):
  698. mock_get_client.return_value = mock_client
  699. mock_ha_settings.return_value = {
  700. "ha_url": "http://ha.local:8123",
  701. "ha_token": "test-token-123",
  702. "ha_enabled": True,
  703. }
  704. success, message = await service._send_homeassistant({}, "Test Title", "Test Message", db=mock_db)
  705. assert success is True
  706. mock_client.post.assert_called_once()
  707. call_args = mock_client.post.call_args
  708. assert call_args[0][0] == "http://ha.local:8123/api/services/persistent_notification/create"
  709. payload = call_args.kwargs.get("json") or call_args[1].get("json")
  710. assert payload["title"] == "Test Title"
  711. assert payload["message"] == "Test Message"
  712. @pytest.mark.asyncio
  713. async def test_send_homeassistant_no_db_no_env(self, service):
  714. """Verify HA provider fails gracefully without DB or env vars."""
  715. with patch.dict("os.environ", {}, clear=True):
  716. success, message = await service._send_homeassistant({}, "Test", "Test", db=None)
  717. assert success is False
  718. assert "not configured" in message.lower()
  719. @pytest.mark.asyncio
  720. async def test_send_homeassistant_auth_failure(self, service):
  721. """Verify HA provider reports auth failure."""
  722. mock_response = MagicMock()
  723. mock_response.status_code = 401
  724. mock_client = AsyncMock()
  725. mock_client.post = AsyncMock(return_value=mock_response)
  726. mock_db = AsyncMock()
  727. with (
  728. patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
  729. patch(
  730. "backend.app.api.routes.settings.get_homeassistant_settings",
  731. new_callable=AsyncMock,
  732. ) as mock_ha_settings,
  733. ):
  734. mock_get_client.return_value = mock_client
  735. mock_ha_settings.return_value = {
  736. "ha_url": "http://ha.local:8123",
  737. "ha_token": "bad-token",
  738. "ha_enabled": True,
  739. }
  740. success, message = await service._send_homeassistant({}, "Test", "Test", db=mock_db)
  741. assert success is False
  742. assert "authentication" in message.lower()
  743. @pytest.mark.asyncio
  744. async def test_send_homeassistant_env_fallback(self, service):
  745. """Verify HA provider falls back to env vars when no DB session."""
  746. mock_response = MagicMock()
  747. mock_response.status_code = 200
  748. mock_client = AsyncMock()
  749. mock_client.post = AsyncMock(return_value=mock_response)
  750. with (
  751. patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
  752. patch.dict("os.environ", {"HA_URL": "http://env-ha:8123", "HA_TOKEN": "env-token"}),
  753. ):
  754. mock_get_client.return_value = mock_client
  755. success, message = await service._send_homeassistant({}, "Test", "Test", db=None)
  756. assert success is True
  757. call_args = mock_client.post.call_args
  758. assert "env-ha:8123" in call_args[0][0]
  759. @pytest.mark.asyncio
  760. async def test_send_homeassistant_empty_config_accepted(self, service):
  761. """Verify HA provider works with empty config dict (no fields needed)."""
  762. mock_response = MagicMock()
  763. mock_response.status_code = 200
  764. mock_client = AsyncMock()
  765. mock_client.post = AsyncMock(return_value=mock_response)
  766. mock_db = AsyncMock()
  767. with (
  768. patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
  769. patch(
  770. "backend.app.api.routes.settings.get_homeassistant_settings",
  771. new_callable=AsyncMock,
  772. ) as mock_ha_settings,
  773. ):
  774. mock_get_client.return_value = mock_client
  775. mock_ha_settings.return_value = {
  776. "ha_url": "http://ha.local:8123",
  777. "ha_token": "token",
  778. "ha_enabled": True,
  779. }
  780. success, _ = await service._send_homeassistant({}, "Title", "Body", db=mock_db)
  781. assert success is True
  782. @pytest.mark.asyncio
  783. async def test_send_to_provider_dispatches_homeassistant(self, service):
  784. """Verify _send_to_provider dispatches to _send_homeassistant."""
  785. provider = MagicMock()
  786. provider.provider_type = "homeassistant"
  787. provider.config = "{}"
  788. provider.quiet_hours_enabled = False
  789. with patch.object(service, "_send_homeassistant", new_callable=AsyncMock) as mock_send:
  790. mock_send.return_value = (True, "OK")
  791. success, _ = await service._send_to_provider(provider, "Title", "Message", db=AsyncMock())
  792. assert success is True
  793. mock_send.assert_called_once()
  794. class TestNotificationVariableFallbacks:
  795. """Tests for notification variable fallback values."""
  796. @pytest.fixture
  797. def service(self):
  798. return NotificationService()
  799. def test_format_duration_with_valid_seconds(self, service):
  800. """Verify duration formats correctly with valid input."""
  801. result = service._format_duration(3661) # 1h 1m 1s
  802. assert "1h" in result
  803. def test_format_duration_with_none_returns_unknown(self, service):
  804. """CRITICAL: Verify None duration returns 'Unknown' fallback."""
  805. result = service._format_duration(None)
  806. assert result == "Unknown"
  807. def test_format_duration_with_zero(self, service):
  808. """Verify zero duration formats correctly."""
  809. result = service._format_duration(0)
  810. # Should return some valid string, not "Unknown"
  811. assert result is not None
  812. assert isinstance(result, str)
  813. def test_format_duration_hours_and_minutes(self, service):
  814. """Verify duration formats hours and minutes."""
  815. result = service._format_duration(5400) # 1h 30m
  816. assert "1h" in result
  817. assert "30m" in result
  818. def test_format_duration_minutes_only(self, service):
  819. """Verify duration formats minutes only when < 1 hour."""
  820. result = service._format_duration(1800) # 30m
  821. assert "30m" in result or "30" in result
  822. @pytest.mark.asyncio
  823. async def test_print_complete_fallback_values(self, service):
  824. """CRITICAL: Verify fallback values when archive_data is missing."""
  825. mock_db = AsyncMock()
  826. with (
  827. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  828. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  829. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  830. ):
  831. mock_get.return_value = [] # No providers, just testing variable setup
  832. mock_build.return_value = ("Test", "Test")
  833. await service.on_print_complete(
  834. printer_id=1,
  835. printer_name="Test",
  836. status="completed",
  837. data={"subtask_name": "test_print"},
  838. db=mock_db,
  839. archive_data=None, # No archive data - should use fallbacks
  840. )
  841. # Test passes if no exception is raised with missing archive_data
  842. @pytest.mark.asyncio
  843. async def test_print_complete_with_archive_data(self, service):
  844. """Verify archive data values are used when provided."""
  845. mock_db = AsyncMock()
  846. captured_variables = {}
  847. async def capture_build(db, event_type, variables):
  848. captured_variables.update(variables)
  849. return ("Test", "Test")
  850. with (
  851. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  852. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  853. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  854. ):
  855. mock_get.return_value = []
  856. await service.on_print_complete(
  857. printer_id=1,
  858. printer_name="Test",
  859. status="completed",
  860. data={"subtask_name": "test_print"},
  861. db=mock_db,
  862. archive_data={
  863. "print_time_seconds": 3600,
  864. "actual_filament_grams": 50.5,
  865. },
  866. )
  867. # When archive data is provided, duration should not be "Unknown"
  868. if captured_variables.get("duration"):
  869. assert captured_variables["duration"] != "Unknown"
  870. @pytest.mark.asyncio
  871. async def test_duration_prefers_actual_time_seconds_over_slicer_estimate(self, service):
  872. """#1198: completion notification duration must reflect *actual* elapsed
  873. time from started_at/completed_at, not the slicer's pre-print estimate.
  874. Pre-fix the duration variable read from `print_time_seconds` (slicer
  875. estimate parsed from the 3MF at archive creation), so a print cancelled
  876. 2 minutes into a 3-hour estimate would notify "duration: 3h"."""
  877. mock_db = AsyncMock()
  878. mock_provider = MagicMock()
  879. mock_provider.id = 1
  880. captured_variables: dict = {}
  881. async def capture_build(db, event_type, variables):
  882. captured_variables.update(variables)
  883. return ("Test", "Test")
  884. with (
  885. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  886. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  887. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  888. ):
  889. mock_get.return_value = [mock_provider]
  890. await service.on_print_complete(
  891. printer_id=1,
  892. printer_name="Test",
  893. status="cancelled",
  894. data={"subtask_name": "test_print"},
  895. db=mock_db,
  896. archive_data={
  897. "print_time_seconds": 10800, # 3h slicer estimate
  898. "actual_time_seconds": 120, # 2m actual elapsed
  899. },
  900. )
  901. # 2 minutes — not 3 hours — even though the slicer estimate is in the dict.
  902. assert "2m" in captured_variables["duration"]
  903. assert "3h" not in captured_variables["duration"]
  904. @pytest.mark.asyncio
  905. async def test_duration_falls_back_to_slicer_estimate_when_actual_time_missing(self, service):
  906. """#1198: when actual_time_seconds is absent (e.g. timestamps weren't
  907. recorded for some reason), the duration variable falls back to
  908. print_time_seconds rather than rendering 'Unknown'. Preserves
  909. backwards-compat for any code path that didn't compute actual elapsed."""
  910. mock_db = AsyncMock()
  911. mock_provider = MagicMock()
  912. mock_provider.id = 1
  913. captured_variables: dict = {}
  914. async def capture_build(db, event_type, variables):
  915. captured_variables.update(variables)
  916. return ("Test", "Test")
  917. with (
  918. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  919. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  920. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  921. ):
  922. mock_get.return_value = [mock_provider]
  923. await service.on_print_complete(
  924. printer_id=1,
  925. printer_name="Test",
  926. status="completed",
  927. data={"subtask_name": "test_print"},
  928. db=mock_db,
  929. archive_data={
  930. "print_time_seconds": 3600, # 1h slicer estimate, no actual
  931. "actual_time_seconds": None,
  932. },
  933. )
  934. assert captured_variables["duration"] != "Unknown"
  935. assert "1h" in captured_variables["duration"]
  936. @pytest.mark.asyncio
  937. async def test_duration_unknown_when_both_time_fields_missing(self, service):
  938. """#1198: with neither actual nor estimated time available the duration
  939. variable surfaces the existing 'Unknown' fallback."""
  940. mock_db = AsyncMock()
  941. mock_provider = MagicMock()
  942. mock_provider.id = 1
  943. captured_variables: dict = {}
  944. async def capture_build(db, event_type, variables):
  945. captured_variables.update(variables)
  946. return ("Test", "Test")
  947. with (
  948. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  949. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  950. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  951. ):
  952. mock_get.return_value = [mock_provider]
  953. await service.on_print_complete(
  954. printer_id=1,
  955. printer_name="Test",
  956. status="completed",
  957. data={"subtask_name": "test_print"},
  958. db=mock_db,
  959. archive_data={
  960. "print_time_seconds": None,
  961. "actual_time_seconds": None,
  962. },
  963. )
  964. assert captured_variables["duration"] == "Unknown"
  965. @pytest.mark.asyncio
  966. async def test_print_complete_with_finish_photo_url(self, service):
  967. """Verify finish_photo_url is passed through from archive_data."""
  968. mock_db = AsyncMock()
  969. mock_provider = MagicMock()
  970. mock_provider.id = 1
  971. captured_variables = {}
  972. async def capture_build(db, event_type, variables):
  973. captured_variables.update(variables)
  974. return ("Test", "Test")
  975. with (
  976. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  977. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  978. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  979. ):
  980. mock_get.return_value = [mock_provider]
  981. await service.on_print_complete(
  982. printer_id=1,
  983. printer_name="Test",
  984. status="completed",
  985. data={"subtask_name": "test_print"},
  986. db=mock_db,
  987. archive_data={
  988. "print_time_seconds": 3600,
  989. "actual_filament_grams": 50.5,
  990. "finish_photo_url": "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg",
  991. },
  992. )
  993. # finish_photo_url should be passed through to template variables
  994. assert (
  995. captured_variables.get("finish_photo_url")
  996. == "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg"
  997. )
  998. @pytest.mark.asyncio
  999. async def test_print_start_estimated_time_fallback(self, service):
  1000. """Verify estimated time shows 'Unknown' when not available."""
  1001. mock_db = AsyncMock()
  1002. mock_provider = MagicMock()
  1003. mock_provider.id = 1
  1004. captured_variables = {}
  1005. async def capture_build(db, event_type, variables):
  1006. captured_variables.update(variables)
  1007. return ("Test", "Test")
  1008. with (
  1009. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1010. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1011. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1012. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1013. ):
  1014. # Need at least one provider to trigger message building
  1015. mock_get.return_value = [mock_provider]
  1016. await service.on_print_start(
  1017. printer_id=1,
  1018. printer_name="Test",
  1019. data={
  1020. "subtask_name": "test",
  1021. # No estimated_time or mc_remaining_time
  1022. },
  1023. db=mock_db,
  1024. )
  1025. # When no time data, should show "Unknown"
  1026. assert captured_variables.get("estimated_time") == "Unknown"
  1027. @pytest.mark.asyncio
  1028. async def test_print_progress_remaining_time_fallback(self, service):
  1029. """Verify remaining time shows 'Unknown' when not available."""
  1030. mock_db = AsyncMock()
  1031. mock_provider = MagicMock()
  1032. mock_provider.id = 1
  1033. captured_variables = {}
  1034. async def capture_build(db, event_type, variables):
  1035. captured_variables.update(variables)
  1036. return ("Test", "Test")
  1037. with (
  1038. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1039. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1040. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1041. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1042. ):
  1043. # Need at least one provider to trigger message building
  1044. mock_get.return_value = [mock_provider]
  1045. await service.on_print_progress(
  1046. printer_id=1,
  1047. printer_name="Test",
  1048. progress=50,
  1049. remaining_time=None, # No remaining time
  1050. filename="test.3mf",
  1051. db=mock_db,
  1052. )
  1053. # When no remaining time, should show "Unknown"
  1054. assert captured_variables.get("remaining_time") == "Unknown"
  1055. @pytest.mark.asyncio
  1056. async def test_filename_fallback_to_unknown(self, service):
  1057. """Verify filename defaults to 'Unknown' when not provided."""
  1058. mock_db = AsyncMock()
  1059. mock_provider = MagicMock()
  1060. mock_provider.id = 1
  1061. captured_variables = {}
  1062. async def capture_build(db, event_type, variables):
  1063. captured_variables.update(variables)
  1064. return ("Test", "Test")
  1065. with (
  1066. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1067. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1068. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1069. ):
  1070. # Need at least one provider to trigger message building
  1071. mock_get.return_value = [mock_provider]
  1072. await service.on_print_complete(
  1073. printer_id=1,
  1074. printer_name="Test",
  1075. status="completed",
  1076. data={}, # No subtask_name or filename
  1077. db=mock_db,
  1078. )
  1079. # Filename should default to something (either "Unknown" or cleaned empty)
  1080. assert "filename" in captured_variables
  1081. @pytest.mark.asyncio
  1082. async def test_print_start_uses_archive_print_time_seconds(self, service):
  1083. """Verify print_time_seconds from archive_data is used for estimated_time."""
  1084. mock_db = AsyncMock()
  1085. mock_provider = MagicMock()
  1086. mock_provider.id = 1
  1087. captured_variables = {}
  1088. async def capture_build(db, event_type, variables):
  1089. captured_variables.update(variables)
  1090. return ("Test", "Test")
  1091. with (
  1092. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1093. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1094. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1095. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1096. ):
  1097. mock_get.return_value = [mock_provider]
  1098. # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)
  1099. await service.on_print_start(
  1100. printer_id=1,
  1101. printer_name="Test",
  1102. data={"subtask_name": "test"},
  1103. db=mock_db,
  1104. archive_data={"print_time_seconds": 7200},
  1105. )
  1106. # Should use archive's print_time_seconds: 7200 seconds = 2h 0m
  1107. assert captured_variables.get("estimated_time") == "2h 0m"
  1108. @pytest.mark.asyncio
  1109. async def test_print_start_archive_data_overrides_mqtt(self, service):
  1110. """Verify archive_data takes priority over MQTT remaining_time."""
  1111. mock_db = AsyncMock()
  1112. mock_provider = MagicMock()
  1113. mock_provider.id = 1
  1114. captured_variables = {}
  1115. async def capture_build(db, event_type, variables):
  1116. captured_variables.update(variables)
  1117. return ("Test", "Test")
  1118. with (
  1119. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1120. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1121. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1122. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1123. ):
  1124. mock_get.return_value = [mock_provider]
  1125. # Both archive_data and MQTT remaining_time provided
  1126. # Archive says 2 hours, MQTT says 30 minutes (wrong at start)
  1127. await service.on_print_start(
  1128. printer_id=1,
  1129. printer_name="Test",
  1130. data={
  1131. "subtask_name": "test",
  1132. "remaining_time": 1800, # 30 minutes from MQTT
  1133. },
  1134. db=mock_db,
  1135. archive_data={"print_time_seconds": 7200}, # 2 hours from 3MF
  1136. )
  1137. # Should use archive's print_time_seconds (more reliable)
  1138. assert captured_variables.get("estimated_time") == "2h 0m"
  1139. @pytest.mark.asyncio
  1140. async def test_print_start_falls_back_to_mqtt_when_no_archive(self, service):
  1141. """Verify MQTT remaining_time is used when archive_data not provided."""
  1142. mock_db = AsyncMock()
  1143. mock_provider = MagicMock()
  1144. mock_provider.id = 1
  1145. captured_variables = {}
  1146. async def capture_build(db, event_type, variables):
  1147. captured_variables.update(variables)
  1148. return ("Test", "Test")
  1149. with (
  1150. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1151. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1152. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1153. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1154. ):
  1155. mock_get.return_value = [mock_provider]
  1156. # Only MQTT remaining_time provided (1800 seconds = 30 minutes)
  1157. await service.on_print_start(
  1158. printer_id=1,
  1159. printer_name="Test",
  1160. data={
  1161. "subtask_name": "test",
  1162. "remaining_time": 1800,
  1163. },
  1164. db=mock_db,
  1165. # No archive_data
  1166. )
  1167. # Should use MQTT remaining_time
  1168. assert captured_variables.get("estimated_time") == "30m"
  1169. @pytest.mark.asyncio
  1170. async def test_print_start_eta_calculated_from_estimated_time(self, service):
  1171. """Verify ETA is calculated as wall-clock time from estimated_time."""
  1172. mock_db = AsyncMock()
  1173. mock_provider = MagicMock()
  1174. mock_provider.id = 1
  1175. captured_variables = {}
  1176. async def capture_build(db, event_type, variables):
  1177. captured_variables.update(variables)
  1178. return ("Test", "Test")
  1179. with (
  1180. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1181. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1182. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1183. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1184. ):
  1185. mock_get.return_value = [mock_provider]
  1186. await service.on_print_start(
  1187. printer_id=1,
  1188. printer_name="Test",
  1189. data={"subtask_name": "test"},
  1190. db=mock_db,
  1191. archive_data={"print_time_seconds": 3600}, # 1 hour
  1192. )
  1193. # ETA should be a time string in HH:MM format
  1194. eta = captured_variables.get("eta")
  1195. assert eta is not None
  1196. assert eta != "Unknown"
  1197. assert ":" in eta # HH:MM format
  1198. @pytest.mark.asyncio
  1199. async def test_print_start_eta_unknown_when_no_time(self, service):
  1200. """Verify ETA shows 'Unknown' when no time data available."""
  1201. mock_db = AsyncMock()
  1202. mock_provider = MagicMock()
  1203. mock_provider.id = 1
  1204. captured_variables = {}
  1205. async def capture_build(db, event_type, variables):
  1206. captured_variables.update(variables)
  1207. return ("Test", "Test")
  1208. with (
  1209. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1210. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1211. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1212. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1213. ):
  1214. mock_get.return_value = [mock_provider]
  1215. await service.on_print_start(
  1216. printer_id=1,
  1217. printer_name="Test",
  1218. data={"subtask_name": "test"},
  1219. db=mock_db,
  1220. )
  1221. assert captured_variables.get("eta") == "Unknown"
  1222. @pytest.mark.asyncio
  1223. async def test_print_start_eta_respects_12h_format(self, service):
  1224. """Verify ETA uses 12-hour format when time_format is '12h'."""
  1225. mock_db = AsyncMock()
  1226. mock_provider = MagicMock()
  1227. mock_provider.id = 1
  1228. captured_variables = {}
  1229. async def capture_build(db, event_type, variables):
  1230. captured_variables.update(variables)
  1231. return ("Test", "Test")
  1232. with (
  1233. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1234. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1235. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1236. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value="12h"),
  1237. ):
  1238. mock_get.return_value = [mock_provider]
  1239. await service.on_print_start(
  1240. printer_id=1,
  1241. printer_name="Test",
  1242. data={"subtask_name": "test"},
  1243. db=mock_db,
  1244. archive_data={"print_time_seconds": 3600},
  1245. )
  1246. eta = captured_variables.get("eta")
  1247. assert eta is not None
  1248. # 12h format should contain AM or PM
  1249. assert "AM" in eta or "PM" in eta
  1250. class TestNotificationTemplates:
  1251. """Tests for notification message template rendering."""
  1252. @pytest.fixture
  1253. def service(self):
  1254. return NotificationService()
  1255. @pytest.mark.asyncio
  1256. async def test_template_renders_variables(self, service):
  1257. """Verify template variables are replaced correctly."""
  1258. template_title = "Print {progress}% Complete"
  1259. template_body = "{printer}: {filename}\nRemaining: {remaining_time}"
  1260. variables = {
  1261. "printer": "Test Printer",
  1262. "filename": "test.3mf",
  1263. "progress": "50",
  1264. "remaining_time": "1h 30m",
  1265. }
  1266. title = template_title.format(**variables)
  1267. body = template_body.format(**variables)
  1268. assert title == "Print 50% Complete"
  1269. assert "Test Printer" in body
  1270. assert "test.3mf" in body
  1271. assert "1h 30m" in body
  1272. @pytest.mark.asyncio
  1273. async def test_template_handles_missing_variables(self, service):
  1274. """Verify missing template variables don't cause crashes."""
  1275. template = "{printer}: {unknown_var}"
  1276. variables = {"printer": "Test"}
  1277. # Should handle gracefully - either leave placeholder or skip
  1278. try:
  1279. result = template.format_map({**variables, "unknown_var": "{unknown_var}"})
  1280. assert "Test" in result
  1281. except KeyError:
  1282. pytest.fail("Template should handle missing variables gracefully")
  1283. class TestPrinterErrorNotifications:
  1284. """Tests for HMS error (printer error) notifications."""
  1285. @pytest.fixture
  1286. def service(self):
  1287. return NotificationService()
  1288. @pytest.fixture
  1289. def mock_provider(self):
  1290. """Create a mock notification provider with error notifications enabled."""
  1291. provider = MagicMock()
  1292. provider.id = 1
  1293. provider.name = "Test Provider"
  1294. provider.provider_type = "webhook"
  1295. provider.enabled = True
  1296. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  1297. provider.on_printer_error = True # Enable error notifications
  1298. provider.quiet_hours_enabled = False
  1299. provider.daily_digest_enabled = False
  1300. provider.printer_id = None
  1301. return provider
  1302. @pytest.fixture
  1303. def mock_db(self):
  1304. """Create a mock database session."""
  1305. db = AsyncMock()
  1306. db.commit = AsyncMock()
  1307. return db
  1308. @pytest.mark.asyncio
  1309. async def test_on_printer_error_sends_notification(self, service, mock_provider, mock_db):
  1310. """Verify HMS error notification is sent when triggered."""
  1311. with (
  1312. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1313. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1314. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1315. ):
  1316. mock_get.return_value = [mock_provider]
  1317. mock_build.return_value = ("Printer Error", "AMS/Filament Error: 0700_8010")
  1318. await service.on_printer_error(
  1319. printer_id=1,
  1320. printer_name="Test Printer",
  1321. error_type="AMS/Filament Error",
  1322. db=mock_db,
  1323. error_detail="Error code: 0700_8010",
  1324. )
  1325. mock_get.assert_called_once()
  1326. mock_send.assert_called_once()
  1327. @pytest.mark.asyncio
  1328. async def test_on_printer_error_skipped_when_disabled(self, service, mock_provider, mock_db):
  1329. """CRITICAL: Verify error notifications respect toggle setting."""
  1330. mock_provider.on_printer_error = False
  1331. with (
  1332. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1333. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1334. ):
  1335. # Provider with toggle disabled won't be returned
  1336. mock_get.return_value = []
  1337. await service.on_printer_error(
  1338. printer_id=1,
  1339. printer_name="Test",
  1340. error_type="AMS Error",
  1341. db=mock_db,
  1342. error_detail="Test error",
  1343. )
  1344. mock_send.assert_not_called()
  1345. @pytest.mark.asyncio
  1346. async def test_on_printer_error_includes_error_detail(self, service, mock_provider, mock_db):
  1347. """Verify error details are passed to template variables."""
  1348. captured_variables = {}
  1349. async def capture_build(db, event_type, variables):
  1350. captured_variables.update(variables)
  1351. return ("Test", "Test")
  1352. with (
  1353. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1354. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1355. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1356. ):
  1357. mock_get.return_value = [mock_provider]
  1358. await service.on_printer_error(
  1359. printer_id=1,
  1360. printer_name="X1 Carbon",
  1361. error_type="AMS/Filament Error",
  1362. db=mock_db,
  1363. error_detail="Error code: 0700_8010",
  1364. )
  1365. assert captured_variables["printer"] == "X1 Carbon"
  1366. assert captured_variables["error_type"] == "AMS/Filament Error"
  1367. assert captured_variables["error_detail"] == "Error code: 0700_8010"
  1368. @pytest.mark.asyncio
  1369. async def test_on_printer_error_fallback_when_no_detail(self, service, mock_provider, mock_db):
  1370. """Verify fallback message when error_detail is None."""
  1371. captured_variables = {}
  1372. async def capture_build(db, event_type, variables):
  1373. captured_variables.update(variables)
  1374. return ("Test", "Test")
  1375. with (
  1376. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1377. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1378. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1379. ):
  1380. mock_get.return_value = [mock_provider]
  1381. await service.on_printer_error(
  1382. printer_id=1,
  1383. printer_name="Test Printer",
  1384. error_type="Unknown Error",
  1385. db=mock_db,
  1386. error_detail=None, # No detail provided
  1387. )
  1388. assert captured_variables["error_detail"] == "No details available"
  1389. class TestAIFailureDetectionNotifications:
  1390. """Tests for the AI failure-detection event (#1794 — split out of on_printer_error).
  1391. Pins that Obico failure-detection dispatches go through the dedicated
  1392. on_ai_failure_detection event field, not the multiplexed printer-error
  1393. field. Mirrors the printer-error coverage above so a regression on either
  1394. surface fails its own case.
  1395. """
  1396. @pytest.fixture
  1397. def service(self):
  1398. return NotificationService()
  1399. @pytest.fixture
  1400. def mock_provider(self):
  1401. provider = MagicMock()
  1402. provider.id = 1
  1403. provider.name = "Test Provider"
  1404. provider.provider_type = "webhook"
  1405. provider.enabled = True
  1406. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  1407. provider.on_ai_failure_detection = True
  1408. provider.on_printer_error = False # disabled — the regression guard
  1409. provider.quiet_hours_enabled = False
  1410. provider.daily_digest_enabled = False
  1411. provider.printer_id = None
  1412. return provider
  1413. @pytest.fixture
  1414. def mock_db(self):
  1415. db = AsyncMock()
  1416. db.commit = AsyncMock()
  1417. return db
  1418. @pytest.mark.asyncio
  1419. async def test_dispatch_uses_ai_failure_detection_event_not_printer_error(self, service, mock_provider, mock_db):
  1420. """Regression guard: provider subscribed only to AI alerts must receive
  1421. the Obico notification."""
  1422. captured_event = []
  1423. async def capture(db, event_field, printer_id):
  1424. captured_event.append(event_field)
  1425. return [mock_provider]
  1426. with (
  1427. patch.object(service, "_get_providers_for_event", side_effect=capture),
  1428. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1429. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1430. ):
  1431. mock_build.return_value = ("Possible Print Failure Detected", "details")
  1432. await service.on_ai_failure_detection(
  1433. printer_id=1,
  1434. printer_name="X1 Carbon",
  1435. task_name="benchy.3mf",
  1436. confidence=0.87,
  1437. action="notify",
  1438. db=mock_db,
  1439. )
  1440. assert captured_event == ["on_ai_failure_detection"]
  1441. mock_send.assert_called_once()
  1442. @pytest.mark.asyncio
  1443. async def test_skipped_when_only_printer_error_is_enabled(self, service, mock_provider, mock_db):
  1444. """Pre-#1794 behaviour MUST NOT survive: a provider with only the
  1445. legacy on_printer_error toggle should NOT receive AI notifications now."""
  1446. mock_provider.on_ai_failure_detection = False
  1447. mock_provider.on_printer_error = True
  1448. with (
  1449. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1450. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1451. ):
  1452. mock_get.return_value = [] # the event-field filter excludes the provider
  1453. await service.on_ai_failure_detection(
  1454. printer_id=1,
  1455. printer_name="X1 Carbon",
  1456. task_name="benchy.3mf",
  1457. confidence=0.87,
  1458. action="notify",
  1459. db=mock_db,
  1460. )
  1461. mock_send.assert_not_called()
  1462. @pytest.mark.asyncio
  1463. async def test_variables_include_task_name_confidence_action(self, service, mock_provider, mock_db):
  1464. captured_variables = {}
  1465. async def capture_build(db, event_type, variables):
  1466. captured_variables.update(variables)
  1467. return ("Test", "Test")
  1468. with (
  1469. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1470. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1471. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1472. ):
  1473. mock_get.return_value = [mock_provider]
  1474. await service.on_ai_failure_detection(
  1475. printer_id=1,
  1476. printer_name="X1 Carbon",
  1477. task_name="benchy.3mf",
  1478. confidence=0.873,
  1479. action="pause_and_off",
  1480. db=mock_db,
  1481. )
  1482. assert captured_variables["printer"] == "X1 Carbon"
  1483. assert captured_variables["task_name"] == "benchy.3mf"
  1484. assert captured_variables["confidence"] == "0.87" # 2-decimal format
  1485. assert captured_variables["action"] == "pause_and_off"
  1486. @pytest.mark.asyncio
  1487. async def test_task_name_fallback_when_unknown(self, service, mock_provider, mock_db):
  1488. captured_variables = {}
  1489. async def capture_build(db, event_type, variables):
  1490. captured_variables.update(variables)
  1491. return ("Test", "Test")
  1492. with (
  1493. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1494. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1495. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1496. ):
  1497. mock_get.return_value = [mock_provider]
  1498. await service.on_ai_failure_detection(
  1499. printer_id=1,
  1500. printer_name="Test",
  1501. task_name="", # empty
  1502. confidence=0.5,
  1503. action="notify",
  1504. db=mock_db,
  1505. )
  1506. assert captured_variables["task_name"] == "current job"
  1507. class TestPlateNotEmptyNotifications:
  1508. """Tests for plate not empty (build plate detection) notifications."""
  1509. @pytest.fixture
  1510. def service(self):
  1511. return NotificationService()
  1512. @pytest.fixture
  1513. def mock_provider(self):
  1514. """Create a mock notification provider with plate detection enabled."""
  1515. provider = MagicMock()
  1516. provider.id = 1
  1517. provider.name = "Test Provider"
  1518. provider.provider_type = "webhook"
  1519. provider.enabled = True
  1520. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  1521. provider.on_plate_not_empty = True
  1522. provider.quiet_hours_enabled = False
  1523. provider.daily_digest_enabled = False
  1524. provider.printer_id = None
  1525. return provider
  1526. @pytest.fixture
  1527. def mock_db(self):
  1528. """Create a mock database session."""
  1529. db = AsyncMock()
  1530. db.commit = AsyncMock()
  1531. return db
  1532. @pytest.mark.asyncio
  1533. async def test_on_plate_not_empty_sends_notification(self, service, mock_provider, mock_db):
  1534. """Verify plate not empty notification is sent when triggered."""
  1535. with (
  1536. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1537. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1538. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1539. ):
  1540. mock_get.return_value = [mock_provider]
  1541. mock_build.return_value = ("Plate Not Empty", "Objects detected on build plate")
  1542. await service.on_plate_not_empty(
  1543. printer_id=1,
  1544. printer_name="Test Printer",
  1545. db=mock_db,
  1546. difference_percent=5.2,
  1547. )
  1548. mock_get.assert_called_once()
  1549. mock_send.assert_called_once()
  1550. # Verify force_immediate is True (critical alert)
  1551. call_kwargs = mock_send.call_args[1]
  1552. assert call_kwargs.get("force_immediate") is True
  1553. @pytest.mark.asyncio
  1554. async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):
  1555. """Verify notification is skipped when toggle is disabled."""
  1556. mock_provider.on_plate_not_empty = False
  1557. with (
  1558. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1559. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1560. ):
  1561. mock_get.return_value = []
  1562. await service.on_plate_not_empty(
  1563. printer_id=1,
  1564. printer_name="Test",
  1565. db=mock_db,
  1566. )
  1567. mock_send.assert_not_called()
  1568. @pytest.mark.asyncio
  1569. async def test_on_plate_not_empty_includes_difference_percent(self, service, mock_provider, mock_db):
  1570. """Verify difference percentage is passed to template variables."""
  1571. captured_variables = {}
  1572. async def capture_build(db, event_type, variables):
  1573. captured_variables.update(variables)
  1574. return ("Test", "Test")
  1575. with (
  1576. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1577. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1578. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1579. ):
  1580. mock_get.return_value = [mock_provider]
  1581. await service.on_plate_not_empty(
  1582. printer_id=1,
  1583. printer_name="X1 Carbon",
  1584. db=mock_db,
  1585. difference_percent=3.5,
  1586. )
  1587. assert captured_variables["printer"] == "X1 Carbon"
  1588. assert captured_variables["difference_percent"] == "3.5"
  1589. class TestBedCooledNotifications:
  1590. """Tests for bed cooled (after print) notifications."""
  1591. @pytest.fixture
  1592. def service(self):
  1593. return NotificationService()
  1594. @pytest.fixture
  1595. def mock_provider(self):
  1596. """Create a mock notification provider with bed cooled enabled."""
  1597. provider = MagicMock()
  1598. provider.id = 1
  1599. provider.name = "Test Provider"
  1600. provider.provider_type = "webhook"
  1601. provider.enabled = True
  1602. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  1603. provider.on_bed_cooled = True
  1604. provider.quiet_hours_enabled = False
  1605. provider.daily_digest_enabled = False
  1606. provider.printer_id = None
  1607. return provider
  1608. @pytest.fixture
  1609. def mock_db(self):
  1610. """Create a mock database session."""
  1611. db = AsyncMock()
  1612. db.commit = AsyncMock()
  1613. return db
  1614. @pytest.mark.asyncio
  1615. async def test_on_bed_cooled_sends_notification(self, service, mock_provider, mock_db):
  1616. """Verify bed cooled notification is sent when triggered."""
  1617. with (
  1618. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1619. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1620. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1621. ):
  1622. mock_get.return_value = [mock_provider]
  1623. mock_build.return_value = ("Bed Cooled", "Test Printer: Bed cooled to 30°C")
  1624. await service.on_bed_cooled(
  1625. printer_id=1,
  1626. printer_name="Test Printer",
  1627. bed_temp=30.0,
  1628. threshold=35.0,
  1629. filename="benchy.3mf",
  1630. db=mock_db,
  1631. )
  1632. mock_get.assert_called_once()
  1633. mock_send.assert_called_once()
  1634. @pytest.mark.asyncio
  1635. async def test_on_bed_cooled_skipped_when_no_providers(self, service, mock_db):
  1636. """Verify notification is skipped when no providers have bed cooled enabled."""
  1637. with (
  1638. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1639. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1640. ):
  1641. mock_get.return_value = []
  1642. await service.on_bed_cooled(
  1643. printer_id=1,
  1644. printer_name="Test Printer",
  1645. bed_temp=30.0,
  1646. threshold=35.0,
  1647. filename="benchy.3mf",
  1648. db=mock_db,
  1649. )
  1650. mock_send.assert_not_called()
  1651. @pytest.mark.asyncio
  1652. async def test_on_bed_cooled_includes_correct_variables(self, service, mock_provider, mock_db):
  1653. """Verify bed temp, threshold, and filename are passed to template variables."""
  1654. captured_variables = {}
  1655. async def capture_build(db, event_type, variables):
  1656. captured_variables.update(variables)
  1657. return ("Test", "Test")
  1658. with (
  1659. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1660. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1661. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1662. ):
  1663. mock_get.return_value = [mock_provider]
  1664. await service.on_bed_cooled(
  1665. printer_id=1,
  1666. printer_name="X1 Carbon",
  1667. bed_temp=28.7,
  1668. threshold=35.0,
  1669. filename="benchy.gcode.3mf",
  1670. db=mock_db,
  1671. )
  1672. assert captured_variables["printer"] == "X1 Carbon"
  1673. assert captured_variables["bed_temp"] == "29"
  1674. assert captured_variables["threshold"] == "35"
  1675. assert captured_variables["filename"] == "benchy"
  1676. @pytest.mark.asyncio
  1677. async def test_on_bed_cooled_handles_none_filename(self, service, mock_provider, mock_db):
  1678. """Verify None filename is handled gracefully."""
  1679. captured_variables = {}
  1680. async def capture_build(db, event_type, variables):
  1681. captured_variables.update(variables)
  1682. return ("Test", "Test")
  1683. with (
  1684. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1685. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1686. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1687. ):
  1688. mock_get.return_value = [mock_provider]
  1689. await service.on_bed_cooled(
  1690. printer_id=1,
  1691. printer_name="Test Printer",
  1692. bed_temp=30.0,
  1693. threshold=35.0,
  1694. filename=None,
  1695. db=mock_db,
  1696. )
  1697. assert captured_variables["filename"] == "Unknown"
  1698. class TestFirstLayerCompleteNotifications:
  1699. """Tests for first layer complete notifications."""
  1700. @pytest.fixture
  1701. def service(self):
  1702. return NotificationService()
  1703. @pytest.fixture
  1704. def mock_provider(self):
  1705. """Create a mock notification provider with first layer complete enabled."""
  1706. provider = MagicMock()
  1707. provider.id = 1
  1708. provider.name = "Test Provider"
  1709. provider.provider_type = "webhook"
  1710. provider.enabled = True
  1711. provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
  1712. provider.on_first_layer_complete = True
  1713. provider.quiet_hours_enabled = False
  1714. provider.daily_digest_enabled = False
  1715. provider.printer_id = None
  1716. return provider
  1717. @pytest.fixture
  1718. def mock_db(self):
  1719. """Create a mock database session."""
  1720. db = AsyncMock()
  1721. db.commit = AsyncMock()
  1722. return db
  1723. @pytest.mark.asyncio
  1724. async def test_on_first_layer_complete_sends_notification(self, service, mock_provider, mock_db):
  1725. """Verify first layer complete notification is sent when triggered."""
  1726. with (
  1727. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1728. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1729. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1730. ):
  1731. mock_get.return_value = [mock_provider]
  1732. mock_build.return_value = ("First Layer Complete", "Test Printer: benchy.3mf")
  1733. await service.on_first_layer_complete(
  1734. printer_id=1,
  1735. printer_name="Test Printer",
  1736. filename="benchy.3mf",
  1737. total_layers=50,
  1738. db=mock_db,
  1739. )
  1740. mock_get.assert_called_once()
  1741. mock_send.assert_called_once()
  1742. @pytest.mark.asyncio
  1743. async def test_on_first_layer_complete_skipped_when_no_providers(self, service, mock_db):
  1744. """Verify notification is skipped when no providers have first layer complete enabled."""
  1745. with (
  1746. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1747. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1748. ):
  1749. mock_get.return_value = []
  1750. await service.on_first_layer_complete(
  1751. printer_id=1,
  1752. printer_name="Test Printer",
  1753. filename="benchy.3mf",
  1754. total_layers=50,
  1755. db=mock_db,
  1756. )
  1757. mock_send.assert_not_called()
  1758. @pytest.mark.asyncio
  1759. async def test_on_first_layer_complete_includes_correct_variables(self, service, mock_provider, mock_db):
  1760. """Verify printer name, filename, and total_layers are passed to template variables."""
  1761. captured_variables = {}
  1762. async def capture_build(db, event_type, variables):
  1763. captured_variables.update(variables)
  1764. return ("Test", "Test")
  1765. with (
  1766. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1767. patch.object(service, "_send_to_providers", new_callable=AsyncMock),
  1768. patch.object(service, "_build_message_from_template", side_effect=capture_build),
  1769. ):
  1770. mock_get.return_value = [mock_provider]
  1771. await service.on_first_layer_complete(
  1772. printer_id=1,
  1773. printer_name="X1 Carbon",
  1774. filename="benchy.gcode.3mf",
  1775. total_layers=120,
  1776. db=mock_db,
  1777. )
  1778. assert captured_variables["printer"] == "X1 Carbon"
  1779. assert captured_variables["filename"] == "benchy"
  1780. assert captured_variables["total_layers"] == "120"
  1781. @pytest.mark.asyncio
  1782. async def test_on_first_layer_complete_passes_image_data(self, service, mock_provider, mock_db):
  1783. """Verify image_data is passed through to _send_to_providers."""
  1784. with (
  1785. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  1786. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  1787. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  1788. ):
  1789. mock_get.return_value = [mock_provider]
  1790. mock_build.return_value = ("First Layer Complete", "Test message")
  1791. fake_image = b"\x89PNG\r\n\x1a\nfakeimage"
  1792. await service.on_first_layer_complete(
  1793. printer_id=1,
  1794. printer_name="Test Printer",
  1795. filename="benchy.3mf",
  1796. total_layers=50,
  1797. db=mock_db,
  1798. image_data=fake_image,
  1799. )
  1800. mock_send.assert_called_once()
  1801. call_kwargs = mock_send.call_args
  1802. assert call_kwargs.kwargs.get("image_data") == fake_image
  1803. class TestNtfyOutbound:
  1804. """Regression for #1534 — UA hygiene and Cloudflare-challenge detection."""
  1805. @pytest.fixture
  1806. def service(self):
  1807. return NotificationService()
  1808. @pytest.mark.asyncio
  1809. async def test_notification_client_sets_honest_user_agent(self, service):
  1810. """Default httpx UA leaks `python-httpx/<version>` — every other
  1811. outbound client in the codebase identifies as Bambuddy. The
  1812. notification client must too."""
  1813. client = await service._get_client()
  1814. try:
  1815. assert client.headers.get("user-agent") == "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
  1816. finally:
  1817. await service.close()
  1818. @pytest.mark.asyncio
  1819. async def test_ntfy_cloudflare_challenge_returns_actionable_error(self, service):
  1820. """When ntfy is fronted by Cloudflare and CF returns its JS
  1821. challenge, the user must see a message that points at the actual
  1822. fix (CF security skip), not the raw HTML."""
  1823. import httpx
  1824. challenge_html = (
  1825. '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title>'
  1826. '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
  1827. )
  1828. mock_response = httpx.Response(
  1829. 403,
  1830. content=challenge_html.encode(),
  1831. headers={"server": "cloudflare", "content-type": "text/html; charset=UTF-8"},
  1832. )
  1833. mock_client = AsyncMock()
  1834. mock_client.post = AsyncMock(return_value=mock_response)
  1835. with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
  1836. ok, detail = await service._send_ntfy(
  1837. {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "tk_xxx"},
  1838. title="t",
  1839. message="m",
  1840. )
  1841. assert ok is False
  1842. assert "Cloudflare" in detail
  1843. assert "security-skip" in detail or "Bot Fight Mode" in detail
  1844. # The raw HTML must not be the dominant content shown to the user.
  1845. assert "<!DOCTYPE" not in detail
  1846. @pytest.mark.asyncio
  1847. async def test_ntfy_normal_403_still_surfaces_body(self, service):
  1848. """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
  1849. the original body so the user can debug the real error — we
  1850. only intercept the Cloudflare-challenge shape."""
  1851. import httpx
  1852. mock_response = httpx.Response(
  1853. 403,
  1854. content=b"forbidden: invalid auth token",
  1855. headers={"content-type": "text/plain"},
  1856. )
  1857. mock_client = AsyncMock()
  1858. mock_client.post = AsyncMock(return_value=mock_response)
  1859. with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
  1860. ok, detail = await service._send_ntfy(
  1861. {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
  1862. title="t",
  1863. message="m",
  1864. )
  1865. assert ok is False
  1866. assert "Cloudflare" not in detail
  1867. assert "invalid auth token" in detail
  1868. assert detail.startswith("HTTP 403:")
  1869. @pytest.mark.asyncio
  1870. async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
  1871. """Cloudflare adds Server: cloudflare to EVERY proxied response,
  1872. including legitimate origin errors. A real 401 "wrong token"
  1873. from an ntfy server that happens to sit behind Cloudflare must
  1874. still surface the origin's actual error body — we must not flip
  1875. every CF-fronted 4xx into a "your Cloudflare is blocking" message.
  1876. """
  1877. import httpx
  1878. mock_response = httpx.Response(
  1879. 401,
  1880. content=b'{"code":40101,"http":401,"error":"unauthorized"}',
  1881. headers={
  1882. "server": "cloudflare",
  1883. "cf-ray": "abc123-FRA",
  1884. "content-type": "application/json",
  1885. # No cf-mitigated — CF just proxied the origin response.
  1886. },
  1887. )
  1888. mock_client = AsyncMock()
  1889. mock_client.post = AsyncMock(return_value=mock_response)
  1890. with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
  1891. ok, detail = await service._send_ntfy(
  1892. {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
  1893. title="t",
  1894. message="m",
  1895. )
  1896. assert ok is False
  1897. assert "Cloudflare" not in detail
  1898. assert "unauthorized" in detail
  1899. assert detail.startswith("HTTP 401:")
  1900. @pytest.mark.asyncio
  1901. async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):
  1902. """The cf-mitigated header on its own is enough — that's the
  1903. canonical CF "I actively blocked this" signal, even if the
  1904. response body shape changes between CF challenge generations."""
  1905. import httpx
  1906. mock_response = httpx.Response(
  1907. 403,
  1908. content=b"<html>some future CF block page</html>",
  1909. headers={
  1910. "server": "cloudflare",
  1911. "cf-mitigated": "challenge",
  1912. "content-type": "text/html",
  1913. },
  1914. )
  1915. mock_client = AsyncMock()
  1916. mock_client.post = AsyncMock(return_value=mock_response)
  1917. with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
  1918. ok, detail = await service._send_ntfy(
  1919. {"server": "https://ntfy.example", "topic": "alerts"},
  1920. title="t",
  1921. message="m",
  1922. )
  1923. assert ok is False
  1924. assert "Cloudflare" in detail
  1925. class TestEmailProvider:
  1926. """Tests for SMTP email provider, including #1792 finish-photo inline embed.
  1927. Embed is opt-in via the template: only when the user's template referenced
  1928. ``{finish_photo_url}`` (so the URL appears in the rendered body) AND the
  1929. photo bytes are available does ``_send_email`` build the multipart/related
  1930. shape. Otherwise it stays single-part text — no surprise inline image.
  1931. """
  1932. PHOTO_URL = "https://printer.local/api/v1/archives/42/photos/finish.jpg"
  1933. @pytest.fixture
  1934. def service(self):
  1935. return NotificationService()
  1936. @pytest.fixture
  1937. def smtp_config(self):
  1938. return {
  1939. "smtp_server": "smtp.example.com",
  1940. "smtp_port": "587",
  1941. "username": "alice",
  1942. "password": "secret",
  1943. "from_email": "bambuddy@example.com",
  1944. "to_email": "alice@example.com",
  1945. "security": "starttls",
  1946. "auth_enabled": "true",
  1947. }
  1948. @staticmethod
  1949. def _fake_smtp_class(captured: dict):
  1950. class FakeSMTP:
  1951. # timeout matches the real smtplib.SMTP/SMTP_SSL signature — the
  1952. # service passes an explicit timeout so a wedged relay can't hang
  1953. # the send (#2572).
  1954. def __init__(self, host, port, timeout=None):
  1955. captured["host"] = host
  1956. captured["port"] = port
  1957. captured["timeout"] = timeout
  1958. def starttls(self):
  1959. captured["starttls"] = True
  1960. def login(self, u, p):
  1961. captured["login"] = (u, p)
  1962. def sendmail(self, frm, to, body):
  1963. captured["from"] = frm
  1964. captured["to"] = to
  1965. captured["raw"] = body
  1966. def quit(self):
  1967. captured["quit"] = True
  1968. return FakeSMTP
  1969. @pytest.mark.asyncio
  1970. async def test_email_without_image_or_url_stays_text_only(self, service, smtp_config):
  1971. """No image_data and no URL in body → original single-part text shape."""
  1972. captured: dict = {}
  1973. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  1974. ok, _ = await service._send_email(smtp_config, "Print Failed", "Reason: unknown")
  1975. assert ok is True
  1976. assert "image/jpeg" not in captured["raw"]
  1977. assert "multipart/related" not in captured["raw"]
  1978. assert "cid:bambuddy-finish-photo" not in captured["raw"]
  1979. assert "Reason: unknown" in captured["raw"]
  1980. @pytest.mark.asyncio
  1981. async def test_email_image_without_template_reference_stays_text_only(self, service, smtp_config):
  1982. """image_data present but template didn't include {finish_photo_url} → no embed.
  1983. Pins the template-driven contract: a user whose body is just
  1984. "Print failed. Reason: unknown" does NOT get a surprise inline image
  1985. stapled to the bottom, even though the photo bytes are available
  1986. upstream from the archive.
  1987. """
  1988. captured: dict = {}
  1989. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  1990. ok, _ = await service._send_email(
  1991. smtp_config,
  1992. "Print Failed",
  1993. "Reason: unknown",
  1994. image_data=b"\xff\xd8\xff\xe0jpeg",
  1995. finish_photo_url=self.PHOTO_URL,
  1996. )
  1997. assert ok is True
  1998. raw = captured["raw"]
  1999. assert "image/jpeg" not in raw
  2000. assert "multipart/related" not in raw
  2001. assert "cid:bambuddy-finish-photo" not in raw
  2002. @pytest.mark.asyncio
  2003. async def test_email_inlines_when_template_uses_finish_photo_url(self, service, smtp_config):
  2004. """URL in body + image_data present → multipart/related + cid embed; HTML swaps URL for <img>."""
  2005. captured: dict = {}
  2006. body = f"Print failed. Reason: unknown\n\nSnapshot: {self.PHOTO_URL}"
  2007. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  2008. ok, _ = await service._send_email(
  2009. smtp_config,
  2010. "Print Failed",
  2011. body,
  2012. image_data=b"\xff\xd8\xff\xe0fake-jpeg-bytes",
  2013. finish_photo_url=self.PHOTO_URL,
  2014. )
  2015. assert ok is True
  2016. raw = captured["raw"]
  2017. # multipart/related shape with both alt parts and an image part
  2018. assert "multipart/related" in raw
  2019. assert "multipart/alternative" in raw
  2020. assert "text/plain" in raw
  2021. assert "text/html" in raw
  2022. assert "image/jpeg" in raw
  2023. # HTML references the exact cid the Content-ID header registers
  2024. assert "Content-ID: <bambuddy-finish-photo>" in raw
  2025. assert 'src="cid:bambuddy-finish-photo"' in raw
  2026. # Inline disposition so renders embedded, not as download attachment
  2027. assert 'Content-Disposition: inline; filename="finish-photo.jpg"' in raw
  2028. # Plain-text body keeps the URL so non-HTML clients still get a clickable link
  2029. assert self.PHOTO_URL in raw
  2030. @pytest.mark.asyncio
  2031. async def test_email_image_data_without_url_arg_stays_text_only(self, service, smtp_config):
  2032. """image_data passed but finish_photo_url=None → defence-in-depth, no embed.
  2033. Even if a future caller forgets to thread the URL through but does pass
  2034. the bytes, the conservative default is no embed (avoids attaching an
  2035. unreferenced image to an unrelated event type).
  2036. """
  2037. captured: dict = {}
  2038. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  2039. ok, _ = await service._send_email(
  2040. smtp_config,
  2041. "Print Failed",
  2042. f"Snapshot: {self.PHOTO_URL}",
  2043. image_data=b"\xff\xd8\xff\xe0jpeg",
  2044. finish_photo_url=None,
  2045. )
  2046. assert ok is True
  2047. assert "image/jpeg" not in captured["raw"]
  2048. assert "multipart/related" not in captured["raw"]
  2049. @pytest.mark.asyncio
  2050. async def test_email_html_body_escapes_user_content(self, service, smtp_config):
  2051. """Template-rendered body must not be injected raw into the HTML part."""
  2052. captured: dict = {}
  2053. body = f"Filename: <script>alert(1)</script>\nLine 2\nSnapshot: {self.PHOTO_URL}"
  2054. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  2055. ok, _ = await service._send_email(
  2056. smtp_config,
  2057. "Print Failed",
  2058. body,
  2059. image_data=b"\xff\xd8\xff\xe0jpeg",
  2060. finish_photo_url=self.PHOTO_URL,
  2061. )
  2062. assert ok is True
  2063. raw = captured["raw"]
  2064. # Raw HTML must NOT round-trip into the HTML part — verify escaped form is present.
  2065. assert "&lt;script&gt;alert(1)&lt;/script&gt;" in raw
  2066. # Newlines in the body become <br> in HTML
  2067. assert "Line 2" in raw
  2068. assert "<br>" in raw
  2069. @pytest.mark.asyncio
  2070. async def test_email_html_swaps_url_for_img_tag(self, service, smtp_config):
  2071. """In the HTML part, the URL substring is replaced with the <img cid:...> tag.
  2072. Plain text keeps the URL; HTML clients see the inline image where the
  2073. URL was. The URL must NOT appear inside an <a href> wrapping the image
  2074. — we replace the URL outright with the img tag (renderers don't need
  2075. the URL twice in the HTML part when the image is already inline).
  2076. """
  2077. captured: dict = {}
  2078. body = f"See: {self.PHOTO_URL} for the snapshot."
  2079. with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
  2080. ok, _ = await service._send_email(
  2081. smtp_config,
  2082. "Print Failed",
  2083. body,
  2084. image_data=b"\xff\xd8\xff\xe0jpeg",
  2085. finish_photo_url=self.PHOTO_URL,
  2086. )
  2087. assert ok is True
  2088. raw = captured["raw"]
  2089. # The <img> tag appears in the HTML part
  2090. assert 'src="cid:bambuddy-finish-photo"' in raw
  2091. # The escaped URL is the marker we replaced — the HTML part should not
  2092. # contain BOTH the escaped URL AND the cid img (we swapped, not duplicated).
  2093. # The plain-text part still has the URL; check it's there at least once.
  2094. assert self.PHOTO_URL in raw