Files
bambuddy/backend/tests/unit/services/test_spool_assignment_notifications.py
T
maziggy 305529f483 fix(notifications): missing-spool-assignment check now unions both assignment tables (#1473)
notify_missing_spool_assignments_on_print_start queried only the legacy
  SpoolAssignment table. In Spoolman mode that table is empty -- bindings
  live in spoolman_slot_assignments -- so assigned_global_trays came back
  empty and every used tray was flagged missing, firing a false-positive
  notification on every print.

  Union SpoolAssignment + SpoolmanSlotAssignment rows for the printer
  before computing the missing set. Both tables expose printer_id /
  ams_id / tray_id identically, so _global_tray_from_assignment is
  unchanged. Union-only, so legacy-mode behavior cannot regress.
2026-05-21 09:02:18 +02:00

172 lines
6.3 KiB
Python

"""Unit tests for spool assignment notification service."""
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
class _FakeAssignmentsResult:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _FakeSession:
"""Fake DB session that returns legacy vs. Spoolman assignment rows based
on which table the SELECT targets, so tests can exercise either mode."""
def __init__(
self,
printer_name: str,
legacy: list[SimpleNamespace] | None = None,
spoolman: list[SimpleNamespace] | None = None,
):
self._printer = SimpleNamespace(name=printer_name)
self._legacy = legacy or []
self._spoolman = spoolman or []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, model, key):
return self._printer
async def execute(self, statement):
table = statement.get_final_froms()[0].name
if table == "spoolman_slot_assignments":
return _FakeAssignmentsResult(self._spoolman)
return _FakeAssignmentsResult(self._legacy)
@pytest.mark.asyncio
async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
"""When a mapped tray is unassigned, service emits websocket and notification events."""
logger = logging.getLogger(__name__)
data = {
"ams_mapping": [1],
"raw_data": {},
}
# Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
with (
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=_FakeSession("Printer A", assignments),
),
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_ws,
patch(
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
new_callable=AsyncMock,
) as mock_notify,
):
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
ws_kwargs = mock_ws.await_args.kwargs
assert ws_kwargs["printer_id"] == 1
assert ws_kwargs["printer_name"] == "Printer A"
assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
notify_kwargs = mock_notify.await_args.kwargs
assert notify_kwargs["printer_id"] == 1
assert notify_kwargs["printer_name"] == "Printer A"
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
def _patches(session):
"""Common patch set: the fake session + stubbed printer state / emitters."""
return (
patch(
"backend.app.services.spool_assignment_notifications.async_session",
return_value=session,
),
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
patch(
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
new_callable=AsyncMock,
),
patch(
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
new_callable=AsyncMock,
),
)
@pytest.mark.asyncio
async def test_spoolman_only_assignment_suppresses_notification():
"""#1473 — trays bound only via spoolman_slot_assignments must NOT be
flagged missing (the legacy spool_assignment table is empty in Spoolman
mode, so checking it alone fired a false positive on every print)."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}} # print uses A1 + A2
# Both used trays bound via Spoolman; legacy table empty.
session = _FakeSession(
"Printer A",
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()
mock_notify.assert_not_awaited()
@pytest.mark.asyncio
async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
"""A Spoolman assignment for A1 only, with a print using A1 + A2, flags
A2 alone."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[],
spoolman=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 only
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_awaited_once()
assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
mock_notify.assert_awaited_once()
@pytest.mark.asyncio
async def test_mixed_mode_union_covers_all_used_trays():
"""A1 bound in the legacy table, A2 bound in spoolman_slot_assignments —
the union covers both used trays, so no notification fires."""
logger = logging.getLogger(__name__)
data = {"ams_mapping": [0, 1], "raw_data": {}}
session = _FakeSession(
"Printer A",
legacy=[SimpleNamespace(ams_id=0, tray_id=0)], # A1
spoolman=[SimpleNamespace(ams_id=0, tray_id=1)], # A2
)
p_session, p_status, p_ws, p_notify = _patches(session)
with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
await notify_missing_spool_assignments_on_print_start(1, data, logger)
mock_ws.assert_not_awaited()
mock_notify.assert_not_awaited()