test_filament_low_producer_2913.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. """The "Low Filament" notification has a producer now (issue #2913).
  2. Every layer of this feature existed -- a column on notification_providers, a
  3. schema field, a route, a message template, thirteen locales and a UI toggle --
  4. except the thing that fires it. `_get_providers_for_event(db, "on_filament_low")`
  5. appeared only inside the method that would be called, so the toggle could be
  6. switched on and could never produce a notification.
  7. The reason it shipped is visible in the old test file: the only occurrence of
  8. `on_filament_low` in test_notification_service.py is a fixture setting the flag
  9. to False. Nothing ever asserted the event reaches a provider. The last test here
  10. does exactly that.
  11. """
  12. import logging
  13. from datetime import datetime
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from sqlalchemy import select
  17. from backend.app.models.notification import NotificationProvider
  18. from backend.app.models.printer import Printer
  19. from backend.app.models.settings import Settings
  20. from backend.app.models.spool import Spool
  21. from backend.app.models.spool_assignment import SpoolAssignment
  22. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  23. from backend.app.services.print_scheduler import PrintScheduler, _ams_slot_label, _remaining_percent
  24. from backend.app.services.spoolman import SpoolmanUnavailableError
  25. async def _printer(db, name: str = "X2D") -> Printer:
  26. p = Printer(name=name, serial_number=f"S-{name}", ip_address="1.1.1.1", access_code="c", model="X2D")
  27. db.add(p)
  28. await db.flush()
  29. return p
  30. async def _assigned_spool(
  31. db, printer, *, weight_used: float, threshold_pct: int | None = None, ams=0, tray=0, color_name=None
  32. ):
  33. spool = Spool(
  34. material="PLA",
  35. color_name=color_name,
  36. label_weight=1000,
  37. core_weight=250,
  38. weight_used=weight_used,
  39. low_stock_threshold_pct=threshold_pct,
  40. )
  41. spool.k_profiles = []
  42. spool.assignments = []
  43. db.add(spool)
  44. await db.flush()
  45. db.add(SpoolAssignment(printer_id=printer.id, spool_id=spool.id, ams_id=ams, tray_id=tray))
  46. await db.flush()
  47. return spool
  48. # -- the arithmetic ---------------------------------------------------------
  49. def test_remaining_percent_matches_the_inventory_page_rule():
  50. """`remaining = label - used`, as a percentage of label. The same expression
  51. the Low Stock count uses, so the alert and the badge cannot disagree."""
  52. assert _remaining_percent(1000, 850.0) == pytest.approx(15.0)
  53. assert _remaining_percent(1000, 0.0) == pytest.approx(100.0)
  54. def test_remaining_percent_is_none_without_a_label_weight():
  55. """A spool that cannot say how full it started cannot say how empty it is."""
  56. assert _remaining_percent(0, 100.0) is None
  57. assert _remaining_percent(None, 100.0) is None
  58. def test_remaining_percent_floors_at_zero_when_overdrawn():
  59. assert _remaining_percent(1000, 1200.0) == pytest.approx(0.0)
  60. def test_slot_labels_are_the_ones_the_notification_side_already_uses():
  61. """Every kind of slot, against the spelling the rest of the app produces.
  62. The four cases after the first two are the ones the hand-rolled version got
  63. wrong: 254 is a real ams_id (InventoryPage.tsx:286, usage_tracker.py:55) and
  64. fell into its ``>= 128`` branch as ``HT-`` plus ``chr(191)``; an external
  65. assignment stores 255 with tray_id choosing the side, so both externals came
  66. out as one label and a two-external printer could not tell them apart; and
  67. an A2L AMS-Lite normalises to unit 6, which read as ``AMS-G``.
  68. """
  69. assert _ams_slot_label(0, 0) == "A1"
  70. assert _ams_slot_label(1, 3) == "B4"
  71. assert _ams_slot_label(128, 0) == "HT-A"
  72. assert _ams_slot_label(254, 0) == "Ext-L"
  73. assert _ams_slot_label(255, 0) == "Ext-L"
  74. assert _ams_slot_label(255, 1) == "Ext-R"
  75. assert _ams_slot_label(6, 0) == "Lite-1"
  76. # -- the producer -----------------------------------------------------------
  77. @pytest.fixture
  78. def scheduler():
  79. return PrintScheduler()
  80. async def _pass(scheduler, db):
  81. """Run one low-filament check, stepping over the inter-pass time gate.
  82. ``_check_filament_low`` refuses to run again within
  83. ``_FILAMENT_LOW_MIN_INTERVAL``, so consecutive calls in a test would be
  84. swallowed by the gate and every multi-pass assertion below would pass
  85. without exercising what it names. The gate has its own test.
  86. """
  87. scheduler._filament_low_next_check = 0.0
  88. await scheduler._check_filament_low(db)
  89. @pytest.fixture
  90. def notify():
  91. """The service, replaced, with one provider that wants the event.
  92. Every test below that is about the producer assumes someone wants the alert;
  93. the provider guard in front of the check has its own tests further down,
  94. which run the real provider query.
  95. """
  96. with patch("backend.app.services.print_scheduler.notification_service") as ns:
  97. ns.on_filament_low = AsyncMock()
  98. ns._get_providers_for_event = AsyncMock(return_value=[MagicMock()])
  99. yield ns
  100. @pytest.mark.asyncio
  101. async def test_fires_when_an_assigned_spool_is_below_the_threshold(db_session, scheduler, notify):
  102. printer = await _printer(db_session)
  103. await _assigned_spool(db_session, printer, weight_used=850.0) # 15% left, default threshold 20
  104. await db_session.commit()
  105. await _pass(scheduler, db_session)
  106. notify.on_filament_low.assert_awaited_once()
  107. args = notify.on_filament_low.await_args.args
  108. assert args[0] == printer.id
  109. assert args[1] == "X2D"
  110. assert args[2] == "A1"
  111. assert args[3] == 15
  112. @pytest.mark.asyncio
  113. async def test_the_spool_colour_is_passed_for_the_template(db_session, scheduler, notify):
  114. """on_filament_low has always accepted color= and the filament_low template
  115. lists {color} as a variable; with nothing passing it, the variable rendered
  116. empty in every custom template."""
  117. printer = await _printer(db_session)
  118. await _assigned_spool(db_session, printer, weight_used=850.0, color_name="Jade White")
  119. await db_session.commit()
  120. await _pass(scheduler, db_session)
  121. assert notify.on_filament_low.await_args.kwargs["color"] == "Jade White"
  122. @pytest.mark.asyncio
  123. async def test_stays_quiet_above_the_threshold(db_session, scheduler, notify):
  124. printer = await _printer(db_session)
  125. await _assigned_spool(db_session, printer, weight_used=500.0) # 50% left
  126. await db_session.commit()
  127. await _pass(scheduler, db_session)
  128. notify.on_filament_low.assert_not_awaited()
  129. @pytest.mark.asyncio
  130. async def test_a_slot_with_no_assigned_spool_produces_nothing(db_session, scheduler, notify):
  131. """Guessing a remaining weight for an unassigned slot is how the AMS remain
  132. percentage would have got back in."""
  133. await _printer(db_session)
  134. await db_session.commit()
  135. await _pass(scheduler, db_session)
  136. notify.on_filament_low.assert_not_awaited()
  137. @pytest.mark.asyncio
  138. async def test_does_not_repeat_on_the_next_pass(db_session, scheduler, notify):
  139. """The debounce. A spool sitting under the threshold must not alert every
  140. 30 seconds until it is changed."""
  141. printer = await _printer(db_session)
  142. await _assigned_spool(db_session, printer, weight_used=850.0)
  143. await db_session.commit()
  144. await _pass(scheduler, db_session)
  145. await _pass(scheduler, db_session)
  146. await _pass(scheduler, db_session)
  147. assert notify.on_filament_low.await_count == 1
  148. @pytest.mark.asyncio
  149. async def test_re_arms_once_the_slot_goes_back_above_the_threshold(db_session, scheduler, notify):
  150. """Cleared by the value going back up, not by a timer -- so a refilled slot
  151. can alert again, and a spool hovering at the boundary cannot spam."""
  152. printer = await _printer(db_session)
  153. spool = await _assigned_spool(db_session, printer, weight_used=850.0)
  154. await db_session.commit()
  155. await _pass(scheduler, db_session)
  156. assert notify.on_filament_low.await_count == 1
  157. # Refilled — a fresh spool put on the same slot.
  158. spool.weight_used = 0.0
  159. await db_session.commit()
  160. await _pass(scheduler, db_session)
  161. assert notify.on_filament_low.await_count == 1
  162. # And down again.
  163. spool.weight_used = 900.0
  164. await db_session.commit()
  165. await _pass(scheduler, db_session)
  166. assert notify.on_filament_low.await_count == 2
  167. @pytest.mark.asyncio
  168. async def test_debounce_is_per_slot_not_per_printer(db_session, scheduler, notify):
  169. printer = await _printer(db_session)
  170. await _assigned_spool(db_session, printer, weight_used=850.0, ams=0, tray=0)
  171. await _assigned_spool(db_session, printer, weight_used=900.0, ams=0, tray=1)
  172. await db_session.commit()
  173. await _pass(scheduler, db_session)
  174. assert notify.on_filament_low.await_count == 2
  175. slots = {call.args[2] for call in notify.on_filament_low.await_args_list}
  176. assert slots == {"A1", "A2"}
  177. @pytest.mark.asyncio
  178. async def test_per_spool_override_beats_the_global_threshold(db_session, scheduler, notify):
  179. """A spool marked as needing 60% left alerts at 50%, where the global 20%
  180. would have stayed quiet. Same precedence the Inventory page uses."""
  181. printer = await _printer(db_session)
  182. await _assigned_spool(db_session, printer, weight_used=500.0, threshold_pct=60)
  183. await db_session.commit()
  184. await _pass(scheduler, db_session)
  185. notify.on_filament_low.assert_awaited_once()
  186. assert notify.on_filament_low.await_args.args[3] == 50
  187. @pytest.mark.asyncio
  188. async def test_global_setting_is_honoured(db_session, scheduler, notify):
  189. printer = await _printer(db_session)
  190. await _assigned_spool(db_session, printer, weight_used=600.0) # 40% left
  191. db_session.add(Settings(key="low_stock_threshold", value="50"))
  192. await db_session.commit()
  193. await _pass(scheduler, db_session)
  194. notify.on_filament_low.assert_awaited_once()
  195. @pytest.mark.asyncio
  196. async def test_a_failing_provider_does_not_break_the_pass(db_session, scheduler, notify):
  197. """A notification provider being down must not take the scheduler with it."""
  198. printer = await _printer(db_session)
  199. await _assigned_spool(db_session, printer, weight_used=850.0)
  200. await db_session.commit()
  201. notify.on_filament_low.side_effect = RuntimeError("provider down")
  202. await _pass(scheduler, db_session) # must not raise
  203. notify.on_filament_low.assert_awaited_once()
  204. # -- the pass is time-gated -------------------------------------------------
  205. @pytest.mark.asyncio
  206. async def test_a_second_pass_inside_the_interval_does_no_work(db_session, scheduler, notify):
  207. """The gate, which every multi-pass test above steps over deliberately.
  208. run() sleeps _fast_check_interval -- 3 seconds -- on any productive pass,
  209. and the early-return path counts as productive while an upload is in flight
  210. (#2602). Ungated, this check would re-read every assigned spool on that
  211. cadence for the length of a batch drain, which in Spoolman mode means the
  212. whole collection over HTTP against a third-party service.
  213. """
  214. printer = await _printer(db_session)
  215. await _assigned_spool(db_session, printer, weight_used=850.0)
  216. await db_session.commit()
  217. with patch.object(scheduler, "_get_low_stock_threshold", new_callable=AsyncMock) as work:
  218. work.return_value = 20.0
  219. await scheduler._check_filament_low(db_session)
  220. await scheduler._check_filament_low(db_session)
  221. await scheduler._check_filament_low(db_session)
  222. # Not "no notification" -- the debounce would give that too. No work at all.
  223. assert work.await_count == 1
  224. @pytest.mark.asyncio
  225. async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify):
  226. """Archiving a spool takes it out of the Low Stock count (InventoryPage.tsx:1089).
  227. Spoolman mode gets this for free -- get_all_spools without allow_archived
  228. does not return them -- so without the explicit filter the internal path
  229. would be the only mode that alerts on a spool the user has retired.
  230. """
  231. printer = await _printer(db_session)
  232. spool = await _assigned_spool(db_session, printer, weight_used=850.0)
  233. spool.archived_at = datetime(2026, 8, 1, 12, 0, 0)
  234. await db_session.commit()
  235. await _pass(scheduler, db_session)
  236. notify.on_filament_low.assert_not_awaited()
  237. # -- Spoolman mode ----------------------------------------------------------
  238. def _spoolman_spool(spool_id: int, remaining_weight: float, label_weight: int = 1000, color_name=None) -> dict:
  239. """A raw Spoolman spool, in the shape _map_spoolman_spool reads."""
  240. filament = {"id": 1, "name": "PLA Basic", "material": "PLA", "weight": label_weight, "vendor": {"name": "X"}}
  241. if color_name is not None:
  242. filament["color_name"] = color_name
  243. return {
  244. "id": spool_id,
  245. "remaining_weight": remaining_weight,
  246. "used_weight": label_weight - remaining_weight,
  247. "filament": filament,
  248. }
  249. async def _spoolman_slot(db, printer, spool_id: int, *, ams=0, tray=0):
  250. db.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=ams, tray_id=tray, spoolman_spool_id=spool_id))
  251. db.add(Settings(key="spoolman_enabled", value="true"))
  252. await db.flush()
  253. def _spoolman_client(spools, *, unavailable: bool = False):
  254. client = MagicMock()
  255. if unavailable:
  256. client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Cannot reach Spoolman"))
  257. else:
  258. client.get_all_spools = AsyncMock(return_value=spools)
  259. return client
  260. @pytest.mark.asyncio
  261. async def test_spoolman_mode_alerts_from_the_collection_read(db_session, scheduler, notify):
  262. """The half with the client in it, which had no coverage at all.
  263. The threshold is meant to mean the same thing in both inventory modes, so
  264. the mode that reaches a third-party service for its numbers needs its own
  265. tests rather than inheriting the internal path's.
  266. """
  267. printer = await _printer(db_session)
  268. await _spoolman_slot(db_session, printer, 7, ams=0, tray=1)
  269. await db_session.commit()
  270. client = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0, color_name="Jade White")]) # 15% left
  271. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  272. await _pass(scheduler, db_session)
  273. notify.on_filament_low.assert_awaited_once()
  274. args = notify.on_filament_low.await_args.args
  275. assert args[2] == "A2"
  276. assert args[3] == 15
  277. assert notify.on_filament_low.await_args.kwargs["color"] == "Jade White"
  278. # One request for the whole collection, not one per assigned slot.
  279. client.get_all_spools.assert_awaited_once()
  280. @pytest.mark.asyncio
  281. async def test_a_spoolman_slot_whose_spool_is_gone_is_skipped(db_session, scheduler, notify):
  282. """A spool deleted in Spoolman leaves the assignment row behind. It has no
  283. remaining weight, so it produces nothing rather than a guess."""
  284. printer = await _printer(db_session)
  285. await _spoolman_slot(db_session, printer, 7)
  286. await db_session.commit()
  287. client = _spoolman_client([_spoolman_spool(99, remaining_weight=150.0)])
  288. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  289. await _pass(scheduler, db_session)
  290. notify.on_filament_low.assert_not_awaited()
  291. @pytest.mark.asyncio
  292. async def test_an_unreachable_spoolman_resolves_nothing_and_stays_quiet(db_session, scheduler, notify, caplog):
  293. """An outage is an ordinary state for a third-party service, not an error here.
  294. The assertion is the log, not the silence. Letting SpoolmanUnavailableError
  295. reach _check_filament_low's broad except produces the same absence of
  296. notifications -- so "nothing was sent" cannot tell the two apart -- while
  297. writing a full traceback on every pass for the length of the outage. What
  298. the catch buys is that it is handled where it is expected.
  299. """
  300. printer = await _printer(db_session)
  301. await _spoolman_slot(db_session, printer, 7)
  302. await db_session.commit()
  303. client = _spoolman_client(None, unavailable=True)
  304. with (
  305. caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"),
  306. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  307. ):
  308. await _pass(scheduler, db_session) # must not raise
  309. notify.on_filament_low.assert_not_awaited()
  310. assert "Low-filament check failed" not in caplog.text
  311. @pytest.mark.asyncio
  312. async def test_an_outage_does_not_re_alert_everything_when_it_clears(db_session, scheduler, notify):
  313. """The reason a pass that resolves nothing leaves the notified set alone.
  314. Clearing the set on an empty pass would make a brief Spoolman outage
  315. indistinguishable from every spool being refilled, and the alerts would all
  316. fire again the moment it came back.
  317. """
  318. printer = await _printer(db_session)
  319. await _spoolman_slot(db_session, printer, 7)
  320. await db_session.commit()
  321. low = [_spoolman_spool(7, remaining_weight=150.0)]
  322. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(low))):
  323. await _pass(scheduler, db_session)
  324. assert notify.on_filament_low.await_count == 1
  325. outage = _spoolman_client(None, unavailable=True)
  326. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=outage)):
  327. await _pass(scheduler, db_session)
  328. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(low))):
  329. await _pass(scheduler, db_session)
  330. assert notify.on_filament_low.await_count == 1
  331. @pytest.mark.asyncio
  332. async def test_a_replacement_spool_in_the_same_slot_can_alert(db_session, scheduler, notify):
  333. """Why the notified key carries the spool id.
  334. Pull a low spool and put a different part-used one in the same slot. The
  335. key survives the pass where the slot resolves to nothing -- deliberately --
  336. and the replacement never goes above the threshold, so keyed on the slot
  337. alone it could never re-arm and the second spool would run out silently.
  338. """
  339. printer = await _printer(db_session)
  340. await _spoolman_slot(db_session, printer, 7)
  341. await db_session.commit()
  342. first = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0)])
  343. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=first)):
  344. await _pass(scheduler, db_session)
  345. assert notify.on_filament_low.await_count == 1
  346. # The slot resolves to nothing for a pass while the roll is swapped.
  347. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client([]))):
  348. await _pass(scheduler, db_session)
  349. assignment = (await db_session.execute(select(SpoolmanSlotAssignment))).scalars().one()
  350. assignment.spoolman_spool_id = 8
  351. await db_session.commit()
  352. second = _spoolman_client([_spoolman_spool(8, remaining_weight=100.0)]) # 10% left
  353. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=second)):
  354. await _pass(scheduler, db_session)
  355. assert notify.on_filament_low.await_count == 2
  356. assert notify.on_filament_low.await_args.args[3] == 10
  357. # -- nobody wants the alert ------------------------------------------------
  358. async def _provider(db, *, enabled: bool, on_filament_low: bool) -> None:
  359. db.add(
  360. NotificationProvider(
  361. name=f"p-{enabled}-{on_filament_low}",
  362. provider_type="ntfy",
  363. config="{}",
  364. enabled=enabled,
  365. on_filament_low=on_filament_low,
  366. )
  367. )
  368. await db.flush()
  369. @pytest.fixture
  370. def real_providers():
  371. """The real service, with only the send replaced.
  372. The guard is one query against notification_providers, so these tests run
  373. it for real rather than mocking the answer -- otherwise a guard asking about
  374. the wrong event, or ignoring ``enabled``, would pass.
  375. """
  376. from backend.app.services.print_scheduler import notification_service
  377. with patch.object(notification_service, "on_filament_low", new_callable=AsyncMock) as send:
  378. yield send
  379. @pytest.mark.asyncio
  380. async def test_no_spoolman_call_when_no_provider_wants_the_event(db_session, scheduler, real_providers):
  381. """on_filament_low defaults to off, so on most installs nobody wants this.
  382. Unguarded, every Spoolman install with slot assignments read its whole spool
  383. collection every interval, forever, for an alert that was switched off. The
  384. two providers here are the two ways of not wanting it: enabled with the
  385. event off, and the event on but the provider disabled.
  386. """
  387. printer = await _printer(db_session)
  388. await _spoolman_slot(db_session, printer, 7)
  389. await _provider(db_session, enabled=True, on_filament_low=False)
  390. await _provider(db_session, enabled=False, on_filament_low=True)
  391. await db_session.commit()
  392. get_client = AsyncMock(return_value=_spoolman_client([_spoolman_spool(7, remaining_weight=150.0)]))
  393. with patch("backend.app.services.spoolman.get_spoolman_client", get_client):
  394. await _pass(scheduler, db_session)
  395. get_client.assert_not_awaited()
  396. real_providers.assert_not_awaited()
  397. @pytest.mark.asyncio
  398. async def test_internal_mode_does_no_spool_work_when_no_provider_wants_the_event(db_session, scheduler, real_providers):
  399. """Same guard, internal mode: no spool query at all, not just no send."""
  400. printer = await _printer(db_session)
  401. await _assigned_spool(db_session, printer, weight_used=850.0)
  402. await db_session.commit()
  403. with patch.object(scheduler, "_get_low_stock_threshold", new_callable=AsyncMock) as work:
  404. work.return_value = 20.0
  405. await _pass(scheduler, db_session)
  406. work.assert_not_awaited()
  407. real_providers.assert_not_awaited()
  408. @pytest.mark.asyncio
  409. async def test_one_provider_wanting_the_event_is_enough(db_session, scheduler, real_providers):
  410. """The other side of the guard, through the same real query: one enabled
  411. provider with the event on and the check runs. Without this, a guard that
  412. always returned would pass the two tests above."""
  413. printer = await _printer(db_session)
  414. await _spoolman_slot(db_session, printer, 7)
  415. await _provider(db_session, enabled=True, on_filament_low=False)
  416. await _provider(db_session, enabled=True, on_filament_low=True)
  417. await db_session.commit()
  418. client = _spoolman_client([_spoolman_spool(7, remaining_weight=150.0)])
  419. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  420. await _pass(scheduler, db_session)
  421. client.get_all_spools.assert_awaited_once()
  422. real_providers.assert_awaited_once()
  423. @pytest.mark.asyncio
  424. async def test_switching_the_event_off_and_on_again_re_arms(db_session, scheduler, real_providers):
  425. """While the event is off no pass runs, so none can see a slot go back above
  426. its threshold and re-arm it. A spool refilled under the same id meanwhile
  427. would stay silenced once the event is back on. Switching it off forgets what
  428. was sent; switching it on reports the spools that are low now."""
  429. printer = await _printer(db_session)
  430. spool = await _assigned_spool(db_session, printer, weight_used=850.0)
  431. provider = NotificationProvider(name="p", provider_type="ntfy", config="{}", enabled=True, on_filament_low=True)
  432. db_session.add(provider)
  433. await db_session.commit()
  434. await _pass(scheduler, db_session)
  435. assert real_providers.await_count == 1
  436. # Off; refilled and run low again while no pass was looking; on again.
  437. provider.on_filament_low = False
  438. await db_session.commit()
  439. await _pass(scheduler, db_session)
  440. spool.weight_used = 900.0
  441. provider.on_filament_low = True
  442. await db_session.commit()
  443. await _pass(scheduler, db_session)
  444. assert real_providers.await_count == 2
  445. # -- the event actually reaches a provider ----------------------------------
  446. @pytest.mark.asyncio
  447. async def test_the_event_reaches_a_provider_with_the_toggle_on():
  448. """The assertion that was missing, and the reason this shipped unfired.
  449. Everything above proves the producer calls the service. This proves the
  450. service does not drop it: a provider with on_filament_low enabled is handed
  451. the rendered message.
  452. """
  453. from backend.app.services.notification_service import NotificationService
  454. service = NotificationService()
  455. provider = MagicMock()
  456. provider.id = 1
  457. provider.on_filament_low = True
  458. with (
  459. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  460. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  461. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  462. ):
  463. mock_get.return_value = [provider]
  464. mock_build.return_value = ("Filament Low", "X2D A1 is at 15%")
  465. await service.on_filament_low(1, "X2D", "A1", 15, AsyncMock(), color="Jade White")
  466. mock_get.assert_awaited_once()
  467. assert mock_get.await_args.args[1] == "on_filament_low"
  468. mock_send.assert_awaited_once()
  469. variables = mock_send.await_args.kwargs["variables"]
  470. assert variables["printer"] == "X2D"
  471. assert variables["slot"] == "A1"
  472. assert variables["remaining_percent"] == "15"
  473. assert variables["color"] == "Jade White"