|
@@ -0,0 +1,989 @@
|
|
|
|
|
+"""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)
|