test_phantom_print_hardening.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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. import pytest
  10. from sqlalchemy import or_, select
  11. from sqlalchemy.sql import ClauseElement
  12. from backend.app.models.archive import PrintArchive
  13. class TestArchiveMatchQueryShape:
  14. """Tests that the archive duplicate lookup query uses exact match, not ilike (#374).
  15. The old query used `ilike('%{name}%')` which caused "Clip" to match
  16. "Cable Clip", "Clip Stand", etc. The new query uses exact print_name
  17. match OR exact filename variants (.3mf, .gcode.3mf).
  18. """
  19. def _build_archive_query(self, check_name: str, printer_id: int = 1) -> ClauseElement:
  20. """Build the exact query used in on_print_start for archive dedup."""
  21. return (
  22. select(PrintArchive)
  23. .where(PrintArchive.printer_id == printer_id)
  24. .where(PrintArchive.status == "printing")
  25. .where(
  26. or_(
  27. PrintArchive.print_name == check_name,
  28. PrintArchive.filename.in_(
  29. [
  30. f"{check_name}.3mf",
  31. f"{check_name}.gcode.3mf",
  32. ]
  33. ),
  34. )
  35. )
  36. .order_by(PrintArchive.created_at.desc())
  37. .limit(1)
  38. )
  39. def test_query_does_not_contain_ilike(self):
  40. """Verify the compiled query does NOT use LIKE/ILIKE."""
  41. query = self._build_archive_query("Clip")
  42. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  43. assert "LIKE" not in query_str.upper(), f"Query should not use LIKE: {query_str}"
  44. def test_query_uses_exact_equality(self):
  45. """Verify the query uses = for print_name comparison."""
  46. query = self._build_archive_query("Benchy")
  47. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  48. assert "print_name = " in query_str or "print_name ='" in query_str or "print_name =" in query_str
  49. def test_query_uses_in_for_filename_variants(self):
  50. """Verify the query uses IN for filename matching with .3mf variants."""
  51. query = self._build_archive_query("MyPrint")
  52. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  53. assert "IN" in query_str.upper()
  54. assert "MyPrint.3mf" in query_str
  55. assert "MyPrint.gcode.3mf" in query_str
  56. def test_partial_name_not_in_query(self):
  57. """Verify 'Clip' does not produce a wildcard pattern."""
  58. query = self._build_archive_query("Clip")
  59. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  60. # Should NOT contain %Clip% wildcard
  61. assert "%Clip%" not in query_str
  62. def test_check_name_derivation_from_subtask(self):
  63. """Verify check_name is derived correctly from subtask_name."""
  64. # Simulates: check_name = subtask_name or filename.split("/")[-1].replace(...)
  65. subtask_name = "Cable Clip"
  66. filename = "/sdcard/Cable Clip.gcode"
  67. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  68. assert check_name == "Cable Clip"
  69. query = self._build_archive_query(check_name)
  70. query_str = str(query.compile(compile_kwargs={"literal_binds": True}))
  71. # Exact match should contain the full name, not a partial
  72. assert "Cable Clip" in query_str
  73. assert "%Cable Clip%" not in query_str
  74. def test_check_name_derivation_from_filename(self):
  75. """Verify check_name strips extensions correctly from filename."""
  76. subtask_name = None
  77. filename = "/sdcard/MyPrint.gcode"
  78. check_name = subtask_name or filename.split("/")[-1].replace(".gcode", "").replace(".3mf", "")
  79. assert check_name == "MyPrint"
  80. class TestMultiplePrintingQueueItemsWarning:
  81. """Tests for the multiple-printing-items warning logic (#374).
  82. The code in on_print_complete now detects when multiple queue items
  83. are in 'printing' status for the same printer, which signals a bug.
  84. """
  85. def test_single_item_returns_item_no_warning(self, caplog):
  86. """Verify single item is returned without warning."""
  87. from unittest.mock import MagicMock
  88. items = [MagicMock(id=1, archive_id=10, library_file_id=None)]
  89. # Simulate the exact code from on_print_complete
  90. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  91. logger = logging.getLogger("backend.app.main")
  92. printer_id = 1
  93. printing_items = list(items)
  94. if len(printing_items) > 1:
  95. logger.warning(
  96. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  97. printer_id,
  98. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  99. )
  100. queue_item = printing_items[0] if printing_items else None
  101. assert queue_item is not None
  102. assert queue_item.id == 1
  103. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  104. assert len(bug_warnings) == 0
  105. def test_multiple_items_warns_and_returns_first(self, caplog):
  106. """Verify warning is logged and first item is returned when multiple exist."""
  107. from unittest.mock import MagicMock
  108. items = [
  109. MagicMock(id=1, archive_id=10, library_file_id=None),
  110. MagicMock(id=2, archive_id=20, library_file_id=None),
  111. ]
  112. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  113. logger = logging.getLogger("backend.app.main")
  114. printer_id = 1
  115. printing_items = list(items)
  116. if len(printing_items) > 1:
  117. logger.warning(
  118. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  119. printer_id,
  120. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  121. )
  122. queue_item = printing_items[0] if printing_items else None
  123. assert queue_item is not None
  124. assert queue_item.id == 1 # First item is used
  125. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  126. assert len(bug_warnings) == 1
  127. assert "printer 1" in bug_warnings[0].message
  128. # Warning should include item details
  129. assert "10" in bug_warnings[0].message # archive_id of item 1
  130. assert "20" in bug_warnings[0].message # archive_id of item 2
  131. def test_empty_list_returns_none_no_warning(self, caplog):
  132. """Verify None is returned and no warning when no items exist."""
  133. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  134. logger = logging.getLogger("backend.app.main")
  135. printer_id = 1
  136. printing_items = []
  137. if len(printing_items) > 1:
  138. logger.warning(
  139. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  140. printer_id,
  141. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  142. )
  143. queue_item = printing_items[0] if printing_items else None
  144. assert queue_item is None
  145. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  146. assert len(bug_warnings) == 0
  147. def test_three_items_warns_with_all_details(self, caplog):
  148. """Verify warning includes all item details when three items found."""
  149. from unittest.mock import MagicMock
  150. items = [
  151. MagicMock(id=1, archive_id=10, library_file_id=None),
  152. MagicMock(id=2, archive_id=None, library_file_id=5),
  153. MagicMock(id=3, archive_id=30, library_file_id=None),
  154. ]
  155. with caplog.at_level(logging.WARNING, logger="backend.app.main"):
  156. logger = logging.getLogger("backend.app.main")
  157. printer_id = 7
  158. printing_items = list(items)
  159. if len(printing_items) > 1:
  160. logger.warning(
  161. "BUG: Multiple queue items in 'printing' status for printer %s: %s",
  162. printer_id,
  163. [(i.id, i.archive_id, i.library_file_id) for i in printing_items],
  164. )
  165. queue_item = printing_items[0] if printing_items else None
  166. assert queue_item.id == 1
  167. bug_warnings = [r for r in caplog.records if "BUG: Multiple queue items" in r.message]
  168. assert len(bug_warnings) == 1
  169. assert "printer 7" in bug_warnings[0].message