test_spool_assignment_notifications.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Unit tests for spool assignment notification service."""
  2. import logging
  3. from types import SimpleNamespace
  4. from unittest.mock import AsyncMock, patch
  5. import pytest
  6. from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
  7. class _FakeAssignmentsResult:
  8. def __init__(self, rows):
  9. self._rows = rows
  10. def fetchall(self):
  11. return self._rows
  12. class _FakeSession:
  13. """Fake DB session that returns legacy vs. Spoolman assignment rows based
  14. on which table the SELECT targets, so tests can exercise either mode."""
  15. def __init__(
  16. self,
  17. printer_name: str,
  18. legacy: list[SimpleNamespace] | None = None,
  19. spoolman: list[SimpleNamespace] | None = None,
  20. ):
  21. self._printer = SimpleNamespace(name=printer_name)
  22. self._legacy = legacy or []
  23. self._spoolman = spoolman or []
  24. async def __aenter__(self):
  25. return self
  26. async def __aexit__(self, exc_type, exc, tb):
  27. return False
  28. async def get(self, model, key):
  29. return self._printer
  30. async def execute(self, statement):
  31. table = statement.get_final_froms()[0].name
  32. if table == "spoolman_slot_assignments":
  33. return _FakeAssignmentsResult(self._spoolman)
  34. return _FakeAssignmentsResult(self._legacy)
  35. @pytest.mark.asyncio
  36. async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
  37. """When a mapped tray is unassigned, service emits websocket and notification events."""
  38. logger = logging.getLogger(__name__)
  39. data = {
  40. "ams_mapping": [1],
  41. "raw_data": {},
  42. }
  43. # Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
  44. assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
  45. with (
  46. patch(
  47. "backend.app.services.spool_assignment_notifications.async_session",
  48. return_value=_FakeSession("Printer A", assignments),
  49. ),
  50. patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
  51. patch(
  52. "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
  53. new_callable=AsyncMock,
  54. ) as mock_ws,
  55. patch(
  56. "backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
  57. new_callable=AsyncMock,
  58. ) as mock_notify,
  59. ):
  60. await notify_missing_spool_assignments_on_print_start(1, data, logger)
  61. mock_ws.assert_awaited_once()
  62. ws_kwargs = mock_ws.await_args.kwargs
  63. assert ws_kwargs["printer_id"] == 1
  64. assert ws_kwargs["printer_name"] == "Printer A"
  65. assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
  66. mock_notify.assert_awaited_once()
  67. notify_kwargs = mock_notify.await_args.kwargs
  68. assert notify_kwargs["printer_id"] == 1
  69. assert notify_kwargs["printer_name"] == "Printer A"
  70. assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
  71. def _patches(session):
  72. """Common patch set: the fake session + stubbed printer state / emitters."""
  73. return (
  74. patch(
  75. "backend.app.services.spool_assignment_notifications.async_session",
  76. return_value=session,
  77. ),
  78. patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
  79. patch(
  80. "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
  81. new_callable=AsyncMock,
  82. ),
  83. patch(
  84. "backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
  85. new_callable=AsyncMock,
  86. ),
  87. )
  88. @pytest.mark.asyncio
  89. async def test_spoolman_only_assignment_suppresses_notification():
  90. """#1473 — trays bound only via spoolman_slot_assignments must NOT be
  91. flagged missing (the legacy spool_assignment table is empty in Spoolman
  92. mode, so checking it alone fired a false positive on every print)."""
  93. logger = logging.getLogger(__name__)
  94. data = {"ams_mapping": [0, 1], "raw_data": {}} # print uses A1 + A2
  95. # Both used trays bound via Spoolman; legacy table empty.
  96. session = _FakeSession(
  97. "Printer A",
  98. legacy=[],
  99. spoolman=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
  100. )
  101. p_session, p_status, p_ws, p_notify = _patches(session)
  102. with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
  103. await notify_missing_spool_assignments_on_print_start(1, data, logger)
  104. mock_ws.assert_not_awaited()
  105. mock_notify.assert_not_awaited()
  106. @pytest.mark.asyncio
  107. async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
  108. """A Spoolman assignment for A1 only, with a print using A1 + A2, flags
  109. A2 alone."""
  110. logger = logging.getLogger(__name__)
  111. data = {"ams_mapping": [0, 1], "raw_data": {}}
  112. session = _FakeSession(
  113. "Printer A",
  114. legacy=[],
  115. spoolman=[SimpleNamespace(ams_id=0, tray_id=0)], # A1 only
  116. )
  117. p_session, p_status, p_ws, p_notify = _patches(session)
  118. with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
  119. await notify_missing_spool_assignments_on_print_start(1, data, logger)
  120. mock_ws.assert_awaited_once()
  121. assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
  122. mock_notify.assert_awaited_once()
  123. @pytest.mark.asyncio
  124. async def test_mixed_mode_union_covers_all_used_trays():
  125. """A1 bound in the legacy table, A2 bound in spoolman_slot_assignments —
  126. the union covers both used trays, so no notification fires."""
  127. logger = logging.getLogger(__name__)
  128. data = {"ams_mapping": [0, 1], "raw_data": {}}
  129. session = _FakeSession(
  130. "Printer A",
  131. legacy=[SimpleNamespace(ams_id=0, tray_id=0)], # A1
  132. spoolman=[SimpleNamespace(ams_id=0, tray_id=1)], # A2
  133. )
  134. p_session, p_status, p_ws, p_notify = _patches(session)
  135. with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
  136. await notify_missing_spool_assignments_on_print_start(1, data, logger)
  137. mock_ws.assert_not_awaited()
  138. mock_notify.assert_not_awaited()