| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989 |
- """The two Inventory stock alerts have a producer now (issue #2955).
- ``on_stock_reorder_alert`` and ``on_stock_break_alert`` had a column, a schema
- field, a route, a template and a UI toggle -- and, until #2945, could not even be
- saved -- but nothing that decides a SKU has reached its reorder point: the Forecast
- panel does that in the browser, so with no page open nothing could ever alert.
- ``PrintScheduler._check_stock_forecast`` runs the panel's arithmetic
- (``stock_forecast``) from the scheduler loop.
- The arithmetic itself is pinned against the panel in ``test_stock_forecast_2955``;
- these tests are about *when* it alerts and what it sends.
- The scenarios use the delta rate (grams consumed over the age of the spool)
- because it needs no usage history: a 1000 g spool created 100 days ago with
- ``used`` grams consumed runs at ``used / 100`` g/day. With a 6 day lead time and
- the default 14 day margin:
- used 920 -> 80 g left, 9.2 g/day, 8 days of stock, reorder point ~90 g : reorder
- used 960 -> 40 g left, 9.6 g/day, 4 days of stock : break
- used 500 -> 500 g left, 5 g/day, 100 days of stock : nothing
- """
- import json
- import logging
- import time
- from datetime import datetime, timedelta
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.models.filament_sku_settings import FilamentSkuSettings
- from backend.app.models.notification import NotificationProvider
- from backend.app.models.settings import Settings
- from backend.app.models.spool import Spool
- from backend.app.services.print_scheduler import _STOCK_ALERTS_SETTING_KEY, PrintScheduler
- from backend.app.services.spoolman import SpoolmanUnavailableError
- from backend.app.utils.local_time import utcnow_naive
- REORDER_USED = 920.0
- BREAK_USED = 960.0
- QUIET_USED = 500.0
- async def _spool(
- db,
- *,
- used: float,
- color: str | None = "Black",
- subtype: str | None = "Basic",
- brand: str | None = "Bambu Lab",
- age_days: int = 100,
- archived: bool = False,
- baseline: float = 0.0,
- ) -> Spool:
- spool = Spool(
- material="PLA",
- subtype=subtype,
- brand=brand,
- color_name=color,
- label_weight=1000,
- core_weight=250,
- weight_used=used,
- weight_used_baseline=baseline,
- created_at=utcnow_naive() - timedelta(days=age_days),
- archived_at=utcnow_naive() if archived else None,
- )
- spool.k_profiles = []
- spool.assignments = []
- db.add(spool)
- await db.flush()
- return spool
- async def _sku(db, *, lead: int = 6, color: str | None = "Black", snoozed: bool = False, margin: int = 14):
- row = FilamentSkuSettings(
- material="PLA",
- subtype="Basic",
- brand="Bambu Lab",
- color_name=color,
- lead_time_days=lead,
- safety_margin_value=margin,
- safety_margin_unit="days",
- alerts_snoozed=snoozed,
- )
- db.add(row)
- await db.flush()
- return row
- @pytest.fixture
- def scheduler():
- return PrintScheduler()
- async def _pass(scheduler, db):
- """Run one check, stepping over the hourly gate (which has its own test)."""
- scheduler._stock_forecast_next_check = 0.0
- await scheduler._check_stock_forecast(db)
- @pytest.fixture
- def notify():
- """The service, replaced, with a provider that wants both events.
- Every test about the producer assumes someone wants the alerts; the guard in
- front of it has its own tests further down, against the real provider query.
- """
- with patch("backend.app.services.print_scheduler.notification_service") as ns:
- ns.on_stock_reorder_alert = AsyncMock()
- ns.on_stock_break_alert = AsyncMock()
- ns._get_providers_for_event = AsyncMock(return_value=[MagicMock()])
- yield ns
- # -- a restart does not start it over ----------------------------------------
- async def _stored(db) -> list | None:
- """What the settings row holds, parsed. None when there is no row."""
- from sqlalchemy import select
- row = (await db.execute(select(Settings).where(Settings.key == _STOCK_ALERTS_SETTING_KEY))).scalar_one_or_none()
- return json.loads(row.value) if row else None
- @pytest.mark.asyncio
- async def test_a_fresh_scheduler_does_not_re_send_what_is_already_stored(db_session, scheduler, notify):
- """The first check runs as soon as Bambuddy starts. Without the stored set, every update
- re-sent one message per SKU that was still low."""
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- restarted = PrintScheduler()
- await _pass(restarted, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- notify.on_stock_break_alert.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_stored_reorder_still_lets_the_worsening_to_a_break_through_after_a_restart(
- db_session, scheduler, notify
- ):
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- spool.weight_used = BREAK_USED
- await db_session.commit()
- restarted = PrintScheduler()
- await _pass(restarted, db_session)
- notify.on_stock_break_alert.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_a_sku_that_cleared_while_it_was_down_alerts_again_after_a_restart(db_session, scheduler, notify):
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- spool.weight_used = QUIET_USED
- await db_session.commit()
- await _pass(PrintScheduler(), db_session)
- spool.weight_used = REORDER_USED
- await db_session.commit()
- await _pass(PrintScheduler(), db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- @pytest.mark.asyncio
- async def test_the_stored_set_drops_a_sku_that_no_longer_exists(db_session, scheduler, notify):
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert len(await _stored(db_session)) == 1
- spool.archived_at = utcnow_naive()
- await db_session.commit()
- await _pass(PrintScheduler(), db_session)
- assert await _stored(db_session) == []
- @pytest.mark.asyncio
- async def test_the_stored_set_is_emptied_when_nobody_wants_either_event(db_session, scheduler, real_providers):
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- provider_row = NotificationProvider(
- name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
- )
- db_session.add(provider_row)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert len(await _stored(db_session)) == 1
- provider_row.on_stock_reorder_alert = False
- await db_session.commit()
- await _pass(PrintScheduler(), db_session)
- assert await _stored(db_session) == []
- @pytest.mark.asyncio
- async def test_an_unchanged_set_is_not_written_again(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- with patch("backend.app.core.db_dialect.upsert_setting", AsyncMock()) as upsert:
- await _pass(scheduler, db_session)
- await _pass(PrintScheduler(), db_session)
- upsert.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_an_unreadable_stored_set_is_treated_as_empty(db_session, scheduler, notify):
- db_session.add(Settings(key=_STOCK_ALERTS_SETTING_KEY, value="not json {"))
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert len(await _stored(db_session)) == 1
- # -- when it alerts ----------------------------------------------------------
- @pytest.mark.asyncio
- async def test_a_sku_at_its_reorder_point_alerts_once_with_its_numbers(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_not_awaited()
- notify.on_stock_reorder_alert.assert_awaited_once()
- material, brand, stock_g, rate, days_left, _db = notify.on_stock_reorder_alert.await_args.args
- assert (material, brand) == ("PLA", "Bambu Lab")
- assert stock_g == pytest.approx(80.0)
- assert rate == pytest.approx(9.2, rel=1e-3)
- assert days_left == 8
- kwargs = notify.on_stock_reorder_alert.await_args.kwargs
- assert kwargs == {"subtype": "Basic", "color": "Black", "skip_break_subscribers": False}
- @pytest.mark.asyncio
- async def test_a_sku_that_runs_out_before_the_lead_time_alerts_as_a_break(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=BREAK_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_awaited_once()
- args = notify.on_stock_break_alert.await_args.args
- assert args[2] == pytest.approx(40.0)
- assert args[4] == 4 # days left
- assert args[5] == 6 # lead time
- assert notify.on_stock_break_alert.await_args.kwargs == {"subtype": "Basic", "color": "Black"}
- # A breaking SKU has also reached its reorder point: the reorder event goes too, minus the
- # providers that have the break alert on (the service applies that filter; see below).
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert notify.on_stock_reorder_alert.await_args.kwargs["skip_break_subscribers"] is True
- @pytest.mark.asyncio
- async def test_a_sku_with_plenty_of_stock_stays_quiet(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=QUIET_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_not_awaited()
- notify.on_stock_break_alert.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_sku_that_has_not_been_used_has_no_rate_and_stays_quiet(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=0.0)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_not_awaited()
- notify.on_stock_break_alert.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_the_global_lead_time_counts(db_session, scheduler, notify):
- """No SKU row at all: the lead time is the global setting, and 40 g at 9.6 g/day is 4 days."""
- db_session.add(Settings(key="forecast_global_lead_time_days", value="30"))
- await _spool(db_session, used=BREAK_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_awaited_once()
- assert notify.on_stock_break_alert.await_args.args[5] == 30
- @pytest.mark.asyncio
- async def test_usage_history_beats_the_delta_rate_when_there_is_enough_of_it(db_session, scheduler, notify):
- """300 g used over 100 days is 3 g/day and 700 g left is months of stock. But the
- last three days took 300 g each: the history rate says two days, so it is a break.
- Also the only test that reads ``spool_usage_history`` through the scheduler.
- """
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- await _sku(db_session)
- spool = await _spool(db_session, used=300.0)
- for days_ago in (3, 2, 1):
- db_session.add(
- SpoolUsageHistory(
- spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
- )
- )
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_history_of_a_reset_spool_is_left_out(db_session, scheduler, notify):
- """Same history, but the spool was reset (baseline set): its earlier events have no
- anchor, so the rate falls back to the delta rate -- 3 g/day, nothing to alert."""
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- await _sku(db_session)
- spool = await _spool(db_session, used=300.0, baseline=100.0)
- for days_ago in (3, 2, 1):
- db_session.add(
- SpoolUsageHistory(
- spool_id=spool.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
- )
- )
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_not_awaited()
- notify.on_stock_reorder_alert.assert_not_awaited()
- # -- once per transition -----------------------------------------------------
- @pytest.mark.asyncio
- async def test_does_not_repeat_on_the_next_pass(db_session, scheduler, notify):
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- await _pass(scheduler, db_session)
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_re_arms_once_the_condition_clears(db_session, scheduler, notify):
- """Restocked (a fresh roll brings the SKU back above the line), then low again."""
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- spool.weight_used = QUIET_USED
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- spool.weight_used = REORDER_USED
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- @pytest.mark.asyncio
- async def test_worsening_from_reorder_to_break_alerts_again_as_a_break(db_session, scheduler, notify):
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- spool.weight_used = BREAK_USED
- await db_session.commit()
- await _pass(scheduler, db_session)
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- notify.on_stock_break_alert.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_easing_from_break_to_reorder_is_not_announced_again(db_session, scheduler, notify):
- """Ordered a few rolls' worth, so it is no longer a break but still under the reorder point.
- "Reached the reorder point" straight after "order immediately" is the opposite news."""
- await _sku(db_session)
- spool = await _spool(db_session, used=BREAK_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- spool.weight_used = REORDER_USED
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_break_alert.await_count == 1
- assert notify.on_stock_reorder_alert.await_count == 1 # the break's own, from before it eased
- # Worse again is news again.
- spool.weight_used = BREAK_USED
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_break_alert.await_count == 2
- assert notify.on_stock_reorder_alert.await_count == 1
- @pytest.mark.asyncio
- async def test_two_colours_of_one_product_alert_separately_and_say_which(db_session, scheduler, notify):
- """The SKU is material/subtype/brand/colour, so the messages must differ by colour."""
- await _sku(db_session, color="Black")
- await _sku(db_session, color="White")
- await _spool(db_session, used=REORDER_USED, color="Black")
- await _spool(db_session, used=REORDER_USED, color="White")
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- colours = {call.kwargs["color"] for call in notify.on_stock_reorder_alert.await_args_list}
- assert colours == {"Black", "White"}
- @pytest.mark.asyncio
- async def test_a_sku_that_disappears_and_comes_back_low_alerts_again(db_session, scheduler, notify):
- """The roll runs out and is archived, so the SKU has no spools; a new roll is later low.
- The old alert must not silence the new roll: nothing has cleared the SKU's
- entry, because it was not in the forecast to be cleared.
- """
- await _sku(db_session)
- spool = await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- spool.archived_at = utcnow_naive()
- await db_session.commit()
- await _pass(scheduler, db_session)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- @pytest.mark.asyncio
- async def test_spools_of_one_sku_are_added_together(db_session, scheduler, notify):
- """Two half-full spools are one SKU with 1000 g, not two SKUs with 500 g each."""
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED) # 80 g left, on its own a reorder
- await _spool(db_session, used=QUIET_USED) # 500 g left; together 580 g at 14.2 g/day, 40 days
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_not_awaited()
- notify.on_stock_break_alert.assert_not_awaited()
- # -- snooze and archive ------------------------------------------------------
- @pytest.mark.asyncio
- async def test_a_snoozed_sku_does_not_alert_and_alerts_when_unsnoozed(db_session, scheduler, notify):
- row = await _sku(db_session, snoozed=True)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_not_awaited()
- row.alerts_snoozed = False
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_a_colour_with_no_row_of_its_own_uses_the_colourless_row(db_session, scheduler, notify):
- """Settings saved before colour became part of the key live on the colourless row."""
- await _sku(db_session, color=None, snoozed=True)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_an_archived_spool_is_not_stock(db_session, scheduler, notify):
- """It would supply 900 g of stock the SKU does not have."""
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await _spool(db_session, used=0.0, archived=True)
- await db_session.commit()
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert notify.on_stock_reorder_alert.await_args.args[2] == pytest.approx(80.0)
- # -- failure and cost --------------------------------------------------------
- @pytest.mark.asyncio
- async def test_a_failing_provider_does_not_break_the_pass(db_session, scheduler, notify):
- await _sku(db_session)
- await _sku(db_session, color="White")
- await _spool(db_session, used=REORDER_USED, color="Black")
- await _spool(db_session, used=REORDER_USED, color="White")
- await db_session.commit()
- notify.on_stock_reorder_alert.side_effect = [RuntimeError("provider down"), None]
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- # The failed alert is lost, not retried every hour: that repetition is what the
- # debounce is for, and a provider that is down is the case where it would never stop.
- notify.on_stock_reorder_alert.side_effect = None
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 2
- @pytest.mark.asyncio
- async def test_a_second_pass_inside_the_interval_does_no_work(db_session, scheduler, notify):
- """The gate. run() re-enters the checks every 3 s while an upload is in flight."""
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
- work.return_value = {}
- await scheduler._check_stock_forecast(db_session)
- wait = scheduler._stock_forecast_next_check - time.monotonic()
- await scheduler._check_stock_forecast(db_session)
- await scheduler._check_stock_forecast(db_session)
- assert work.await_count == 1
- # Hourly: the forecast is in whole days, so a finer check repeats the same answer.
- assert 3500 < wait <= 3600
- # -- the provider guard ------------------------------------------------------
- async def _provider(db, *, enabled: bool = True, reorder: bool = False, brk: bool = False) -> None:
- db.add(
- NotificationProvider(
- name=f"p-{enabled}-{reorder}-{brk}",
- provider_type="ntfy",
- config="{}",
- enabled=enabled,
- on_stock_reorder_alert=reorder,
- on_stock_break_alert=brk,
- )
- )
- await db.flush()
- @pytest.fixture
- def real_providers():
- """The real service, with only the two sends replaced, so the guard runs its real query."""
- from backend.app.services.print_scheduler import notification_service
- with (
- patch.object(notification_service, "on_stock_reorder_alert", new_callable=AsyncMock) as reorder,
- patch.object(notification_service, "on_stock_break_alert", new_callable=AsyncMock) as brk,
- ):
- yield reorder, brk
- @pytest.mark.asyncio
- async def test_no_work_when_no_provider_wants_either_event(db_session, scheduler, real_providers):
- """Both toggles default to off, so on most installs nobody wants this.
- Unguarded, every install read its whole spool collection every hour (in
- Spoolman mode, over HTTP) for alerts that were switched off. The two
- providers are the two ways of not wanting them: enabled with both events off,
- and the events on but the provider disabled.
- """
- await _provider(db_session, enabled=True)
- await _provider(db_session, enabled=False, reorder=True, brk=True)
- await _spool(db_session, used=REORDER_USED)
- await db_session.commit()
- with patch.object(scheduler, "_stock_forecasts", new_callable=AsyncMock) as work:
- await _pass(scheduler, db_session)
- work.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_an_event_nobody_wants_is_not_recorded_as_sent(db_session, scheduler, real_providers):
- """Only the break event is on, and the SKU is at its reorder point. Switching
- the reorder event on later must report it -- it was never sent."""
- reorder, brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- provider_row = NotificationProvider(
- name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
- )
- db_session.add(provider_row)
- await db_session.commit()
- await _pass(scheduler, db_session)
- reorder.assert_not_awaited()
- brk.assert_not_awaited()
- provider_row.on_stock_reorder_alert = True
- await db_session.commit()
- await _pass(scheduler, db_session)
- reorder.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_a_reorder_only_provider_still_hears_about_a_break(db_session, scheduler, real_providers):
- """A SKU that is breaking has certainly reached its reorder point, which is what the
- Reorder Alert toggle promises. Without this, the worst state is the one a provider
- with only that toggle on is never told about."""
- reorder, brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=BREAK_USED)
- await _provider(db_session, reorder=True)
- await db_session.commit()
- await _pass(scheduler, db_session)
- reorder.assert_awaited_once()
- assert reorder.await_args.kwargs["skip_break_subscribers"] is True
- brk.assert_not_awaited() # nobody has it on
- @pytest.mark.asyncio
- async def test_switching_the_break_alert_on_later_reports_the_break(db_session, scheduler, real_providers):
- """The reorder-only provider was told when the SKU broke. A provider that turns the break
- alert on afterwards is told too -- and the reorder-only one is not told a second time."""
- reorder, brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=BREAK_USED)
- await _provider(db_session, reorder=True)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert reorder.await_count == 1
- await _provider(db_session, brk=True)
- await db_session.commit()
- await _pass(scheduler, db_session)
- brk.assert_awaited_once()
- assert reorder.await_count == 1
- @pytest.mark.asyncio
- async def test_switching_one_event_off_and_on_again_reports_it_while_the_other_stays_on(
- db_session, scheduler, real_providers
- ):
- """A wants reorders, B wants breaks, and the SKU is breaking: both were told. B switches
- the break alert off (A keeps the check running) and on again: B is told again."""
- reorder, brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=BREAK_USED)
- await _provider(db_session, reorder=True)
- b_provider = NotificationProvider(
- name="b", provider_type="ntfy", config="{}", enabled=True, on_stock_break_alert=True
- )
- db_session.add(b_provider)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert (reorder.await_count, brk.await_count) == (1, 1)
- b_provider.on_stock_break_alert = False
- await db_session.commit()
- await _pass(scheduler, db_session)
- b_provider.on_stock_break_alert = True
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert brk.await_count == 2
- assert reorder.await_count == 1
- @pytest.mark.asyncio
- async def test_a_break_alert_only_provider_is_not_told_about_a_reorder(db_session, scheduler, real_providers):
- reorder, brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- await _provider(db_session, brk=True)
- await db_session.commit()
- await _pass(scheduler, db_session)
- reorder.assert_not_awaited()
- brk.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_a_provider_with_both_on_gets_the_break_and_no_second_message(db_session, scheduler):
- """Through the real service: A has both toggles on, B only reorder. A break SKU goes to A as
- a break and to B as a reorder -- and A does not also get the reorder."""
- from backend.app.services.notification_service import NotificationService
- from backend.app.services.print_scheduler import notification_service
- await _sku(db_session)
- await _spool(db_session, used=BREAK_USED)
- both = NotificationProvider(
- name="both",
- provider_type="ntfy",
- config="{}",
- enabled=True,
- on_stock_reorder_alert=True,
- on_stock_break_alert=True,
- )
- only_reorder = NotificationProvider(
- name="only-reorder", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
- )
- db_session.add_all([both, only_reorder])
- await db_session.commit()
- sent: list[tuple[str, list[str]]] = []
- async def record(providers, title, message, db, event_type, **kwargs):
- sent.append((event_type, sorted(p.name for p in providers)))
- with (
- patch.object(notification_service, "_send_to_providers", side_effect=record),
- patch.object(notification_service, "_build_message_from_template", AsyncMock(return_value=("t", "b"))),
- ):
- await _pass(scheduler, db_session)
- assert isinstance(notification_service, NotificationService)
- assert sorted(sent) == [("stock_break_alert", ["both"]), ("stock_reorder_alert", ["only-reorder"])]
- @pytest.mark.asyncio
- async def test_switching_every_event_off_and_on_again_reports_what_is_low_now(db_session, scheduler, real_providers):
- reorder, _brk = real_providers
- await _sku(db_session)
- await _spool(db_session, used=REORDER_USED)
- provider_row = NotificationProvider(
- name="p", provider_type="ntfy", config="{}", enabled=True, on_stock_reorder_alert=True
- )
- db_session.add(provider_row)
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert reorder.await_count == 1
- provider_row.on_stock_reorder_alert = False
- await db_session.commit()
- await _pass(scheduler, db_session)
- provider_row.on_stock_reorder_alert = True
- await db_session.commit()
- await _pass(scheduler, db_session)
- assert reorder.await_count == 2
- # -- Spoolman mode -----------------------------------------------------------
- def _spoolman_spool(spool_id: int, used: float, *, color: str = "Black", age_days: int = 100) -> dict:
- """A raw Spoolman spool, in the shape _map_spoolman_spool reads."""
- registered = (utcnow_naive() - timedelta(days=age_days)).isoformat() + "Z"
- return {
- "id": spool_id,
- "remaining_weight": 1000 - used,
- "used_weight": used,
- "registered": registered,
- "filament": {
- "id": 1,
- "name": "PLA Basic",
- "material": "PLA",
- "weight": 1000,
- "color_name": color,
- "vendor": {"name": "Bambu Lab"},
- },
- }
- def _spoolman_client(spools, *, unavailable: bool = False):
- client = MagicMock()
- if unavailable:
- client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Cannot reach Spoolman"))
- else:
- client.get_all_spools = AsyncMock(return_value=spools)
- return client
- @pytest.mark.asyncio
- async def test_spoolman_mode_alerts_from_one_collection_read(db_session, scheduler, notify):
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED), _spoolman_spool(8, used=0.0, color="White")])
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
- await _pass(scheduler, db_session)
- # Black has 80 g at 9.2 g/day and no SKU row: lead time 0, so a reorder needs the
- # 14 day margin -- 80 g is under the ~128 g that gives.
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert notify.on_stock_reorder_alert.await_args.kwargs["color"] == "Black"
- client.get_all_spools.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_an_unreachable_spoolman_stays_quiet_and_does_not_forget(db_session, scheduler, notify, caplog):
- """An outage is an ordinary state for a third-party service. It must not raise, and it
- must not clear what was sent: the SKU would alert again the moment Spoolman came back."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- spools = [_spoolman_spool(7, used=REORDER_USED)]
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- down = _spoolman_client([], unavailable=True)
- with (
- caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"),
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=down)),
- ):
- await _pass(scheduler, db_session)
- # An ordinary state for a third-party service, not a traceback every hour.
- assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client(spools))):
- await _pass(scheduler, db_session)
- assert notify.on_stock_reorder_alert.await_count == 1
- @pytest.mark.asyncio
- async def test_a_colour_spoolman_made_up_from_the_subtype_is_not_sent_as_a_colour(db_session, scheduler, notify):
- """With no colour of its own, Spoolman mode shows the subtype in its place so the list has
- no blank colours. In a message that reads "PLA Basic Basic", so the colour is left out."""
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- raw = _spoolman_spool(7, used=REORDER_USED)
- del raw["filament"]["color_name"]
- raw["filament"]["name"] = "Basic"
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=_spoolman_client([raw]))):
- await _pass(scheduler, db_session)
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert notify.on_stock_reorder_alert.await_args.kwargs["color"] is None
- @pytest.mark.asyncio
- async def test_spoolman_mode_does_not_borrow_a_local_spools_history(db_session, scheduler, notify):
- """A Spoolman id that matches a local spool id (left from before Spoolman was switched on)
- must not pick up that spool's usage history. Here the local spool 1 has a history that would
- make this a break; Spoolman's spool 1 is quiet by its own delta rate."""
- from backend.app.models.spool_usage_history import SpoolUsageHistory
- local = await _spool(db_session, used=300.0)
- for days_ago in (3, 2, 1):
- db_session.add(
- SpoolUsageHistory(
- spool_id=local.id, weight_used=300.0, created_at=utcnow_naive() - timedelta(days=days_ago)
- )
- )
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await _sku(db_session)
- await db_session.commit()
- client = _spoolman_client([_spoolman_spool(local.id, used=300.0)])
- with patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
- await _pass(scheduler, db_session)
- notify.on_stock_break_alert.assert_not_awaited()
- notify.on_stock_reorder_alert.assert_not_awaited()
- # -- the service -------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_the_events_reach_a_provider_with_subtype_and_colour():
- """Both events pass subtype and colour through to the template variables a provider is sent."""
- from backend.app.services.notification_service import NotificationService
- service = NotificationService()
- provider = MagicMock()
- provider.id = 1
- with (
- patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
- patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
- patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
- ):
- mock_get.return_value = [provider]
- mock_build.return_value = ("t", "b")
- await service.on_stock_reorder_alert(
- "PLA", "Bambu Lab", 80.0, 9.2, 8, AsyncMock(), subtype="Matte", color="Charcoal"
- )
- await service.on_stock_break_alert(
- "PLA", "Bambu Lab", 40.0, 9.6, 4, 6, AsyncMock(), subtype="Matte", color="Charcoal"
- )
- assert [c.args[1] for c in mock_get.await_args_list] == ["on_stock_reorder_alert", "on_stock_break_alert"]
- assert mock_send.await_count == 2
- for call in mock_send.await_args_list:
- variables = call.kwargs["variables"]
- assert variables["subtype"] == "Matte"
- assert variables["color"] == "Charcoal"
- assert variables["material"] == "PLA"
- @pytest.mark.asyncio
- async def test_subtype_and_colour_are_optional_so_the_old_call_shape_still_works():
- """Neither method had a caller, but the new arguments are keyword-only with defaults regardless."""
- from backend.app.services.notification_service import NotificationService
- service = NotificationService()
- with (
- patch.object(service, "_get_providers_for_event", new_callable=AsyncMock, return_value=[MagicMock()]),
- patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
- patch.object(service, "_build_message_from_template", new_callable=AsyncMock, return_value=("t", "b")),
- ):
- await service.on_stock_reorder_alert("PLA", None, 80.0, 9.2, 8, AsyncMock())
- variables = mock_send.await_args.kwargs["variables"]
- assert variables["subtype"] == ""
- assert variables["color"] == ""
- @pytest.mark.asyncio
- async def test_a_spoolman_registered_date_with_a_trailing_z_still_gives_the_spool_an_age(db_session, scheduler, notify):
- """Python 3.10's fromisoformat rejects a trailing "Z" (pyproject still allows 3.10), which
- silently dropped the spool's age and with it the delta rate. Simulated here so the test
- means the same on every interpreter."""
- class _Py310Datetime(datetime):
- @classmethod
- def fromisoformat(cls, value):
- if value.endswith("Z"):
- raise ValueError(f"Invalid isoformat string: {value!r}")
- return super().fromisoformat(value)
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- client = _spoolman_client([_spoolman_spool(7, used=REORDER_USED)])
- with (
- patch("backend.app.services.print_scheduler.datetime", _Py310Datetime),
- patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
- ):
- await _pass(scheduler, db_session)
- # 920 g in 100 days is 9.2 g/day: a reorder. With the age dropped the rate falls back to a
- # different number and this SKU is not at its reorder point.
- notify.on_stock_reorder_alert.assert_awaited_once()
- assert notify.on_stock_reorder_alert.await_args.args[3] == pytest.approx(9.2, abs=0.05)
|