test_stock_alert_producer_2955.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  1. """The two Inventory stock alerts have a producer now (issue #2955).
  2. ``on_stock_reorder_alert`` and ``on_stock_break_alert`` had a column, a schema
  3. field, a route, a template and a UI toggle -- and, until #2945, could not even be
  4. saved -- but nothing that decides a SKU has reached its reorder point: the Forecast
  5. panel does that in the browser, so with no page open nothing could ever alert.
  6. ``PrintScheduler._check_stock_forecast`` runs the panel's arithmetic
  7. (``stock_forecast``) from the scheduler loop.
  8. The arithmetic itself is pinned against the panel in ``test_stock_forecast_2955``;
  9. these tests are about *when* it alerts and what it sends.
  10. The scenarios use the delta rate (grams consumed over the age of the spool)
  11. because it needs no usage history: a 1000 g spool created 100 days ago with
  12. ``used`` grams consumed runs at ``used / 100`` g/day. With a 6 day lead time and
  13. the default 14 day margin:
  14. used 920 -> 80 g left, 9.2 g/day, 8 days of stock, reorder point ~90 g : reorder
  15. used 960 -> 40 g left, 9.6 g/day, 4 days of stock : break
  16. used 500 -> 500 g left, 5 g/day, 100 days of stock : nothing
  17. """
  18. import json
  19. import logging
  20. import time
  21. from datetime import datetime, timedelta
  22. from unittest.mock import AsyncMock, MagicMock, patch
  23. import pytest
  24. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  25. from backend.app.models.notification import NotificationProvider
  26. from backend.app.models.settings import Settings
  27. from backend.app.models.spool import Spool
  28. from backend.app.services.print_scheduler import _STOCK_ALERTS_SETTING_KEY, PrintScheduler
  29. from backend.app.services.spoolman import SpoolmanUnavailableError
  30. from backend.app.utils.local_time import utcnow_naive
  31. REORDER_USED = 920.0
  32. BREAK_USED = 960.0
  33. QUIET_USED = 500.0
  34. async def _spool(
  35. db,
  36. *,
  37. used: float,
  38. color: str | None = "Black",
  39. subtype: str | None = "Basic",
  40. brand: str | None = "Bambu Lab",
  41. age_days: int = 100,
  42. archived: bool = False,
  43. baseline: float = 0.0,
  44. ) -> Spool:
  45. spool = Spool(
  46. material="PLA",
  47. subtype=subtype,
  48. brand=brand,
  49. color_name=color,
  50. label_weight=1000,
  51. core_weight=250,
  52. weight_used=used,
  53. weight_used_baseline=baseline,
  54. created_at=utcnow_naive() - timedelta(days=age_days),
  55. archived_at=utcnow_naive() if archived else None,
  56. )
  57. spool.k_profiles = []
  58. spool.assignments = []
  59. db.add(spool)
  60. await db.flush()
  61. return spool
  62. async def _sku(db, *, lead: int = 6, color: str | None = "Black", snoozed: bool = False, margin: int = 14):
  63. row = FilamentSkuSettings(
  64. material="PLA",
  65. subtype="Basic",
  66. brand="Bambu Lab",
  67. color_name=color,
  68. lead_time_days=lead,
  69. safety_margin_value=margin,
  70. safety_margin_unit="days",
  71. alerts_snoozed=snoozed,
  72. )
  73. db.add(row)
  74. await db.flush()
  75. return row
  76. @pytest.fixture
  77. def scheduler():
  78. return PrintScheduler()
  79. async def _pass(scheduler, db):
  80. """Run one check, stepping over the hourly gate (which has its own test)."""
  81. scheduler._stock_forecast_next_check = 0.0
  82. await scheduler._check_stock_forecast(db)
  83. @pytest.fixture
  84. def notify():
  85. """The service, replaced, with a provider that wants both events.
  86. Every test about the producer assumes someone wants the alerts; the guard in
  87. front of it has its own tests further down, against the real provider query.
  88. """
  89. with patch("backend.app.services.print_scheduler.notification_service") as ns:
  90. ns.on_stock_reorder_alert = AsyncMock()
  91. ns.on_stock_break_alert = AsyncMock()
  92. ns._get_providers_for_event = AsyncMock(return_value=[MagicMock()])
  93. yield ns
  94. # -- a restart does not start it over ----------------------------------------
  95. async def _stored(db) -> list | None:
  96. """What the settings row holds, parsed. None when there is no row."""
  97. from sqlalchemy import select
  98. row = (await db.execute(select(Settings).where(Settings.key == _STOCK_ALERTS_SETTING_KEY))).scalar_one_or_none()
  99. return json.loads(row.value) if row else None
  100. @pytest.mark.asyncio
  101. async def test_a_fresh_scheduler_does_not_re_send_what_is_already_stored(db_session, scheduler, notify):
  102. """The first check runs as soon as Bambuddy starts. Without the stored set, every update
  103. re-sent one message per SKU that was still low."""
  104. await _sku(db_session)
  105. await _spool(db_session, used=REORDER_USED)
  106. await db_session.commit()
  107. await _pass(scheduler, db_session)
  108. assert notify.on_stock_reorder_alert.await_count == 1
  109. restarted = PrintScheduler()
  110. await _pass(restarted, db_session)
  111. assert notify.on_stock_reorder_alert.await_count == 1
  112. notify.on_stock_break_alert.assert_not_awaited()
  113. @pytest.mark.asyncio
  114. async def test_a_stored_reorder_still_lets_the_worsening_to_a_break_through_after_a_restart(
  115. db_session, scheduler, notify
  116. ):
  117. await _sku(db_session)
  118. spool = await _spool(db_session, used=REORDER_USED)
  119. await db_session.commit()
  120. await _pass(scheduler, db_session)
  121. spool.weight_used = BREAK_USED
  122. await db_session.commit()
  123. restarted = PrintScheduler()
  124. await _pass(restarted, db_session)
  125. notify.on_stock_break_alert.assert_awaited_once()
  126. @pytest.mark.asyncio
  127. async def test_a_sku_that_cleared_while_it_was_down_alerts_again_after_a_restart(db_session, scheduler, notify):
  128. await _sku(db_session)
  129. spool = await _spool(db_session, used=REORDER_USED)
  130. await db_session.commit()
  131. await _pass(scheduler, db_session)
  132. spool.weight_used = QUIET_USED
  133. await db_session.commit()
  134. await _pass(PrintScheduler(), db_session)
  135. spool.weight_used = REORDER_USED
  136. await db_session.commit()
  137. await _pass(PrintScheduler(), db_session)
  138. assert notify.on_stock_reorder_alert.await_count == 2
  139. @pytest.mark.asyncio
  140. async def test_the_stored_set_drops_a_sku_that_no_longer_exists(db_session, scheduler, notify):
  141. await _sku(db_session)
  142. spool = await _spool(db_session, used=REORDER_USED)
  143. await db_session.commit()
  144. await _pass(scheduler, db_session)
  145. assert len(await _stored(db_session)) == 1
  146. spool.archived_at = utcnow_naive()
  147. await db_session.commit()
  148. await _pass(PrintScheduler(), db_session)
  149. assert await _stored(db_session) == []
  150. @pytest.mark.asyncio
  151. async def test_the_stored_set_is_emptied_when_nobody_wants_either_event(db_session, scheduler, real_providers):
  152. await _sku(db_session)
  153. await _spool(db_session, used=REORDER_USED)
  154. provider_row = NotificationProvider(
  155. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  156. )
  157. db_session.add(provider_row)
  158. await db_session.commit()
  159. await _pass(scheduler, db_session)
  160. assert len(await _stored(db_session)) == 1
  161. provider_row.on_stock_reorder_alert = False
  162. await db_session.commit()
  163. await _pass(PrintScheduler(), db_session)
  164. assert await _stored(db_session) == []
  165. @pytest.mark.asyncio
  166. async def test_an_unchanged_set_is_not_written_again(db_session, scheduler, notify):
  167. await _sku(db_session)
  168. await _spool(db_session, used=REORDER_USED)
  169. await db_session.commit()
  170. await _pass(scheduler, db_session)
  171. with patch("backend.app.core.db_dialect.upsert_setting", AsyncMock()) as upsert:
  172. await _pass(scheduler, db_session)
  173. await _pass(PrintScheduler(), db_session)
  174. upsert.assert_not_awaited()
  175. @pytest.mark.asyncio
  176. async def test_an_unreadable_stored_set_is_treated_as_empty(db_session, scheduler, notify):
  177. db_session.add(Settings(key=_STOCK_ALERTS_SETTING_KEY, value="not json {"))
  178. await _sku(db_session)
  179. await _spool(db_session, used=REORDER_USED)
  180. await db_session.commit()
  181. await _pass(scheduler, db_session)
  182. notify.on_stock_reorder_alert.assert_awaited_once()
  183. assert len(await _stored(db_session)) == 1
  184. # -- when it alerts ----------------------------------------------------------
  185. @pytest.mark.asyncio
  186. async def test_a_sku_at_its_reorder_point_alerts_once_with_its_numbers(db_session, scheduler, notify):
  187. await _sku(db_session)
  188. await _spool(db_session, used=REORDER_USED)
  189. await db_session.commit()
  190. await _pass(scheduler, db_session)
  191. notify.on_stock_break_alert.assert_not_awaited()
  192. notify.on_stock_reorder_alert.assert_awaited_once()
  193. material, brand, stock_g, rate, days_left, _db = notify.on_stock_reorder_alert.await_args.args
  194. assert (material, brand) == ("PLA", "Bambu Lab")
  195. assert stock_g == pytest.approx(80.0)
  196. assert rate == pytest.approx(9.2, rel=1e-3)
  197. assert days_left == 8
  198. kwargs = notify.on_stock_reorder_alert.await_args.kwargs
  199. assert kwargs == {"subtype": "Basic", "color": "Black", "skip_break_subscribers": False}
  200. @pytest.mark.asyncio
  201. async def test_a_sku_that_runs_out_before_the_lead_time_alerts_as_a_break(db_session, scheduler, notify):
  202. await _sku(db_session)
  203. await _spool(db_session, used=BREAK_USED)
  204. await db_session.commit()
  205. await _pass(scheduler, db_session)
  206. notify.on_stock_break_alert.assert_awaited_once()
  207. args = notify.on_stock_break_alert.await_args.args
  208. assert args[2] == pytest.approx(40.0)
  209. assert args[4] == 4 # days left
  210. assert args[5] == 6 # lead time
  211. assert notify.on_stock_break_alert.await_args.kwargs == {"subtype": "Basic", "color": "Black"}
  212. # A breaking SKU has also reached its reorder point: the reorder event goes too, minus the
  213. # providers that have the break alert on (the service applies that filter; see below).
  214. notify.on_stock_reorder_alert.assert_awaited_once()
  215. assert notify.on_stock_reorder_alert.await_args.kwargs["skip_break_subscribers"] is True
  216. @pytest.mark.asyncio
  217. async def test_a_sku_with_plenty_of_stock_stays_quiet(db_session, scheduler, notify):
  218. await _sku(db_session)
  219. await _spool(db_session, used=QUIET_USED)
  220. await db_session.commit()
  221. await _pass(scheduler, db_session)
  222. notify.on_stock_reorder_alert.assert_not_awaited()
  223. notify.on_stock_break_alert.assert_not_awaited()
  224. @pytest.mark.asyncio
  225. async def test_a_sku_that_has_not_been_used_has_no_rate_and_stays_quiet(db_session, scheduler, notify):
  226. await _sku(db_session)
  227. await _spool(db_session, used=0.0)
  228. await db_session.commit()
  229. await _pass(scheduler, db_session)
  230. notify.on_stock_reorder_alert.assert_not_awaited()
  231. notify.on_stock_break_alert.assert_not_awaited()
  232. @pytest.mark.asyncio
  233. async def test_the_global_lead_time_counts(db_session, scheduler, notify):
  234. """No SKU row at all: the lead time is the global setting, and 40 g at 9.6 g/day is 4 days."""
  235. db_session.add(Settings(key="forecast_global_lead_time_days", value="30"))
  236. await _spool(db_session, used=BREAK_USED)
  237. await db_session.commit()
  238. await _pass(scheduler, db_session)
  239. notify.on_stock_break_alert.assert_awaited_once()
  240. assert notify.on_stock_break_alert.await_args.args[5] == 30
  241. @pytest.mark.asyncio
  242. async def test_usage_history_beats_the_delta_rate_when_there_is_enough_of_it(db_session, scheduler, notify):
  243. """300 g used over 100 days is 3 g/day and 700 g left is months of stock. But the
  244. last three days took 300 g each: the history rate says two days, so it is a break.
  245. Also the only test that reads ``spool_usage_history`` through the scheduler.
  246. """
  247. from backend.app.models.spool_usage_history import SpoolUsageHistory
  248. await _sku(db_session)
  249. spool = await _spool(db_session, used=300.0)
  250. for days_ago in (3, 2, 1):
  251. db_session.add(
  252. SpoolUsageHistory(
  253. spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  254. )
  255. )
  256. await db_session.commit()
  257. await _pass(scheduler, db_session)
  258. notify.on_stock_break_alert.assert_awaited_once()
  259. @pytest.mark.asyncio
  260. async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler, notify):
  261. """Same history, but the spool was reset (baseline set): its earlier events have no
  262. anchor, so the rate falls back to the delta rate -- 3 g/day, nothing to alert."""
  263. from backend.app.models.spool_usage_history import SpoolUsageHistory
  264. await _sku(db_session)
  265. spool = await _spool(db_session, used=300.0, baseline=100.0)
  266. for days_ago in (3, 2, 1):
  267. db_session.add(
  268. SpoolUsageHistory(
  269. spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  270. )
  271. )
  272. await db_session.commit()
  273. await _pass(scheduler, db_session)
  274. notify.on_stock_break_alert.assert_not_awaited()
  275. notify.on_stock_reorder_alert.assert_not_awaited()
  276. # -- once per transition -----------------------------------------------------
  277. @pytest.mark.asyncio
  278. async def test_does_not_repeat_on_the_next_pass(db_session, scheduler, notify):
  279. await _sku(db_session)
  280. await _spool(db_session, used=REORDER_USED)
  281. await db_session.commit()
  282. await _pass(scheduler, db_session)
  283. await _pass(scheduler, db_session)
  284. await _pass(scheduler, db_session)
  285. notify.on_stock_reorder_alert.assert_awaited_once()
  286. @pytest.mark.asyncio
  287. async def test_re_arms_once_the_condition_clears(db_session, scheduler, notify):
  288. """Restocked (a fresh roll brings the SKU back above the line), then low again."""
  289. await _sku(db_session)
  290. spool = await _spool(db_session, used=REORDER_USED)
  291. await db_session.commit()
  292. await _pass(scheduler, db_session)
  293. assert notify.on_stock_reorder_alert.await_count == 1
  294. spool.weight_used = QUIET_USED
  295. await db_session.commit()
  296. await _pass(scheduler, db_session)
  297. assert notify.on_stock_reorder_alert.await_count == 1
  298. spool.weight_used = REORDER_USED
  299. await db_session.commit()
  300. await _pass(scheduler, db_session)
  301. assert notify.on_stock_reorder_alert.await_count == 2
  302. @pytest.mark.asyncio
  303. async def test_worsening_from_reorder_to_break_alerts_again_as_a_break(db_session, scheduler, notify):
  304. await _sku(db_session)
  305. spool = await _spool(db_session, used=REORDER_USED)
  306. await db_session.commit()
  307. await _pass(scheduler, db_session)
  308. spool.weight_used = BREAK_USED
  309. await db_session.commit()
  310. await _pass(scheduler, db_session)
  311. await _pass(scheduler, db_session)
  312. notify.on_stock_reorder_alert.assert_awaited_once()
  313. notify.on_stock_break_alert.assert_awaited_once()
  314. @pytest.mark.asyncio
  315. async def test_easing_from_break_to_reorder_is_not_announced_again(db_session, scheduler, notify):
  316. """Ordered a few rolls' worth, so it is no longer a break but still under the reorder point.
  317. "Reached the reorder point" straight after "order immediately" is the opposite news."""
  318. await _sku(db_session)
  319. spool = await _spool(db_session, used=BREAK_USED)
  320. await db_session.commit()
  321. await _pass(scheduler, db_session)
  322. spool.weight_used = REORDER_USED
  323. await db_session.commit()
  324. await _pass(scheduler, db_session)
  325. assert notify.on_stock_break_alert.await_count == 1
  326. assert notify.on_stock_reorder_alert.await_count == 1 # the break's own, from before it eased
  327. # Worse again is news again.
  328. spool.weight_used = BREAK_USED
  329. await db_session.commit()
  330. await _pass(scheduler, db_session)
  331. assert notify.on_stock_break_alert.await_count == 2
  332. assert notify.on_stock_reorder_alert.await_count == 1
  333. @pytest.mark.asyncio
  334. async def test_two_colours_of_one_product_alert_separately_and_say_which(db_session, scheduler, notify):
  335. """The SKU is material/subtype/brand/colour, so the messages must differ by colour."""
  336. await _sku(db_session, color="Black")
  337. await _sku(db_session, color="White")
  338. await _spool(db_session, used=REORDER_USED, color="Black")
  339. await _spool(db_session, used=REORDER_USED, color="White")
  340. await db_session.commit()
  341. await _pass(scheduler, db_session)
  342. assert notify.on_stock_reorder_alert.await_count == 2
  343. colours = {call.kwargs["color"] for call in notify.on_stock_reorder_alert.await_args_list}
  344. assert colours == {"Black", "White"}
  345. @pytest.mark.asyncio
  346. async def test_a_sku_that_disappears_and_comes_back_low_alerts_again(db_session, scheduler, notify):
  347. """The roll runs out and is archived, so the SKU has no spools; a new roll is later low.
  348. The old alert must not silence the new roll: nothing has cleared the SKU's
  349. entry, because it was not in the forecast to be cleared.
  350. """
  351. await _sku(db_session)
  352. spool = await _spool(db_session, used=REORDER_USED)
  353. await db_session.commit()
  354. await _pass(scheduler, db_session)
  355. assert notify.on_stock_reorder_alert.await_count == 1
  356. spool.archived_at = utcnow_naive()
  357. await db_session.commit()
  358. await _pass(scheduler, db_session)
  359. await _spool(db_session, used=REORDER_USED)
  360. await db_session.commit()
  361. await _pass(scheduler, db_session)
  362. assert notify.on_stock_reorder_alert.await_count == 2
  363. @pytest.mark.asyncio
  364. async def test_spools_of_one_sku_are_added_together(db_session, scheduler, notify):
  365. """Two half-full spools are one SKU with 1000 g, not two SKUs with 500 g each."""
  366. await _sku(db_session)
  367. await _spool(db_session, used=REORDER_USED) # 80 g left, on its own a reorder
  368. await _spool(db_session, used=QUIET_USED) # 500 g left; together 580 g at 14.2 g/day, 40 days
  369. await db_session.commit()
  370. await _pass(scheduler, db_session)
  371. notify.on_stock_reorder_alert.assert_not_awaited()
  372. notify.on_stock_break_alert.assert_not_awaited()
  373. # -- snooze and archive ------------------------------------------------------
  374. @pytest.mark.asyncio
  375. async def test_a_snoozed_sku_does_not_alert_and_alerts_when_unsnoozed(db_session, scheduler, notify):
  376. row = await _sku(db_session, snoozed=True)
  377. await _spool(db_session, used=REORDER_USED)
  378. await db_session.commit()
  379. await _pass(scheduler, db_session)
  380. notify.on_stock_reorder_alert.assert_not_awaited()
  381. row.alerts_snoozed = False
  382. await db_session.commit()
  383. await _pass(scheduler, db_session)
  384. notify.on_stock_reorder_alert.assert_awaited_once()
  385. @pytest.mark.asyncio
  386. async def test_a_colour_with_no_row_of_its_own_uses_the_colourless_row(db_session, scheduler, notify):
  387. """Settings saved before colour became part of the key live on the colourless row."""
  388. await _sku(db_session, color=None, snoozed=True)
  389. await _spool(db_session, used=REORDER_USED)
  390. await db_session.commit()
  391. await _pass(scheduler, db_session)
  392. notify.on_stock_reorder_alert.assert_not_awaited()
  393. @pytest.mark.asyncio
  394. async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify):
  395. """It would supply 900 g of stock the SKU does not have."""
  396. await _sku(db_session)
  397. await _spool(db_session, used=REORDER_USED)
  398. await _spool(db_session, used=0.0, archived=True)
  399. await db_session.commit()
  400. await _pass(scheduler, db_session)
  401. notify.on_stock_reorder_alert.assert_awaited_once()
  402. assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(80.0)
  403. # -- failure and cost --------------------------------------------------------
  404. @pytest.mark.asyncio
  405. async def test_a_failing_provider_does_not_break_the_pass(db_session, scheduler, notify):
  406. await _sku(db_session)
  407. await _sku(db_session, color="White")
  408. await _spool(db_session, used=REORDER_USED, color="Black")
  409. await _spool(db_session, used=REORDER_USED, color="White")
  410. await db_session.commit()
  411. notify.on_stock_reorder_alert.side_effect = [RuntimeError("provider down"), None]
  412. await _pass(scheduler, db_session)
  413. assert notify.on_stock_reorder_alert.await_count == 2
  414. # The failed alert is lost, not retried every hour: that repetition is what the
  415. # debounce is for, and a provider that is down is the case where it would never stop.
  416. notify.on_stock_reorder_alert.side_effect = None
  417. await _pass(scheduler, db_session)
  418. assert notify.on_stock_reorder_alert.await_count == 2
  419. @pytest.mark.asyncio
  420. async def test_a_second_pass_inside_the_interval_does_no_work(db_session, scheduler, notify):
  421. """The gate. run() re-enters the checks every 3 s while an upload is in flight."""
  422. await _spool(db_session, used=REORDER_USED)
  423. await db_session.commit()
  424. with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
  425. work.return_value = {}
  426. await scheduler._check_stock_forecast(db_session)
  427. wait = scheduler._stock_forecast_next_check - time.monotonic()
  428. await scheduler._check_stock_forecast(db_session)
  429. await scheduler._check_stock_forecast(db_session)
  430. assert work.await_count == 1
  431. # Hourly: the forecast is in whole days, so a finer check repeats the same answer.
  432. assert 3500 < wait <= 3600
  433. # -- the provider guard ------------------------------------------------------
  434. async def _provider(db, *, enabled: bool = True, reorder: bool = False, brk: bool = False) -> None:
  435. db.add(
  436. NotificationProvider(
  437. name=f"p-{enabled}-{reorder}-{brk}",
  438. provider_type="ntfy",
  439. config="{}",
  440. enabled=enabled,
  441. on_stock_reorder_alert=reorder,
  442. on_stock_break_alert=brk,
  443. )
  444. )
  445. await db.flush()
  446. @pytest.fixture
  447. def real_providers():
  448. """The real service, with only the two sends replaced, so the guard runs its real query."""
  449. from backend.app.services.print_scheduler import notification_service
  450. with (
  451. patch.object(notification_service, "on_stock_reorder_alert", new_callable=AsyncMock) as reorder,
  452. patch.object(notification_service, "on_stock_break_alert", new_callable=AsyncMock) as brk,
  453. ):
  454. yield reorder, brk
  455. @pytest.mark.asyncio
  456. async def test_no_work_when_no_provider_wants_either_event(db_session, scheduler, real_providers):
  457. """Both toggles default to off, so on most installs nobody wants this.
  458. Unguarded, every install read its whole spool collection every hour (in
  459. Spoolman mode, over HTTP) for alerts that were switched off. The two
  460. providers are the two ways of not wanting them: enabled with both events off,
  461. and the events on but the provider disabled.
  462. """
  463. await _provider(db_session, enabled=True)
  464. await _provider(db_session, enabled=False, reorder=True, brk=True)
  465. await _spool(db_session, used=REORDER_USED)
  466. await db_session.commit()
  467. with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
  468. await _pass(scheduler, db_session)
  469. work.assert_not_awaited()
  470. @pytest.mark.asyncio
  471. async def test_an_event_nobody_wants_is_not_recorded_as_sent(db_session, scheduler, real_providers):
  472. """Only the break event is on, and the SKU is at its reorder point. Switching
  473. the reorder event on later must report it -- it was never sent."""
  474. reorder, brk = real_providers
  475. await _sku(db_session)
  476. await _spool(db_session, used=REORDER_USED)
  477. provider_row = NotificationProvider(
  478. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
  479. )
  480. db_session.add(provider_row)
  481. await db_session.commit()
  482. await _pass(scheduler, db_session)
  483. reorder.assert_not_awaited()
  484. brk.assert_not_awaited()
  485. provider_row.on_stock_reorder_alert = True
  486. await db_session.commit()
  487. await _pass(scheduler, db_session)
  488. reorder.assert_awaited_once()
  489. @pytest.mark.asyncio
  490. async def test_a_reorder_only_provider_still_hears_about_a_break(db_session, scheduler, real_providers):
  491. """A SKU that is breaking has certainly reached its reorder point, which is what the
  492. Reorder Alert toggle promises. Without this, the worst state is the one a provider
  493. with only that toggle on is never told about."""
  494. reorder, brk = real_providers
  495. await _sku(db_session)
  496. await _spool(db_session, used=BREAK_USED)
  497. await _provider(db_session, reorder=True)
  498. await db_session.commit()
  499. await _pass(scheduler, db_session)
  500. reorder.assert_awaited_once()
  501. assert reorder.await_args.kwargs["skip_break_subscribers"] is True
  502. brk.assert_not_awaited() # nobody has it on
  503. @pytest.mark.asyncio
  504. async def test_switching_the_break_alert_on_later_reports_the_break(db_session, scheduler, real_providers):
  505. """The reorder-only provider was told when the SKU broke. A provider that turns the break
  506. alert on afterwards is told too -- and the reorder-only one is not told a second time."""
  507. reorder, brk = real_providers
  508. await _sku(db_session)
  509. await _spool(db_session, used=BREAK_USED)
  510. await _provider(db_session, reorder=True)
  511. await db_session.commit()
  512. await _pass(scheduler, db_session)
  513. assert reorder.await_count == 1
  514. await _provider(db_session, brk=True)
  515. await db_session.commit()
  516. await _pass(scheduler, db_session)
  517. brk.assert_awaited_once()
  518. assert reorder.await_count == 1
  519. @pytest.mark.asyncio
  520. async def test_switching_one_event_off_and_on_again_reports_it_while_the_other_stays_on(
  521. db_session, scheduler, real_providers
  522. ):
  523. """A wants reorders, B wants breaks, and the SKU is breaking: both were told. B switches
  524. the break alert off (A keeps the check running) and on again: B is told again."""
  525. reorder, brk = real_providers
  526. await _sku(db_session)
  527. await _spool(db_session, used=BREAK_USED)
  528. await _provider(db_session, reorder=True)
  529. b_provider = NotificationProvider(
  530. name="b", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
  531. )
  532. db_session.add(b_provider)
  533. await db_session.commit()
  534. await _pass(scheduler, db_session)
  535. assert (reorder.await_count, brk.await_count) == (1, 1)
  536. b_provider.on_stock_break_alert = False
  537. await db_session.commit()
  538. await _pass(scheduler, db_session)
  539. b_provider.on_stock_break_alert = True
  540. await db_session.commit()
  541. await _pass(scheduler, db_session)
  542. assert brk.await_count == 2
  543. assert reorder.await_count == 1
  544. @pytest.mark.asyncio
  545. async def test_a_break_alert_only_provider_is_not_told_about_a_reorder(db_session, scheduler, real_providers):
  546. reorder, brk = real_providers
  547. await _sku(db_session)
  548. await _spool(db_session, used=REORDER_USED)
  549. await _provider(db_session, brk=True)
  550. await db_session.commit()
  551. await _pass(scheduler, db_session)
  552. reorder.assert_not_awaited()
  553. brk.assert_not_awaited()
  554. @pytest.mark.asyncio
  555. async def test_a_provider_with_both_on_gets_the_break_and_no_second_message(db_session, scheduler):
  556. """Through the real service: A has both toggles on, B only reorder. A break SKU goes to A as
  557. a break and to B as a reorder -- and A does not also get the reorder."""
  558. from backend.app.services.notification_service import NotificationService
  559. from backend.app.services.print_scheduler import notification_service
  560. await _sku(db_session)
  561. await _spool(db_session, used=BREAK_USED)
  562. both = NotificationProvider(
  563. name="both",
  564. provider_type="ntfy",
  565. config="{}",
  566. enabled=True,
  567. on_stock_reorder_alert=True,
  568. on_stock_break_alert=True,
  569. )
  570. only_reorder = NotificationProvider(
  571. name="only-reorder", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  572. )
  573. db_session.add_all([both, only_reorder])
  574. await db_session.commit()
  575. sent: list[tuple[str, list[str]]] = []
  576. async def record(providers, title, message, db, event_type, **kwargs):
  577. sent.append((event_type, sorted(p.name for p in providers)))
  578. with (
  579. patch.object(notification_service, "_send_to_providers", side_effect=record),
  580. patch.object(notification_service, "_build_message_from_template", AsyncMock(return_value=("t", "b"))),
  581. ):
  582. await _pass(scheduler, db_session)
  583. assert isinstance(notification_service, NotificationService)
  584. assert sorted(sent) == [("stock_break_alert", ["both"]), ("stock_reorder_alert", ["only-reorder"])]
  585. @pytest.mark.asyncio
  586. async def test_switching_every_event_off_and_on_again_reports_what_is_low_now(db_session, scheduler, real_providers):
  587. reorder, _brk = real_providers
  588. await _sku(db_session)
  589. await _spool(db_session, used=REORDER_USED)
  590. provider_row = NotificationProvider(
  591. name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
  592. )
  593. db_session.add(provider_row)
  594. await db_session.commit()
  595. await _pass(scheduler, db_session)
  596. assert reorder.await_count == 1
  597. provider_row.on_stock_reorder_alert = False
  598. await db_session.commit()
  599. await _pass(scheduler, db_session)
  600. provider_row.on_stock_reorder_alert = True
  601. await db_session.commit()
  602. await _pass(scheduler, db_session)
  603. assert reorder.await_count == 2
  604. # -- Spoolman mode -----------------------------------------------------------
  605. def _spoolman_spool(spool_id: int, used: float, *, color: str = "Black", age_days: int = 100) -> dict:
  606. """A raw Spoolman spool, in the shape _map_spoolman_spool reads."""
  607. registered = (utcnow_naive() - timedelta(days=age_days)).isoformat() + "Z"
  608. return {
  609. "id": spool_id,
  610. "remaining_weight": 1000 - used,
  611. "used_weight": used,
  612. "registered": registered,
  613. "filament": {
  614. "id": 1,
  615. "name": "PLA Basic",
  616. "material": "PLA",
  617. "weight": 1000,
  618. "color_name": color,
  619. "vendor": {"name": "Bambu Lab"},
  620. },
  621. }
  622. def _spoolman_client(spools, *, unavailable: bool = False):
  623. client = MagicMock()
  624. if unavailable:
  625. client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Cannot reach Spoolman"))
  626. else:
  627. client.get_all_spools = AsyncMock(return_value=spools)
  628. return client
  629. @pytest.mark.asyncio
  630. async def test_spoolman_mode_alerts_from_one_collection_read(db_session, scheduler, notify):
  631. db_session.add(Settings(key="spoolman_enabled", value="true"))
  632. await db_session.commit()
  633. client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED), _spoolman_spool(8, used=0.0, color="White")])
  634. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  635. await _pass(scheduler, db_session)
  636. # Black has 80 g at 9.2 g/day and no SKU row: lead time 0, so a reorder needs the
  637. # 14 day margin -- 80 g is under the ~128 g that gives.
  638. notify.on_stock_reorder_alert.assert_awaited_once()
  639. assert notify.on_stock_reorder_alert.await_args.kwargs["color"] == "Black"
  640. client.get_all_spools.assert_awaited_once()
  641. @pytest.mark.asyncio
  642. async def test_an_unreachable_spoolman_stays_quiet_and_does_not_forget(db_session, scheduler, notify, caplog):
  643. """An outage is an ordinary state for a third-party service. It must not raise, and it
  644. must not clear what was sent: the SKU would alert again the moment Spoolman came back."""
  645. db_session.add(Settings(key="spoolman_enabled", value="true"))
  646. await db_session.commit()
  647. spools = [_spoolman_spool(7, used=REORDER_USED)]
  648. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
  649. await _pass(scheduler, db_session)
  650. assert notify.on_stock_reorder_alert.await_count == 1
  651. down = _spoolman_client([], unavailable=True)
  652. with (
  653. caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"),
  654. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=down)),
  655. ):
  656. await _pass(scheduler, db_session)
  657. # An ordinary state for a third-party service, not a traceback every hour.
  658. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  659. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
  660. await _pass(scheduler, db_session)
  661. assert notify.on_stock_reorder_alert.await_count == 1
  662. @pytest.mark.asyncio
  663. async def test_a_colour_spoolman_made_up_from_the_subtype_is_not_sent_as_a_colour(db_session, scheduler, notify):
  664. """With no colour of its own, Spoolman mode shows the subtype in its place so the list has
  665. no blank colours. In a message that reads "PLA Basic Basic", so the colour is left out."""
  666. db_session.add(Settings(key="spoolman_enabled", value="true"))
  667. await db_session.commit()
  668. raw = _spoolman_spool(7, used=REORDER_USED)
  669. del raw["filament"]["color_name"]
  670. raw["filament"]["name"] = "Basic"
  671. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client([raw]))):
  672. await _pass(scheduler, db_session)
  673. notify.on_stock_reorder_alert.assert_awaited_once()
  674. assert notify.on_stock_reorder_alert.await_args.kwargs["color"] is None
  675. @pytest.mark.asyncio
  676. async def test_spoolman_mode_does_not_borrow_a_local_spools_history(db_session, scheduler, notify):
  677. """A Spoolman id that matches a local spool id (left from before Spoolman was switched on)
  678. must not pick up that spool's usage history. Here the local spool 1 has a history that would
  679. make this a break; Spoolman's spool 1 is quiet by its own delta rate."""
  680. from backend.app.models.spool_usage_history import SpoolUsageHistory
  681. local = await _spool(db_session, used=300.0)
  682. for days_ago in (3, 2, 1):
  683. db_session.add(
  684. SpoolUsageHistory(
  685. spool_id=local.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
  686. )
  687. )
  688. db_session.add(Settings(key="spoolman_enabled", value="true"))
  689. await _sku(db_session)
  690. await db_session.commit()
  691. client = _spoolman_client([_spoolman_spool(local.id, used=300.0)])
  692. with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
  693. await _pass(scheduler, db_session)
  694. notify.on_stock_break_alert.assert_not_awaited()
  695. notify.on_stock_reorder_alert.assert_not_awaited()
  696. # -- the service -------------------------------------------------------------
  697. @pytest.mark.asyncio
  698. async def test_the_events_reach_a_provider_with_subtype_and_colour():
  699. """Both events pass subtype and colour through to the template variables a provider is sent."""
  700. from backend.app.services.notification_service import NotificationService
  701. service = NotificationService()
  702. provider = MagicMock()
  703. provider.id = 1
  704. with (
  705. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
  706. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  707. patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
  708. ):
  709. mock_get.return_value = [provider]
  710. mock_build.return_value = ("t", "b")
  711. await service.on_stock_reorder_alert(
  712. "PLA", "Bambu Lab", 80.0, 9.2, 8, AsyncMock(), subtype="Matte", color="Charcoal"
  713. )
  714. await service.on_stock_break_alert(
  715. "PLA", "Bambu Lab", 40.0, 9.6, 4, 6, AsyncMock(), subtype="Matte", color="Charcoal"
  716. )
  717. assert [c.args[1] for c in mock_get.await_args_list] == ["on_stock_reorder_alert", "on_stock_break_alert"]
  718. assert mock_send.await_count == 2
  719. for call in mock_send.await_args_list:
  720. variables = call.kwargs["variables"]
  721. assert variables["subtype"] == "Matte"
  722. assert variables["color"] == "Charcoal"
  723. assert variables["material"] == "PLA"
  724. @pytest.mark.asyncio
  725. async def test_subtype_and_colour_are_optional_so_the_old_call_shape_still_works():
  726. """Neither method had a caller, but the new arguments are keyword-only with defaults regardless."""
  727. from backend.app.services.notification_service import NotificationService
  728. service = NotificationService()
  729. with (
  730. patch.object(service, "_get_providers_for_event", new_callable=AsyncMock, return_value=[MagicMock()]),
  731. patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
  732. patch.object(service, "_build_message_from_template", new_callable=AsyncMock, return_value=("t", "b")),
  733. ):
  734. await service.on_stock_reorder_alert("PLA", None, 80.0, 9.2, 8, AsyncMock())
  735. variables = mock_send.await_args.kwargs["variables"]
  736. assert variables["subtype"] == ""
  737. assert variables["color"] == ""
  738. @pytest.mark.asyncio
  739. async def test_a_spoolman_registered_date_with_a_trailing_z_still_gives_the_spool_an_age(db_session, scheduler, notify):
  740. """Python 3.10's fromisoformat rejects a trailing "Z" (pyproject still allows 3.10), which
  741. silently dropped the spool's age and with it the delta rate. Simulated here so the test
  742. means the same on every interpreter."""
  743. class _Py310Datetime(datetime):
  744. @classmethod
  745. def fromisoformat(cls, value):
  746. if value.endswith("Z"):
  747. raise ValueError(f"Invalid isoformat string: {value!r}")
  748. return super().fromisoformat(value)
  749. db_session.add(Settings(key="spoolman_enabled", value="true"))
  750. await db_session.commit()
  751. client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED)])
  752. with (
  753. patch("backend.app.services.print_scheduler.datetime", _Py310Datetime),
  754. patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
  755. ):
  756. await _pass(scheduler, db_session)
  757. # 920 g in 100 days is 9.2 g/day: a reorder. With the age dropped the rate falls back to a
  758. # different number and this SKU is not at its reorder point.
  759. notify.on_stock_reorder_alert.assert_awaited_once()
  760. assert notify.on_stock_reorder_alert.await_args.args[3] == pytest.approx(9.2, abs=0.05)