test_background_dispatch_watchdog.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. """Regression tests for ``BackgroundDispatchService._verify_print_response``.
  2. The background-dispatch watchdog used to be fire-and-forget — it logged a
  3. warning and force-reconnected MQTT, but the dispatch job had already been
  4. marked successful. The user therefore saw "Print started successfully" while
  5. the printer never actually transitioned (#1042 follow-up). The watchdog now
  6. returns a bool so the caller can fail the dispatch job when the printer
  7. doesn't acknowledge the command, mirroring what `_watchdog_print_start` does
  8. on the queue side.
  9. Both transition signals are accepted: ``state`` advancing past ``pre_state``
  10. *or* ``subtask_id`` advancing past ``pre_subtask_id`` — H2D firmware can sit
  11. at FINISH for ~50 s after accepting ``project_file`` while echoing the new
  12. subtask_id back almost immediately (#1078).
  13. """
  14. from types import SimpleNamespace
  15. from unittest.mock import MagicMock, patch
  16. import pytest
  17. from backend.app.services.background_dispatch import BackgroundDispatchService
  18. def _status(state: str, subtask_id: str | None = None, gcode_file: str | None = None):
  19. """Minimal stand-in for PrinterState — only the fields the watchdog reads."""
  20. return SimpleNamespace(state=state, subtask_id=subtask_id, gcode_file=gcode_file)
  21. class TestReturnsTrueOnPickup:
  22. @pytest.mark.asyncio
  23. async def test_returns_true_on_state_change(self):
  24. get_status = MagicMock(return_value=_status("RUNNING", "OLD_SUBTASK"))
  25. with patch(
  26. "backend.app.services.background_dispatch.printer_manager.get_status",
  27. get_status,
  28. ):
  29. result = await BackgroundDispatchService._verify_print_response(
  30. printer_id=42,
  31. printer_name="P1S",
  32. pre_state="FINISH",
  33. pre_subtask_id="OLD_SUBTASK",
  34. timeout=0.3,
  35. poll_interval=0.05,
  36. )
  37. assert result is True
  38. @pytest.mark.asyncio
  39. async def test_returns_true_on_subtask_id_change_even_if_state_still_finish(self):
  40. """#1078: H2D keeps state=FINISH for ~50 s after accepting project_file
  41. but flips subtask_id immediately. Must be accepted as a pickup signal."""
  42. get_status = MagicMock(return_value=_status("FINISH", "NEW_SUBTASK_12345"))
  43. with patch(
  44. "backend.app.services.background_dispatch.printer_manager.get_status",
  45. get_status,
  46. ):
  47. result = await BackgroundDispatchService._verify_print_response(
  48. printer_id=42,
  49. printer_name="H2D",
  50. pre_state="FINISH",
  51. pre_subtask_id="OLD_SUBTASK_99999",
  52. timeout=0.3,
  53. poll_interval=0.05,
  54. )
  55. assert result is True
  56. class TestReturnsFalseOnTimeout:
  57. @pytest.mark.asyncio
  58. async def test_returns_false_when_neither_state_nor_subtask_id_changes(self):
  59. """The exact #1042 scenario: P1S sits in FAILED with HMS pending,
  60. accepts the MQTT publish, never transitions. Watchdog must report
  61. failure so the caller fails the dispatch job."""
  62. get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
  63. client = MagicMock()
  64. get_client = MagicMock(return_value=client)
  65. with (
  66. patch(
  67. "backend.app.services.background_dispatch.printer_manager.get_status",
  68. get_status,
  69. ),
  70. patch(
  71. "backend.app.services.background_dispatch.printer_manager.get_client",
  72. get_client,
  73. ),
  74. ):
  75. result = await BackgroundDispatchService._verify_print_response(
  76. printer_id=42,
  77. printer_name="P1S",
  78. pre_state="FINISH",
  79. pre_subtask_id="OLD_SUBTASK",
  80. timeout=0.2,
  81. poll_interval=0.05,
  82. )
  83. assert result is False
  84. client.force_reconnect_stale_session.assert_called_once()
  85. @pytest.mark.asyncio
  86. async def test_returns_false_on_finish_to_idle_user_dismissed_prompt(self):
  87. """Regression for #1370 in the direct-dispatch path: when pre_state is
  88. FINISH and the printer transitions to IDLE during the verifier window,
  89. that's the user dismissing a post-print prompt — NOT acceptance of our
  90. project_file. The original ``state != pre_state`` check incorrectly
  91. returned True on this transition, so the dispatch job was marked
  92. successful even though no print was running. Must now report failure
  93. so the caller raises RuntimeError and the user sees the actual error.
  94. """
  95. get_status = MagicMock(return_value=_status("IDLE", "OLD_SUBTASK"))
  96. client = MagicMock()
  97. get_client = MagicMock(return_value=client)
  98. with (
  99. patch(
  100. "backend.app.services.background_dispatch.printer_manager.get_status",
  101. get_status,
  102. ),
  103. patch(
  104. "backend.app.services.background_dispatch.printer_manager.get_client",
  105. get_client,
  106. ),
  107. ):
  108. result = await BackgroundDispatchService._verify_print_response(
  109. printer_id=42,
  110. printer_name="P1S",
  111. pre_state="FINISH",
  112. pre_subtask_id="OLD_SUBTASK",
  113. timeout=0.2,
  114. poll_interval=0.05,
  115. )
  116. assert result is False, (
  117. "FINISH -> IDLE is the user dismissing a screen prompt, not the "
  118. "printer accepting project_file — verifier must report failure (#1370)"
  119. )
  120. @pytest.mark.asyncio
  121. async def test_returns_true_on_each_active_print_state(self):
  122. """Counterpart to the #1370 fix: transitions into the active-print
  123. state set ARE valid "command landed" signals. PREPARE / SLICING /
  124. RUNNING / PAUSE all return True.
  125. """
  126. for active_state in ("PREPARE", "SLICING", "RUNNING", "PAUSE"):
  127. get_status = MagicMock(return_value=_status(active_state, "OLD_SUBTASK"))
  128. with patch(
  129. "backend.app.services.background_dispatch.printer_manager.get_status",
  130. get_status,
  131. ):
  132. result = await BackgroundDispatchService._verify_print_response(
  133. printer_id=42,
  134. printer_name="P1S",
  135. pre_state="IDLE",
  136. pre_subtask_id="OLD_SUBTASK",
  137. timeout=0.2,
  138. poll_interval=0.05,
  139. )
  140. assert result is True, (
  141. f"transition IDLE -> {active_state} must be treated as a valid 'command landed' signal"
  142. )
  143. @pytest.mark.asyncio
  144. async def test_returns_false_when_pre_subtask_id_none_and_state_unchanged(self):
  145. """Backward-compat: callers without a captured pre_subtask_id (e.g. the
  146. printer never reported one) must still get the timeout failure path
  147. based on state alone."""
  148. get_status = MagicMock(return_value=_status("FINISH", "ANYTHING"))
  149. get_client = MagicMock(return_value=None)
  150. with (
  151. patch(
  152. "backend.app.services.background_dispatch.printer_manager.get_status",
  153. get_status,
  154. ),
  155. patch(
  156. "backend.app.services.background_dispatch.printer_manager.get_client",
  157. get_client,
  158. ),
  159. ):
  160. result = await BackgroundDispatchService._verify_print_response(
  161. printer_id=42,
  162. printer_name="P1S",
  163. pre_state="FINISH",
  164. pre_subtask_id=None,
  165. timeout=0.2,
  166. poll_interval=0.05,
  167. )
  168. assert result is False
  169. @pytest.mark.asyncio
  170. async def test_subtask_id_none_post_dispatch_does_not_count_as_change(self):
  171. """If the printer transiently reports subtask_id=None during the
  172. watchdog window (e.g. mid-reconnect), that must not be treated as
  173. "advanced past pre_subtask_id" — otherwise we'd false-pass and mark
  174. a never-started print as successful."""
  175. get_status = MagicMock(return_value=_status("FINISH", None))
  176. get_client = MagicMock(return_value=None)
  177. with (
  178. patch(
  179. "backend.app.services.background_dispatch.printer_manager.get_status",
  180. get_status,
  181. ),
  182. patch(
  183. "backend.app.services.background_dispatch.printer_manager.get_client",
  184. get_client,
  185. ),
  186. ):
  187. result = await BackgroundDispatchService._verify_print_response(
  188. printer_id=42,
  189. printer_name="P1S",
  190. pre_state="FINISH",
  191. pre_subtask_id="OLD_SUBTASK",
  192. timeout=0.2,
  193. poll_interval=0.05,
  194. )
  195. assert result is False
  196. class TestDisconnectHandling:
  197. @pytest.mark.asyncio
  198. async def test_disconnect_does_not_short_circuit_window(self):
  199. """A momentary ``get_status() is None`` (brief MQTT disconnect mid-window)
  200. must not immediately fail the dispatch — the printer may reconnect and
  201. still produce a valid transition before timeout. Falsely failing on the
  202. first missed tick is the previous bug class we're moving away from."""
  203. # First call: disconnected. Second call onward: reconnected and transitioned.
  204. get_status = MagicMock(side_effect=[None, _status("RUNNING")])
  205. with patch(
  206. "backend.app.services.background_dispatch.printer_manager.get_status",
  207. get_status,
  208. ):
  209. result = await BackgroundDispatchService._verify_print_response(
  210. printer_id=42,
  211. printer_name="P1S",
  212. pre_state="FINISH",
  213. pre_subtask_id="OLD_SUBTASK",
  214. timeout=0.3,
  215. poll_interval=0.05,
  216. )
  217. assert result is True
  218. assert get_status.call_count >= 2
  219. @pytest.mark.asyncio
  220. async def test_disconnect_for_full_window_returns_false(self):
  221. """Persistent disconnect for the full window is treated as failure.
  222. Better to false-fail and let the user retry than to false-succeed and
  223. leave them watching an idle printer (#1042)."""
  224. get_status = MagicMock(return_value=None)
  225. get_client = MagicMock(return_value=None)
  226. with (
  227. patch(
  228. "backend.app.services.background_dispatch.printer_manager.get_status",
  229. get_status,
  230. ),
  231. patch(
  232. "backend.app.services.background_dispatch.printer_manager.get_client",
  233. get_client,
  234. ),
  235. ):
  236. result = await BackgroundDispatchService._verify_print_response(
  237. printer_id=42,
  238. printer_name="P1S",
  239. pre_state="FINISH",
  240. pre_subtask_id="OLD_SUBTASK",
  241. timeout=0.2,
  242. poll_interval=0.05,
  243. )
  244. assert result is False
  245. class TestDefaults:
  246. def test_default_timeout_matches_queue_watchdog(self):
  247. """Queue and background watchdogs need the same 90 s default to give
  248. slow H2D FINISH→PREPARE transitions the same headroom on both paths."""
  249. import inspect
  250. sig = inspect.signature(BackgroundDispatchService._verify_print_response)
  251. assert sig.parameters["timeout"].default == 90.0
  252. class TestGcodeFileDiscriminator:
  253. """#1150 vs #887/#936 discriminator: skip the forced reconnect when the
  254. printer's gcode_file changed since pre-dispatch (project_file landed,
  255. printer is parsing slowly — reconnecting mid-parse causes 0500_4003).
  256. Reconnect when gcode_file is unchanged (publish was silently swallowed —
  257. half-broken session needs the original recovery)."""
  258. @pytest.mark.asyncio
  259. async def test_skips_reconnect_when_gcode_file_changed(self):
  260. get_status = MagicMock(
  261. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
  262. )
  263. client = MagicMock()
  264. get_client = MagicMock(return_value=client)
  265. with (
  266. patch(
  267. "backend.app.services.background_dispatch.printer_manager.get_status",
  268. get_status,
  269. ),
  270. patch(
  271. "backend.app.services.background_dispatch.printer_manager.get_client",
  272. get_client,
  273. ),
  274. ):
  275. result = await BackgroundDispatchService._verify_print_response(
  276. printer_id=42,
  277. printer_name="P1P",
  278. pre_state="FINISH",
  279. pre_subtask_id="OLD_SUBTASK",
  280. pre_gcode_file="/old.3mf",
  281. timeout=0.2,
  282. poll_interval=0.05,
  283. )
  284. assert result is False
  285. client.force_reconnect_stale_session.assert_not_called()
  286. @pytest.mark.asyncio
  287. async def test_reconnects_when_gcode_file_unchanged(self):
  288. # The half-broken-session case (#887/#936): publish was dropped, so
  289. # the printer is still showing the previous file. Reconnect to clear
  290. # the broken paho QoS-1 queue.
  291. get_status = MagicMock(
  292. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"),
  293. )
  294. client = MagicMock()
  295. get_client = MagicMock(return_value=client)
  296. with (
  297. patch(
  298. "backend.app.services.background_dispatch.printer_manager.get_status",
  299. get_status,
  300. ),
  301. patch(
  302. "backend.app.services.background_dispatch.printer_manager.get_client",
  303. get_client,
  304. ),
  305. ):
  306. await BackgroundDispatchService._verify_print_response(
  307. printer_id=42,
  308. printer_name="P1P",
  309. pre_state="FINISH",
  310. pre_subtask_id="OLD_SUBTASK",
  311. pre_gcode_file="/old.3mf",
  312. timeout=0.2,
  313. poll_interval=0.05,
  314. )
  315. client.force_reconnect_stale_session.assert_called_once()
  316. @pytest.mark.asyncio
  317. async def test_skips_reconnect_when_pre_gcode_file_was_none(self):
  318. # Printer just connected (pre_gcode_file=None) and now reports a
  319. # file — that's a clear "command landed" signal too.
  320. get_status = MagicMock(
  321. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
  322. )
  323. client = MagicMock()
  324. get_client = MagicMock(return_value=client)
  325. with (
  326. patch(
  327. "backend.app.services.background_dispatch.printer_manager.get_status",
  328. get_status,
  329. ),
  330. patch(
  331. "backend.app.services.background_dispatch.printer_manager.get_client",
  332. get_client,
  333. ),
  334. ):
  335. await BackgroundDispatchService._verify_print_response(
  336. printer_id=42,
  337. printer_name="P1P",
  338. pre_state="FINISH",
  339. pre_subtask_id="OLD_SUBTASK",
  340. pre_gcode_file=None,
  341. timeout=0.2,
  342. poll_interval=0.05,
  343. )
  344. client.force_reconnect_stale_session.assert_not_called()
  345. @pytest.mark.asyncio
  346. async def test_reconnects_when_no_pre_gcode_file_arg_supplied(self):
  347. # Backward-compat: callers that don't pass pre_gcode_file at all
  348. # (everything but our updated dispatch sites) must still get the
  349. # original reconnect-on-timeout behaviour. Here pre_gcode_file
  350. # defaults to None and the printer's current gcode_file is also
  351. # None → publish_landed=False → reconnect.
  352. get_status = MagicMock(
  353. return_value=_status("FINISH", "OLD_SUBTASK", gcode_file=None),
  354. )
  355. client = MagicMock()
  356. get_client = MagicMock(return_value=client)
  357. with (
  358. patch(
  359. "backend.app.services.background_dispatch.printer_manager.get_status",
  360. get_status,
  361. ),
  362. patch(
  363. "backend.app.services.background_dispatch.printer_manager.get_client",
  364. get_client,
  365. ),
  366. ):
  367. await BackgroundDispatchService._verify_print_response(
  368. printer_id=42,
  369. printer_name="P1P",
  370. pre_state="FINISH",
  371. pre_subtask_id="OLD_SUBTASK",
  372. timeout=0.2,
  373. poll_interval=0.05,
  374. )
  375. client.force_reconnect_stale_session.assert_called_once()
  376. # ---------------------------------------------------------------------------
  377. # Integration tests: the call sites in _run_reprint_archive and
  378. # _run_print_library_file must (a) await the watchdog instead of fire-and-
  379. # forget, (b) raise RuntimeError on watchdog False so _run_active_job marks
  380. # the job failed, (c) rollback the library-file flow's freshly-created
  381. # archive on timeout. Heavy mocking — the goal is to verify the new wiring,
  382. # not to re-test the dependencies.
  383. # ---------------------------------------------------------------------------
  384. from contextlib import asynccontextmanager # noqa: E402
  385. from unittest.mock import AsyncMock # noqa: E402
  386. from backend.app.services.background_dispatch import ( # noqa: E402
  387. ActiveDispatchState,
  388. PrintDispatchJob,
  389. )
  390. def _make_session_factory(db_mock):
  391. """Build an async-session factory whose context manager yields ``db_mock``.
  392. Mirrors the ``async with async_session() as db`` shape used by both
  393. ``_run_*`` methods so the test can intercept ``db.rollback`` / ``db.scalar``.
  394. """
  395. @asynccontextmanager
  396. async def _factory():
  397. yield db_mock
  398. return _factory
  399. def _printer_namespace():
  400. return SimpleNamespace(
  401. id=10,
  402. name="P1S",
  403. ip_address="1.2.3.4",
  404. access_code="abc",
  405. model="P1S",
  406. )
  407. def _make_dispatch_job(kind: str = "reprint_archive") -> PrintDispatchJob:
  408. return PrintDispatchJob(
  409. id=1,
  410. kind=kind,
  411. source_id=99,
  412. source_name="Test.gcode.3mf",
  413. printer_id=10,
  414. printer_name="P1S",
  415. options={},
  416. requested_by_user_id=None,
  417. requested_by_username=None,
  418. )
  419. @pytest.fixture
  420. def reprint_archive_mocks(tmp_path):
  421. """Mock harness for ``_run_reprint_archive`` covering every external
  422. dependency up to (and including) ``start_print``. The watchdog is left
  423. real so the caller can patch ``_verify_print_response`` per-test."""
  424. archive_file = tmp_path / "test.3mf"
  425. archive_file.write_bytes(b"fake-3mf-content")
  426. archive = SimpleNamespace(
  427. id=99,
  428. filename="Test.gcode.3mf",
  429. file_path=str(archive_file),
  430. )
  431. db = MagicMock()
  432. db.scalar = AsyncMock(return_value=_printer_namespace())
  433. db.rollback = AsyncMock()
  434. archive_service = MagicMock()
  435. archive_service.get_archive = AsyncMock(return_value=archive)
  436. return {
  437. "archive": archive,
  438. "archive_file": archive_file,
  439. "db": db,
  440. "archive_service": archive_service,
  441. "session_factory": _make_session_factory(db),
  442. }
  443. @pytest.fixture
  444. def library_file_mocks(tmp_path):
  445. """Mock harness for ``_run_print_library_file`` — separate from the
  446. reprint fixture because the library flow creates its archive via
  447. ``archive_service.archive_print(...)`` rather than fetching one."""
  448. src_file = tmp_path / "lib_src.3mf"
  449. src_file.write_bytes(b"fake-3mf-content")
  450. lib_file = SimpleNamespace(
  451. id=22,
  452. filename="cube.gcode.3mf",
  453. file_path=str(src_file.relative_to(tmp_path)),
  454. )
  455. lib_file.active = staticmethod(lambda: lib_file) # mimic LibraryFile.active() chainable
  456. new_archive = SimpleNamespace(id=500, filename="cube.gcode.3mf", file_path=str(src_file))
  457. db = MagicMock()
  458. db.scalar = AsyncMock() # configured per-test
  459. db.flush = AsyncMock()
  460. db.commit = AsyncMock()
  461. db.rollback = AsyncMock()
  462. archive_service = MagicMock()
  463. archive_service.archive_print = AsyncMock(return_value=new_archive)
  464. return {
  465. "lib_file": lib_file,
  466. "src_file": src_file,
  467. "new_archive": new_archive,
  468. "db": db,
  469. "archive_service": archive_service,
  470. "session_factory": _make_session_factory(db),
  471. }
  472. class TestReprintArchiveDispatchWiring:
  473. """Verify ``_run_reprint_archive`` (a) awaits the watchdog inline and
  474. (b) raises RuntimeError on False so the dispatch job is marked failed."""
  475. @pytest.mark.asyncio
  476. async def test_raises_runtime_error_when_watchdog_returns_false(self, reprint_archive_mocks):
  477. """The exact #1042 propagation gap: watchdog detects non-transition,
  478. _run_reprint_archive must surface it as a RuntimeError so the surrounding
  479. _run_active_job marks the job failed (instead of silently completing)."""
  480. from backend.app.services.background_dispatch import BackgroundDispatchService
  481. m = reprint_archive_mocks
  482. service = BackgroundDispatchService()
  483. job = _make_dispatch_job(kind="reprint_archive")
  484. watchdog = AsyncMock(return_value=False)
  485. with (
  486. patch("backend.app.services.background_dispatch.async_session", m["session_factory"]),
  487. patch(
  488. "backend.app.services.background_dispatch.ArchiveService",
  489. return_value=m["archive_service"],
  490. ),
  491. patch.object(BackgroundDispatchService, "_verify_print_response", watchdog),
  492. patch(
  493. "backend.app.services.background_dispatch.printer_manager.is_connected",
  494. return_value=True,
  495. ),
  496. patch(
  497. "backend.app.services.background_dispatch.printer_manager.get_status",
  498. return_value=SimpleNamespace(state="FINISH", subtask_id="OLD_SUBTASK"),
  499. ),
  500. patch(
  501. "backend.app.services.background_dispatch.printer_manager.start_print",
  502. return_value=True,
  503. ),
  504. patch(
  505. "backend.app.services.background_dispatch.delete_file_async",
  506. new_callable=AsyncMock,
  507. ),
  508. patch(
  509. "backend.app.services.background_dispatch.with_ftp_retry",
  510. new_callable=AsyncMock,
  511. return_value=True,
  512. ),
  513. patch(
  514. "backend.app.services.background_dispatch.get_ftp_retry_settings",
  515. new_callable=AsyncMock,
  516. return_value=(False, 0, 0, 30.0),
  517. ),
  518. patch(
  519. "backend.app.services.background_dispatch.upload_file_async",
  520. new_callable=AsyncMock,
  521. return_value=True,
  522. ),
  523. patch(
  524. "backend.app.services.background_dispatch.ws_manager.broadcast",
  525. new_callable=AsyncMock,
  526. ),
  527. patch("backend.app.main.register_expected_print"),
  528. pytest.raises(RuntimeError, match="did not acknowledge print command"),
  529. ):
  530. await service._run_reprint_archive(job)
  531. # Watchdog received the captured pre-state and pre_subtask_id.
  532. watchdog.assert_awaited_once()
  533. kwargs = watchdog.await_args.kwargs
  534. args = watchdog.await_args.args
  535. assert "FINISH" in args # pre_state
  536. assert kwargs["pre_subtask_id"] == "OLD_SUBTASK"
  537. @pytest.mark.asyncio
  538. async def test_succeeds_when_watchdog_returns_true(self, reprint_archive_mocks):
  539. """Happy path: watchdog confirms pickup; _run_reprint_archive returns
  540. without raising. Guards against the wiring accidentally raising on True."""
  541. from backend.app.services.background_dispatch import BackgroundDispatchService
  542. m = reprint_archive_mocks
  543. service = BackgroundDispatchService()
  544. job = _make_dispatch_job(kind="reprint_archive")
  545. with (
  546. patch("backend.app.services.background_dispatch.async_session", m["session_factory"]),
  547. patch(
  548. "backend.app.services.background_dispatch.ArchiveService",
  549. return_value=m["archive_service"],
  550. ),
  551. patch.object(
  552. BackgroundDispatchService,
  553. "_verify_print_response",
  554. AsyncMock(return_value=True),
  555. ),
  556. patch(
  557. "backend.app.services.background_dispatch.printer_manager.is_connected",
  558. return_value=True,
  559. ),
  560. patch(
  561. "backend.app.services.background_dispatch.printer_manager.get_status",
  562. return_value=SimpleNamespace(state="FINISH", subtask_id="OLD_SUBTASK"),
  563. ),
  564. patch(
  565. "backend.app.services.background_dispatch.printer_manager.start_print",
  566. return_value=True,
  567. ),
  568. patch(
  569. "backend.app.services.background_dispatch.delete_file_async",
  570. new_callable=AsyncMock,
  571. ),
  572. patch(
  573. "backend.app.services.background_dispatch.with_ftp_retry",
  574. new_callable=AsyncMock,
  575. return_value=True,
  576. ),
  577. patch(
  578. "backend.app.services.background_dispatch.get_ftp_retry_settings",
  579. new_callable=AsyncMock,
  580. return_value=(False, 0, 0, 30.0),
  581. ),
  582. patch(
  583. "backend.app.services.background_dispatch.upload_file_async",
  584. new_callable=AsyncMock,
  585. return_value=True,
  586. ),
  587. patch(
  588. "backend.app.services.background_dispatch.ws_manager.broadcast",
  589. new_callable=AsyncMock,
  590. ),
  591. patch("backend.app.main.register_expected_print"),
  592. ):
  593. await service._run_reprint_archive(job) # must not raise
  594. # Reprint flow does not touch the existing archive — no rollback expected.
  595. m["db"].rollback.assert_not_called()
  596. class TestRunActiveJobMarksFailedOnRuntimeError:
  597. """End-to-end: a watchdog-driven RuntimeError must reach
  598. `_mark_job_finished(failed=True)` via the existing ``_run_active_job``
  599. catch-all, so the dispatch UI shows a real failure (not "Done")."""
  600. @pytest.mark.asyncio
  601. async def test_runtime_error_from_process_job_marks_failed_with_message(self):
  602. from backend.app.services.background_dispatch import BackgroundDispatchService
  603. service = BackgroundDispatchService()
  604. job = _make_dispatch_job()
  605. # Place the job into _active_jobs so _set_active_message has a target.
  606. service._active_jobs[job.id] = ActiveDispatchState(job=job, message="")
  607. failure_message = (
  608. "Printer did not acknowledge print command — state still FINISH. "
  609. "Check the printer for a pending error (HMS code, plate-clear prompt, "
  610. "SD card) and try again."
  611. )
  612. with (
  613. patch.object(
  614. BackgroundDispatchService,
  615. "_process_job",
  616. AsyncMock(side_effect=RuntimeError(failure_message)),
  617. ),
  618. patch.object(
  619. BackgroundDispatchService,
  620. "_mark_job_finished",
  621. new_callable=AsyncMock,
  622. ) as mark_finished,
  623. ):
  624. await service._run_active_job(job)
  625. mark_finished.assert_awaited_once()
  626. kwargs = mark_finished.await_args.kwargs
  627. assert kwargs["failed"] is True
  628. assert "did not acknowledge print command" in kwargs["message"]