test_expected_print_rollback.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """A dispatch that never sends the print command must leave no expectation.
  2. ``register_expected_print`` has to run *before* the MQTT command, because the
  3. printer can report the print before the send returns. So any path that
  4. registers and then fails to send leaves Bambuddy expecting a print that will
  5. never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
  6. False, or an exception in between — a PostgreSQL connection failure mid-dispatch
  7. is the case that surfaced this (#2702 follow-up).
  8. The two-hour TTL sweep does eventually evict such an entry, but two hours is far
  9. longer than it takes someone to react to a failed dispatch by pressing print
  10. again. That reprint would be folded into the *old* archive and inherit its
  11. ``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
  12. """
  13. from __future__ import annotations
  14. import pytest
  15. @pytest.fixture
  16. def expected_print_tables():
  17. """The module-level registries, emptied around each test."""
  18. from backend.app import main
  19. names = (
  20. "_expected_prints",
  21. "_expected_print_creators",
  22. "_expected_print_registered_at",
  23. "_print_ams_mappings",
  24. "_print_plate_ids",
  25. "_print_cost_center_ids",
  26. )
  27. saved = {n: dict(getattr(main, n)) for n in names}
  28. for n in names:
  29. getattr(main, n).clear()
  30. yield main
  31. for n in names:
  32. getattr(main, n).clear()
  33. getattr(main, n).update(saved[n])
  34. # ---------------------------------------------------------------------------
  35. # unregister_expected_print is the exact inverse of register_expected_print
  36. # ---------------------------------------------------------------------------
  37. def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
  38. """The strongest form: register then unregister is a round trip to empty."""
  39. main = expected_print_tables
  40. main.register_expected_print(
  41. 1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1, cost_center_id=5
  42. )
  43. assert main._expected_prints, "nothing registered — the test proves nothing"
  44. main.unregister_expected_print(1, "widget.3mf", 298)
  45. assert main._expected_prints == {}
  46. assert main._expected_print_creators == {}
  47. assert main._expected_print_registered_at == {}
  48. assert main._print_ams_mappings == {}
  49. assert main._print_plate_ids == {}
  50. assert main._print_cost_center_ids == {}
  51. def test_unregister_clears_the_filename_variants_too(expected_print_tables):
  52. """Registration stores the name three ways; a partial undo still matches."""
  53. main = expected_print_tables
  54. main.register_expected_print(1, "widget.3mf", 298)
  55. main.unregister_expected_print(1, "widget.3mf", 298)
  56. for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
  57. assert key not in main._expected_prints, f"{key} survived"
  58. def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
  59. main = expected_print_tables
  60. main.register_expected_print(1, "widget.3mf", 298)
  61. main.register_expected_print(2, "widget.3mf", 299)
  62. main.unregister_expected_print(1, "widget.3mf", 298)
  63. assert main._expected_prints[(2, "widget.3mf")] == 299
  64. def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
  65. expected_print_tables,
  66. ):
  67. """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
  68. ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
  69. by file. Two files can be registered against one archive, so dropping them
  70. on the first unregister would strip usage-tracking data from a print that is
  71. still expected.
  72. """
  73. main = expected_print_tables
  74. main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
  75. main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
  76. main.unregister_expected_print(1, "plate1.3mf", 298)
  77. assert main._print_ams_mappings.get(298) == [3]
  78. assert 298 in main._print_plate_ids
  79. def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
  80. """Runs from a ``finally``, so it must tolerate having nothing to do."""
  81. main = expected_print_tables
  82. main.unregister_expected_print(99, "never-registered.3mf", 1234)
  83. assert main._expected_prints == {}
  84. # ---------------------------------------------------------------------------
  85. # The scheduler's rollback hook
  86. # ---------------------------------------------------------------------------
  87. def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
  88. main = expected_print_tables
  89. from backend.app.services.print_scheduler import PrintScheduler
  90. sched = PrintScheduler()
  91. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  92. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  93. sched._rollback_unconfirmed_expected_print(597)
  94. assert main._expected_prints == {}
  95. assert sched._unconfirmed_expected_print == {}
  96. def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
  97. """A sent print's expectation must survive — the callback needs it."""
  98. main = expected_print_tables
  99. from backend.app.services.print_scheduler import PrintScheduler
  100. sched = PrintScheduler()
  101. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  102. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  103. # What `_start_print` does once start_print() returns True.
  104. sched._unconfirmed_expected_print.pop(597, None)
  105. sched._rollback_unconfirmed_expected_print(597)
  106. assert main._expected_prints[(1, "widget.3mf")] == 298
  107. assert main._print_ams_mappings[298] == [3, 6]
  108. def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
  109. """It runs in the ``finally`` of dispatch, usually with an exception already
  110. propagating — it must not replace it with one of its own."""
  111. from backend.app.services.print_scheduler import PrintScheduler
  112. sched = PrintScheduler()
  113. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  114. monkeypatch.setattr(
  115. expected_print_tables,
  116. "unregister_expected_print",
  117. lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
  118. )
  119. sched._rollback_unconfirmed_expected_print(597) # must not raise
  120. assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
  121. @pytest.mark.asyncio
  122. @pytest.mark.unit
  123. async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
  124. """End to end through `_dispatch_one`, on the reported failure.
  125. A database error inside `_start_print` must leave no expectation behind, must
  126. still release the claim, and must not be swallowed — the background-task
  127. runner logs it, and hiding it here would turn a loud failure into a silent
  128. one.
  129. """
  130. from unittest.mock import AsyncMock, MagicMock, patch
  131. main = expected_print_tables
  132. from backend.app.services.print_scheduler import PrintScheduler
  133. sched = PrintScheduler()
  134. async def fake_start_print(db, item):
  135. # What `_start_print` does before the point the real one died.
  136. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
  137. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  138. raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
  139. db = MagicMock()
  140. db.get = AsyncMock(return_value=MagicMock(id=597))
  141. ctx = MagicMock()
  142. ctx.__aenter__ = AsyncMock(return_value=db)
  143. ctx.__aexit__ = AsyncMock(return_value=False)
  144. with (
  145. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  146. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  147. patch.object(sched, "_start_print", side_effect=fake_start_print),
  148. patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
  149. pytest.raises(RuntimeError),
  150. ):
  151. await sched._dispatch_one(597)
  152. assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
  153. assert main._print_ams_mappings == {}
  154. assert main._print_plate_ids == {}
  155. assert sched._unconfirmed_expected_print == {}
  156. clear.assert_awaited_once_with(db, 597)
  157. @pytest.mark.asyncio
  158. @pytest.mark.unit
  159. async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
  160. """The mirror image: a confirmed send must survive dispatch teardown, or the
  161. print-complete callback would create a duplicate archive."""
  162. from unittest.mock import AsyncMock, MagicMock, patch
  163. main = expected_print_tables
  164. from backend.app.services.print_scheduler import PrintScheduler
  165. sched = PrintScheduler()
  166. async def fake_start_print(db, item):
  167. main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
  168. sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
  169. sched._unconfirmed_expected_print.pop(597, None) # start_print() returned True
  170. db = MagicMock()
  171. db.get = AsyncMock(return_value=MagicMock(id=597))
  172. ctx = MagicMock()
  173. ctx.__aenter__ = AsyncMock(return_value=db)
  174. ctx.__aexit__ = AsyncMock(return_value=False)
  175. with (
  176. patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
  177. patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
  178. patch.object(sched, "_start_print", side_effect=fake_start_print),
  179. patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
  180. ):
  181. await sched._dispatch_one(597)
  182. assert main._expected_prints[(1, "widget.3mf")] == 298
  183. assert main._print_ams_mappings[298] == [3, 6]
  184. def test_rollback_entries_are_per_item(expected_print_tables):
  185. """Two dispatches in flight must not roll back each other's registration."""
  186. main = expected_print_tables
  187. from backend.app.services.print_scheduler import PrintScheduler
  188. sched = PrintScheduler()
  189. main.register_expected_print(1, "a.3mf", 1)
  190. main.register_expected_print(2, "b.3mf", 2)
  191. sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
  192. sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
  193. sched._rollback_unconfirmed_expected_print(10)
  194. assert (1, "a.3mf") not in main._expected_prints
  195. assert main._expected_prints[(2, "b.3mf")] == 2