test_notification_service.py 122 KB

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