test_phantom_print_hardening.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. """Tests for phantom print investigation hardening (#374).
  2. Tests the tightened archive matching (no ilike) and the
  3. multiple-printing-items warning logic.
  4. These are pure unit tests that test the changed logic directly,
  5. NOT by calling the full on_print_start/on_print_complete callbacks
  6. (which spawn background tasks and require heavy mocking).
  7. """
  8. import logging
  9. from unittest.mock import patch
  10. import pytest
  11. from sqlalchemy import or_, select
  12. from sqlalchemy.sql import ClauseElement
  13. from backend.app.models.archive import PrintArchive
  14. class TestArchiveMatchQueryShape:
  15. """Tests that the archive duplicate lookup query uses exact match, not ilike (#374).
  16. The old query used `ilike('%{name}%')` which caused "Clip" to match
  17. "Cable Clip", "Clip Stand", etc. The new query uses exact print_name
  18. match OR exact filename variants (.3mf, .gcode.3mf).
  19. """
  20. def _build_archive_query(self, check_name: str, printer_id: int = 1) -> ClauseElement:
  21. """Build the exact query used in on_print_start for archive dedup."""
  22. return (
  23. select(PrintArchive)
  24. .where(PrintArchive.printer_id == printer_id)
  25. .where(PrintArchive.status == "printing")
  26. .where(
  27. or_(
  28. PrintArchive.print_name == check_name,
  29. PrintArchive.filename.in_(
  30. [
  31. f"{check_name}.3mf",
  32. f"{check_name}.gcode.3mf",
  33. ]
  34. ),
  35. )
  36. )
  37. .order_by(PrintArchive.created_at.desc())
  38. .limit(1)
  39. )
  40. def test_query_does_not_contain_ilike(self):
  41. """Verify the compiled query does NOT use LIKE/ILIKE."""
  42. query = self._build_archive_query("Clip")
  43. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  44. assert "LIKE" not in query_str.upper(), f"Query should not use LIKE: {query_str}"
  45. def test_query_uses_exact_equality(self):
  46. """Verify the query uses = for print_name comparison."""
  47. query = self._build_archive_query("Benchy")
  48. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  49. assert "print_name = " in query_str or "print_name ='" in query_str or "print_name =" in query_str
  50. def test_query_uses_in_for_filename_variants(self):
  51. """Verify the query uses IN for filename matching with .3mf variants."""
  52. query = self._build_archive_query("MyPrint")
  53. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  54. assert "IN" in query_str.upper()
  55. assert "MyPrint.3mf" in query_str
  56. assert "MyPrint.gcode.3mf" in query_str
  57. def test_partial_name_not_in_query(self):
  58. """Verify 'Clip' does not produce a wildcard pattern."""
  59. query = self._build_archive_query("Clip")
  60. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  61. # Should NOT contain %Clip% wildcard
  62. assert "%Clip%" not in query_str
  63. def test_check_name_derivation_from_subtask(self):
  64. """Verify check_name is derived correctly from subtask_name."""
  65. # Simulates: check_name = subtask_name or filename.split("/")[-1].replace(...)
  66. subtask_name = "Cable Clip"
  67. filename = "/sdcard/Cable Clip.gcode"
  68. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  69. assert check_name == "Cable Clip"
  70. query = self._build_archive_query(check_name)
  71. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  72. # Exact match should contain the full name, not a partial
  73. assert "Cable Clip" in query_str
  74. assert "%Cable Clip%" not in query_str
  75. def test_check_name_derivation_from_filename(self):
  76. """Verify check_name strips extensions correctly from filename."""
  77. subtask_name = None
  78. filename = "/sdcard/MyPrint.gcode"
  79. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  80. assert check_name == "MyPrint"
  81. class TestMultiplePrintingQueueItemsWarning:
  82. """Tests for the multiple-printing-items warning logic (#374).
  83. The code in on_print_complete now detects when multiple queue items
  84. are in 'printing' status for the same printer, which signals a bug.
  85. """
  86. def test_single_item_returns_item_no_warning(self, caplog):
  87. """Verify single item is returned without warning."""
  88. from unittest.mock import MagicMock
  89. items = [MagicMock(id=1, archive_id=10, library_file_id=None)]
  90. # Simulate the exact code from on_print_complete
  91. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  92. logger = logging.getLogger("backend.app.main")
  93. printer_id = 1
  94. printing_items = list(items)
  95. if len(printing_items) > 1:
  96. logger.warning(
  97. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  98. printer_id,
  99. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  100. )
  101. queue_item = printing_items[0] if printing_items else None
  102. assert queue_item is not None
  103. assert queue_item.id == 1
  104. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  105. assert len(bug_warnings) == 0
  106. def test_multiple_items_warns_and_returns_first(self, caplog):
  107. """Verify warning is logged and first item is returned when multiple exist."""
  108. from unittest.mock import MagicMock
  109. items = [
  110. MagicMock(id=1, archive_id=10, library_file_id=None),
  111. MagicMock(id=2, archive_id=20, library_file_id=None),
  112. ]
  113. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  114. logger = logging.getLogger("backend.app.main")
  115. printer_id = 1
  116. printing_items = list(items)
  117. if len(printing_items) > 1:
  118. logger.warning(
  119. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  120. printer_id,
  121. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  122. )
  123. queue_item = printing_items[0] if printing_items else None
  124. assert queue_item is not None
  125. assert queue_item.id == 1 # First item is used
  126. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  127. assert len(bug_warnings) == 1
  128. assert "printer 1" in bug_warnings[0].message
  129. # Warning should include item details
  130. assert "10" in bug_warnings[0].message # archive_id of item 1
  131. assert "20" in bug_warnings[0].message # archive_id of item 2
  132. def test_empty_list_returns_none_no_warning(self, caplog):
  133. """Verify None is returned and no warning when no items exist."""
  134. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  135. logger = logging.getLogger("backend.app.main")
  136. printer_id = 1
  137. printing_items = []
  138. if len(printing_items) > 1:
  139. logger.warning(
  140. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  141. printer_id,
  142. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  143. )
  144. queue_item = printing_items[0] if printing_items else None
  145. assert queue_item is None
  146. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  147. assert len(bug_warnings) == 0
  148. def test_three_items_warns_with_all_details(self, caplog):
  149. """Verify warning includes all item details when three items found."""
  150. from unittest.mock import MagicMock
  151. items = [
  152. MagicMock(id=1, archive_id=10, library_file_id=None),
  153. MagicMock(id=2, archive_id=None, library_file_id=5),
  154. MagicMock(id=3, archive_id=30, library_file_id=None),
  155. ]
  156. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  157. logger = logging.getLogger("backend.app.main")
  158. printer_id = 7
  159. printing_items = list(items)
  160. if len(printing_items) > 1:
  161. logger.warning(
  162. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  163. printer_id,
  164. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  165. )
  166. queue_item = printing_items[0] if printing_items else None
  167. assert queue_item.id == 1
  168. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  169. assert len(bug_warnings) == 1
  170. assert "printer 7" in bug_warnings[0].message
  171. class TestBusyPrinterSeedingFromPrintingItems:
  172. """Regression for the duplicate-dispatch bug observed with quantity>1 batches.
  173. The old scheduler seeded ``busy_printers`` with an empty set and relied on
  174. ``_is_printer_idle()`` to gate dispatch. On H2D / P1 series the MQTT state
  175. lags several seconds behind the print command, so the next ``check_queue``
  176. tick saw IDLE and dispatched a second queue item onto the same printer —
  177. both items ended up in 'printing' status. The fix seeds ``busy_printers``
  178. up-front with every printer that already has an item in 'printing' status.
  179. """
  180. @pytest.mark.asyncio
  181. async def test_seed_query_returns_printers_with_printing_items(self):
  182. """The seeding query must return every printer_id that has a 'printing' item."""
  183. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  184. import backend.app.models # noqa: F401
  185. from backend.app.core.database import Base
  186. from backend.app.models.print_queue import PrintQueueItem
  187. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  188. async with engine.begin() as conn:
  189. await conn.run_sync(Base.metadata.create_all)
  190. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  191. async with session_maker() as db:
  192. db.add_all(
  193. [
  194. PrintQueueItem(printer_id=1, status="printing", position=1, archive_id=10),
  195. PrintQueueItem(printer_id=1, status="pending", position=2, archive_id=10),
  196. PrintQueueItem(printer_id=2, status="printing", position=1, archive_id=11),
  197. PrintQueueItem(printer_id=3, status="pending", position=1, archive_id=12),
  198. PrintQueueItem(printer_id=None, status="pending", position=1, archive_id=13),
  199. ]
  200. )
  201. await db.commit()
  202. result = await db.execute(
  203. select(PrintQueueItem.printer_id)
  204. .where(PrintQueueItem.status == "printing")
  205. .where(PrintQueueItem.printer_id.is_not(None))
  206. )
  207. busy_printers = {pid for (pid,) in result.all() if pid is not None}
  208. assert busy_printers == {1, 2}
  209. await engine.dispose()
  210. @pytest.mark.asyncio
  211. async def test_seed_query_empty_when_no_printing_items(self):
  212. """With only pending items, no printer is considered busy by the query."""
  213. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  214. import backend.app.models # noqa: F401
  215. from backend.app.core.database import Base
  216. from backend.app.models.print_queue import PrintQueueItem
  217. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  218. async with engine.begin() as conn:
  219. await conn.run_sync(Base.metadata.create_all)
  220. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  221. async with session_maker() as db:
  222. db.add_all(
  223. [
  224. PrintQueueItem(printer_id=1, status="pending", position=1, archive_id=10),
  225. PrintQueueItem(printer_id=2, status="completed", position=1, archive_id=11),
  226. PrintQueueItem(printer_id=3, status="failed", position=1, archive_id=12),
  227. PrintQueueItem(printer_id=4, status="cancelled", position=1, archive_id=13),
  228. ]
  229. )
  230. await db.commit()
  231. result = await db.execute(
  232. select(PrintQueueItem.printer_id)
  233. .where(PrintQueueItem.status == "printing")
  234. .where(PrintQueueItem.printer_id.is_not(None))
  235. )
  236. busy_printers = {pid for (pid,) in result.all() if pid is not None}
  237. assert busy_printers == set()
  238. await engine.dispose()
  239. @pytest.mark.asyncio
  240. async def test_check_queue_skips_printer_with_existing_printing_item(self, caplog):
  241. """Simulate the exact observed bug: a pending item targets a printer that already
  242. has another queue item in 'printing' status. The scheduler must NOT dispatch the
  243. pending item even if the live MQTT state reports IDLE.
  244. """
  245. from unittest.mock import AsyncMock, patch
  246. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  247. import backend.app.models # noqa: F401
  248. from backend.app.core.database import Base
  249. from backend.app.models.print_queue import PrintQueueItem
  250. from backend.app.services.print_scheduler import PrintScheduler
  251. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  252. async with engine.begin() as conn:
  253. await conn.run_sync(Base.metadata.create_all)
  254. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  255. async with session_maker() as db:
  256. db.add_all(
  257. [
  258. PrintQueueItem(printer_id=1, status="printing", position=1, archive_id=84),
  259. PrintQueueItem(printer_id=1, status="pending", position=2, archive_id=84),
  260. ]
  261. )
  262. await db.commit()
  263. scheduler = PrintScheduler()
  264. start_print_mock = AsyncMock()
  265. with (
  266. patch("backend.app.services.print_scheduler.async_session", session_maker),
  267. patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
  268. patch.object(scheduler, "_is_printer_idle", return_value=True),
  269. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  270. patch.object(scheduler, "_start_print", start_print_mock),
  271. patch("backend.app.services.print_scheduler.printer_manager") as mock_pm,
  272. ):
  273. mock_pm.is_connected.return_value = True
  274. await scheduler.check_queue()
  275. start_print_mock.assert_not_called()
  276. async with session_maker() as db:
  277. rows = (await db.execute(select(PrintQueueItem).order_by(PrintQueueItem.position))).scalars().all()
  278. statuses = [r.status for r in rows]
  279. assert statuses == ["printing", "pending"]
  280. await engine.dispose()
  281. @pytest.mark.asyncio
  282. async def test_scheduler_budget_check_uses_queue_creator_membership(self):
  283. """Scheduler must not bypass cost-center membership when auto-starting a queued job."""
  284. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  285. import backend.app.models # noqa: F401
  286. from backend.app.core.database import Base
  287. from backend.app.models.archive import PrintArchive
  288. from backend.app.models.finance import CostCenter
  289. from backend.app.models.print_queue import PrintQueueItem
  290. from backend.app.models.printer import Printer
  291. from backend.app.models.settings import Settings
  292. from backend.app.models.user import User
  293. from backend.app.services.print_scheduler import PrintScheduler
  294. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  295. async with engine.begin() as conn:
  296. await conn.run_sync(Base.metadata.create_all)
  297. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  298. async with session_maker() as db:
  299. user = User(username="queue-user", email="queue-user@example.com", password_hash="x", role="user")
  300. cost_center = CostCenter(name="Lab", is_active=True, is_private=False, monthly_budget=10.0)
  301. printer = Printer(id=1, name="P1", serial_number="S1", ip_address="1.1.1.1", access_code="x")
  302. billing_enabled = Settings(key="billing_enabled", value="true")
  303. archive = PrintArchive(
  304. id=1,
  305. printer_id=1,
  306. filename="test.3mf",
  307. print_name="test",
  308. file_path="missing.3mf",
  309. file_size=1,
  310. content_hash="hash",
  311. status="completed",
  312. )
  313. db.add_all([user, cost_center, printer, archive, billing_enabled])
  314. await db.flush()
  315. item = PrintQueueItem(
  316. printer_id=1,
  317. archive_id=archive.id,
  318. status="pending",
  319. position=1,
  320. created_by_id=user.id,
  321. cost_center_id=cost_center.id,
  322. estimated_cost=1.0,
  323. )
  324. db.add(item)
  325. await db.commit()
  326. item_id = item.id
  327. scheduler = PrintScheduler()
  328. async with session_maker() as db:
  329. item = await db.get(PrintQueueItem, item_id)
  330. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  331. await scheduler._start_print(db, item)
  332. mock_pm.is_connected.assert_not_called()
  333. await db.refresh(item)
  334. assert item.status == "failed"
  335. assert "cannot print with this cost center" in item.error_message
  336. await engine.dispose()
  337. @pytest.mark.asyncio
  338. async def test_scheduler_budget_check_does_not_depend_on_billing_setting(self):
  339. """Scheduler must still reject unauthorized cost centers when billing is disabled."""
  340. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  341. import backend.app.models # noqa: F401
  342. from backend.app.core.database import Base
  343. from backend.app.models.archive import PrintArchive
  344. from backend.app.models.finance import CostCenter
  345. from backend.app.models.print_queue import PrintQueueItem
  346. from backend.app.models.printer import Printer
  347. from backend.app.models.user import User
  348. from backend.app.services.print_scheduler import PrintScheduler
  349. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  350. async with engine.begin() as conn:
  351. await conn.run_sync(Base.metadata.create_all)
  352. session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  353. async with session_maker() as db:
  354. user = User(username="queue-user", email="queue-user@example.com", password_hash="x", role="user")
  355. cost_center = CostCenter(name="Lab", is_active=True, is_private=False, monthly_budget=10.0)
  356. printer = Printer(id=1, name="P1", serial_number="S1", ip_address="1.1.1.1", access_code="x")
  357. archive = PrintArchive(
  358. id=1,
  359. printer_id=1,
  360. filename="test.3mf",
  361. print_name="test",
  362. file_path="missing.3mf",
  363. file_size=1,
  364. content_hash="hash",
  365. status="completed",
  366. )
  367. db.add_all([user, cost_center, printer, archive])
  368. await db.flush()
  369. item = PrintQueueItem(
  370. printer_id=1,
  371. archive_id=archive.id,
  372. status="pending",
  373. position=1,
  374. created_by_id=user.id,
  375. cost_center_id=cost_center.id,
  376. estimated_cost=1.0,
  377. )
  378. db.add(item)
  379. await db.commit()
  380. item_id = item.id
  381. scheduler = PrintScheduler()
  382. async with session_maker() as db:
  383. item = await db.get(PrintQueueItem, item_id)
  384. with patch("backend.app.services.print_scheduler.printer_manager") as mock_pm:
  385. await scheduler._start_print(db, item)
  386. mock_pm.is_connected.assert_not_called()
  387. await db.refresh(item)
  388. assert item.status == "failed"
  389. assert "cannot print with this cost center" in item.error_message
  390. await engine.dispose()