test_reprint_updates_subtask_id.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """Regression for #1807: false-positive "Print Stopped" notification on the
  2. expected-archive reprint path.
  3. Bambuddy mints a fresh subtask_id per dispatch (``bambu_mqtt.py:3647``). On a
  4. reprint, the archive row is reused — so the stored ``archive.subtask_id`` is
  5. still the value from the FIRST run. The earlier ``not archive.subtask_id``
  6. guard at ``on_print_start`` skipped the rewrite, so the row kept the stale id.
  7. Then, if MQTT reconnects mid-print (which it routinely does — network blips,
  8. printer reboots, Bambuddy restarts), ``reconcile_stale_active_prints`` (#1542)
  9. compares the printer's live subtask_id against the stored one, sees a
  10. mismatch, and synthesises a "missed PRINT COMPLETE" → bogus Print Stopped
  11. notification while the print keeps running.
  12. The fix: update ``archive.subtask_id`` whenever the new effective id differs
  13. from the stored one, not only when the stored one is empty.
  14. """
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from backend.app.core.config import settings as app_settings
  18. from backend.app.main import (
  19. _active_prints,
  20. _expected_print_creators,
  21. _expected_print_registered_at,
  22. _expected_prints,
  23. _print_ams_mappings,
  24. _timelapse_baselines,
  25. register_expected_print,
  26. )
  27. @pytest.fixture(autouse=True)
  28. def _clear_dicts():
  29. _expected_prints.clear()
  30. _expected_print_registered_at.clear()
  31. _expected_print_creators.clear()
  32. _print_ams_mappings.clear()
  33. _active_prints.clear()
  34. _timelapse_baselines.clear()
  35. yield
  36. _expected_prints.clear()
  37. _expected_print_registered_at.clear()
  38. _expected_print_creators.clear()
  39. _print_ams_mappings.clear()
  40. _active_prints.clear()
  41. _timelapse_baselines.clear()
  42. def _patches():
  43. return (
  44. patch("backend.app.main.async_session"),
  45. patch("backend.app.main.notification_service"),
  46. patch("backend.app.main.smart_plug_manager"),
  47. patch("backend.app.main.ws_manager"),
  48. patch("backend.app.main.printer_manager"),
  49. patch("backend.app.main.mqtt_relay"),
  50. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  51. patch("backend.app.main._load_objects_from_archive"),
  52. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  53. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  54. patch(
  55. "backend.app.main._list_timelapse_videos",
  56. new=AsyncMock(return_value=([], "/timelapse")),
  57. ),
  58. )
  59. def _build_mocks(mock_printer, mock_archive):
  60. def execute_router(stmt, *args, **kwargs):
  61. sql = str(stmt).lower()
  62. if "from printers" in sql or "from printer " in sql:
  63. return MagicMock(
  64. scalar_one_or_none=MagicMock(return_value=mock_printer),
  65. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
  66. )
  67. if "from print_archives" in sql or "from print_archive" in sql:
  68. return MagicMock(
  69. scalar_one_or_none=MagicMock(return_value=mock_archive),
  70. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
  71. )
  72. return MagicMock(
  73. scalar_one_or_none=MagicMock(return_value=None),
  74. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  75. )
  76. mock_session = AsyncMock()
  77. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  78. mock_session.__aexit__ = AsyncMock()
  79. mock_session.execute = AsyncMock(side_effect=execute_router)
  80. mock_session.commit = AsyncMock()
  81. return mock_session
  82. def _make_archive(*, archive_id: int, stored_subtask_id: str | None):
  83. mock_archive = MagicMock()
  84. mock_archive.id = archive_id
  85. mock_archive.filename = "Ikea-drybox_silicabox.3mf"
  86. mock_archive.subtask_id = stored_subtask_id
  87. mock_archive.print_time_seconds = None
  88. mock_archive.created_by_id = None
  89. mock_archive.printer_id = 1
  90. mock_archive.print_name = "Ikea-drybox_silicabox"
  91. mock_archive.status = "archived"
  92. mock_archive.file_path = f"archives/{archive_id}/Ikea-drybox_silicabox.3mf"
  93. mock_archive.energy_start_kwh = None
  94. mock_archive.timelapse_path = None
  95. return mock_archive
  96. def _make_printer():
  97. mock_printer = MagicMock()
  98. mock_printer.id = 1
  99. mock_printer.auto_archive = True
  100. mock_printer.external_camera_enabled = False
  101. mock_printer.external_camera_url = None
  102. mock_printer.name = "TestP1S"
  103. return mock_printer
  104. async def _drive(tmp_path, mock_archive, mqtt_subtask_id: str | None):
  105. """Drive ``on_print_start`` with a print-start payload carrying the
  106. given ``subtask_id`` (the printer-echoed id at PRINT START — set by the
  107. queue dispatcher's fresh ``submission_id``)."""
  108. mock_printer = _make_printer()
  109. register_expected_print(1, mock_archive.filename, archive_id=mock_archive.id, ams_mapping=None)
  110. mock_session = _build_mocks(mock_printer, mock_archive)
  111. (
  112. async_session_p,
  113. notif_p,
  114. plug_p,
  115. ws_p,
  116. pm_p,
  117. relay_p,
  118. _energy,
  119. _load_obj,
  120. _store_spoolman,
  121. _send_start,
  122. _list_tl,
  123. ) = _patches()
  124. with (
  125. async_session_p as mock_session_maker,
  126. notif_p as mock_notif,
  127. plug_p as mock_plug,
  128. ws_p as mock_ws,
  129. pm_p as mock_pm,
  130. relay_p as mock_relay,
  131. _energy,
  132. _load_obj,
  133. _store_spoolman,
  134. _send_start,
  135. _list_tl,
  136. patch.object(app_settings, "base_dir", tmp_path),
  137. ):
  138. mock_session_maker.return_value = mock_session
  139. mock_notif.on_print_start = AsyncMock()
  140. mock_plug.on_print_start = AsyncMock()
  141. mock_ws.send_print_start = AsyncMock()
  142. mock_ws.send_archive_updated = AsyncMock()
  143. mock_relay.on_print_start = AsyncMock()
  144. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  145. # last_dispatch_subtask_id fallback shouldn't fire — MQTT carried one.
  146. mock_pm.get_client = MagicMock(return_value=MagicMock(last_dispatch_subtask_id=None))
  147. from backend.app.main import on_print_start
  148. await on_print_start(
  149. 1,
  150. {
  151. "filename": mock_archive.filename,
  152. "subtask_name": mock_archive.print_name,
  153. "raw_data": {"subtask_id": mqtt_subtask_id} if mqtt_subtask_id is not None else {},
  154. },
  155. )
  156. @pytest.mark.asyncio
  157. async def test_reprint_updates_stale_subtask_id(tmp_path):
  158. """The #1807 case: archive stored an OLD subtask_id from the first run.
  159. On reprint dispatch the printer echoes a fresh one — the archive's
  160. stored id must be rewritten so the reconciler doesn't flag the live
  161. print as stale on next MQTT reconnect."""
  162. archive = _make_archive(archive_id=31, stored_subtask_id="1844213296")
  163. await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
  164. assert archive.subtask_id == "2103771517", (
  165. "expected-archive reprint promotion must update archive.subtask_id to the "
  166. "new dispatch id; leaving the old value lets reconcile_stale_active_prints "
  167. "synthesise a bogus PRINT COMPLETE on the next MQTT reconnect (#1807)"
  168. )
  169. @pytest.mark.asyncio
  170. async def test_first_run_still_sets_subtask_id(tmp_path):
  171. """Regression guard for the previously-correct first-run path: an
  172. archive with no stored subtask_id must still have it written on the
  173. first MQTT-echoed PRINT START."""
  174. archive = _make_archive(archive_id=99, stored_subtask_id=None)
  175. await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
  176. assert archive.subtask_id == "2103771517"
  177. @pytest.mark.asyncio
  178. async def test_stable_push_does_not_rewrite(tmp_path):
  179. """The original `not archive.subtask_id` guard's intent was to avoid
  180. rewriting on every push that carries the same id. The inequality check
  181. preserves that no-op behaviour: same id in, no rewrite."""
  182. archive = _make_archive(archive_id=15, stored_subtask_id="2103771517")
  183. # Replace the bare attribute with a MagicMock so we can detect any write,
  184. # not just observe the post-call value (which would match even on a
  185. # spurious "store the same value back" rewrite).
  186. initial = archive.subtask_id
  187. await _drive(tmp_path, archive, mqtt_subtask_id="2103771517")
  188. assert archive.subtask_id == initial