test_completion_queue_item_match.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """A print completion must not close a queue row belonging to another print.
  2. ``on_print_complete`` finds its queue row by printer and ``status='printing'``
  3. alone -- the MQTT payload carries no run identifier to match on -- so any
  4. completion delivered for a printer closes whichever row happens to be printing.
  5. That is fine while the only source of completions is the printer itself, and
  6. wrong the moment one arrives from anywhere else: a live 14-hour print was closed
  7. 18 minutes in, and its plate 2 never dispatched, because a completion for an
  8. unrelated subtask reached the same lookup.
  9. These cover the guard that rules that out, and the deliberate decision to let
  10. the unverifiable cases through rather than strand an item in ``printing``.
  11. """
  12. import pytest
  13. from backend.app.main import _completion_belongs_to_queue_item, _subtask_name_from_filename
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.print_queue import PrintQueueItem
  16. class TestSubtaskNameFromFilename:
  17. """The dispatcher builds the subtask name off the archive file name, so
  18. stripping the extensions back off has to land on exactly what MQTT echoes."""
  19. @pytest.mark.parametrize(
  20. ("filename", "expected"),
  21. [
  22. ("AMS_Rack.gcode.3mf", "AMS_Rack"),
  23. ("AMS_Rack.3mf", "AMS_Rack"),
  24. ("plate.gcode", "plate"),
  25. # A dot in the model's own name is not an extension. Path.stem would
  26. # eat it and produce "My", which matches nothing.
  27. ("My.Model.3mf", "My.Model"),
  28. ("My.Model.gcode.3mf", "My.Model"),
  29. # Extensions are matched case-insensitively; the name is not.
  30. ("Cover.GCODE.3MF", "Cover"),
  31. # Only the file name matters -- archives store a path.
  32. ("archive/1/20260811_112435_AMS_Rack/AMS_Rack.gcode.3mf", "AMS_Rack"),
  33. # Nothing to strip.
  34. ("AMS_Rack", "AMS_Rack"),
  35. ],
  36. )
  37. def test_recovers_the_dispatched_subtask_name(self, filename, expected):
  38. assert _subtask_name_from_filename(filename) == expected
  39. async def _seed(db, *, archive_filename: str | None) -> PrintQueueItem:
  40. """A printing queue item, optionally linked to an archive."""
  41. archive_id = None
  42. if archive_filename is not None:
  43. archive = PrintArchive(
  44. printer_id=1,
  45. filename=archive_filename,
  46. file_path=f"archive/1/{archive_filename}",
  47. file_size=1,
  48. status="printing",
  49. )
  50. db.add(archive)
  51. await db.flush()
  52. archive_id = archive.id
  53. item = PrintQueueItem(printer_id=1, status="printing", archive_id=archive_id)
  54. db.add(item)
  55. await db.flush()
  56. return item
  57. @pytest.mark.asyncio
  58. class TestCompletionBelongsToQueueItem:
  59. async def test_accepts_the_completion_for_its_own_print(self, db_session):
  60. item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
  61. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "AMS_Rack"}) is True
  62. async def test_rejects_a_completion_for_a_different_print(self, db_session):
  63. # The exact shape of the incident: the row was dispatched as AMS_Rack and
  64. # a completion for "Test" arrived on the same printer.
  65. item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
  66. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Test"}) is False
  67. async def test_matches_regardless_of_case(self, db_session):
  68. item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
  69. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "ams_rack"}) is True
  70. @pytest.mark.parametrize("subtask", [None, "", " "])
  71. async def test_lets_an_unidentified_completion_through(self, db_session, subtask):
  72. # No subtask name to compare means unverifiable, not wrong. Refusing here
  73. # would leave the item printing forever and wedge the printer's queue,
  74. # which is the failure the indiscriminate lookup existed to avoid.
  75. item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
  76. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": subtask}) is True
  77. async def test_lets_an_archiveless_item_through(self, db_session):
  78. # Library-file dispatch links the archive after the fact; there is
  79. # nothing to compare against yet.
  80. item = await _seed(db_session, archive_filename=None)
  81. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
  82. async def test_lets_an_archive_without_a_filename_through(self, db_session):
  83. # `filename` is NOT NULL, but nothing stops it being empty.
  84. item = await _seed(db_session, archive_filename="")
  85. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
  86. async def test_lets_a_dangling_archive_reference_through(self, db_session):
  87. item = await _seed(db_session, archive_filename=None)
  88. item.archive_id = 999999
  89. assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
  90. class TestDisposableDatabaseGuard:
  91. """The suite must never be able to open a session against a real database.
  92. ``run_with_retry`` takes its session from ``backend.app.core.database``, not
  93. from the ``backend.app.main.async_session`` that most tests patch, so an
  94. unmocked completion path reaches the app's module-level engine. That engine
  95. is built from ``DATABASE_URL``; conftest redirects it to a throwaway SQLite
  96. file and asserts the redirect took, because the alternative is a suite that
  97. passes while having edited someone's live print history.
  98. """
  99. def test_the_app_engine_points_at_a_throwaway_sqlite_file(self):
  100. from backend.app.core.database import engine
  101. from backend.tests.conftest import _TEST_APP_DB_DIR
  102. assert engine.url.drivername.startswith("sqlite")
  103. assert str(engine.url.database).startswith(str(_TEST_APP_DB_DIR))
  104. def test_the_guard_rejects_a_real_database(self):
  105. from sqlalchemy.engine import make_url
  106. from backend.tests.conftest import _assert_disposable_database
  107. with pytest.raises(RuntimeError, match="Refusing to run tests"):
  108. _assert_disposable_database(
  109. make_url("postgresql+asyncpg://user:pw@192.168.0.2:5432/bambuddy"),
  110. "test",
  111. )
  112. def test_the_guard_rejects_another_sqlite_file(self):
  113. # A developer's own data/bambuddy.db is just as real as a server.
  114. from sqlalchemy.engine import make_url
  115. from backend.tests.conftest import _assert_disposable_database
  116. with pytest.raises(RuntimeError, match="Refusing to run tests"):
  117. _assert_disposable_database(make_url("sqlite+aiosqlite:///data/bambuddy.db"), "test")