test_print_complete_end_to_end.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """A queued print's completion, end to end on the database.
  2. The archive row, the print log entry, the queue item and the wallet charge are
  3. what the rest of Bambuddy reads afterwards (archives page, statistics, project
  4. totals, the cost centre's budget). Each was only pinned through mocks of the
  5. function that writes it; here ``on_print_complete`` runs against real rows and
  6. only the outside world (websocket, MQTT relay, notifications, plugs, the
  7. printer's SD card) is mocked; the printer manager knows no printer.
  8. """
  9. import asyncio
  10. from datetime import datetime
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  15. from backend.app import main as main_module
  16. from backend.app.models.archive import PrintArchive
  17. from backend.app.models.finance import CostCenter, WalletTransaction
  18. from backend.app.models.print_log import PrintLogEntry
  19. from backend.app.models.print_queue import PrintQueueItem
  20. from backend.app.models.settings import Settings
  21. from backend.app.models.user import User
  22. from backend.app.services.bambu_ftp import DeleteResult
  23. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  24. RUN = "run-end-to-end"
  25. @pytest.fixture(autouse=True)
  26. def _clean():
  27. """The completion marks the printer as awaiting a plate clear on the real
  28. printer manager; the next test on this worker must not inherit that."""
  29. from backend.app.services.printer_manager import printer_manager
  30. main_module._active_prints.clear()
  31. yield
  32. main_module._active_prints.clear()
  33. printer_manager._awaiting_plate_clear.clear()
  34. @pytest.fixture(autouse=True)
  35. async def _no_finish_photo(db_session):
  36. """The finish photo would open the printer's camera."""
  37. db_session.add(Settings(key="capture_finish_photo", value="false"))
  38. await db_session.commit()
  39. @pytest.fixture
  40. async def queued_print(db_session, printer_factory):
  41. printer = await printer_factory(name="P")
  42. db_session.add(Settings(key="billing_enabled", value="true"))
  43. user = User(username="payer", password_hash="x", is_active=True)
  44. center = CostCenter(name="Lab", is_active=True, is_private=False)
  45. db_session.add_all([user, center])
  46. await db_session.flush()
  47. archive = PrintArchive(
  48. printer_id=printer.id,
  49. filename="part.3mf",
  50. print_name="part",
  51. file_path="archives/test/part.3mf",
  52. file_size=1,
  53. status="printing",
  54. started_at=datetime(2026, 10, 19, 8, 0),
  55. cost=5.0,
  56. created_by_id=user.id,
  57. billing_run_id=RUN,
  58. )
  59. db_session.add(archive)
  60. await db_session.flush()
  61. item = PrintQueueItem(
  62. printer_id=printer.id,
  63. archive_id=archive.id,
  64. status="printing",
  65. position=1,
  66. created_by_id=user.id,
  67. cost_center_id=center.id,
  68. estimated_cost=5.0,
  69. billing_run_id=RUN,
  70. )
  71. db_session.add(item)
  72. await db_session.commit()
  73. main_module._active_prints[(printer.id, "part.3mf")] = archive.id
  74. return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
  75. async def _complete(test_engine, printer_id: int, status: str, name: str = "part", extra: dict | None = None):
  76. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  77. tasks_before = set(asyncio.all_tasks())
  78. with (
  79. patch("backend.app.main.async_session", maker),
  80. patch("backend.app.core.database.async_session", maker),
  81. patch("backend.app.main.ws_manager") as ws,
  82. patch("backend.app.main.mqtt_relay") as relay,
  83. patch("backend.app.main.notification_service") as notif,
  84. patch("backend.app.main.smart_plug_manager") as plug,
  85. patch("backend.app.services.spoolman_tracking.async_session", maker),
  86. # The SD-card cleanup of the uploaded file
  87. patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)),
  88. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
  89. ):
  90. ws.send_print_complete = AsyncMock()
  91. ws.send_archive_updated = AsyncMock()
  92. ws.broadcast = AsyncMock()
  93. relay.on_print_complete = AsyncMock()
  94. notif.on_print_complete = AsyncMock()
  95. plug.on_print_complete = AsyncMock()
  96. await main_module.on_print_complete(
  97. printer_id,
  98. {
  99. "status": status,
  100. "filename": f"{name}.gcode",
  101. "subtask_name": name,
  102. "timelapse_was_active": False,
  103. **(extra or {}),
  104. },
  105. )
  106. spawned = asyncio.all_tasks() - tasks_before
  107. if spawned:
  108. _done, pending = await asyncio.wait(spawned, timeout=10)
  109. # Left running, they would carry on into the next test unpatched
  110. assert not pending, f"background work still running: {pending}"
  111. return notif
  112. async def _rows(db_session, archive_id: int, item_id: int):
  113. db_session.expire_all()
  114. archive = await db_session.get(PrintArchive, archive_id)
  115. item = await db_session.get(PrintQueueItem, item_id)
  116. logs = (await db_session.scalars(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))).all()
  117. charges = (
  118. await db_session.scalars(select(WalletTransaction).where(WalletTransaction.transaction_type == "print_charge"))
  119. ).all()
  120. return archive, item, logs, charges
  121. async def test_a_completed_print(test_engine, db_session, queued_print):
  122. await _complete(test_engine, queued_print["printer"], "completed")
  123. archive, item, logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  124. assert archive.status == "completed"
  125. assert item.status == "completed"
  126. assert [log.status for log in logs] == ["completed"]
  127. assert len(charges) == 1
  128. charge = charges[0]
  129. assert (charge.user_id, charge.cost_center_id, charge.print_queue_id, charge.print_run_id) == (
  130. queued_print["user"],
  131. queued_print["center"],
  132. queued_print["item"],
  133. RUN,
  134. )
  135. assert abs(charge.amount) == pytest.approx(5.0)
  136. async def test_a_completion_reconciled_at_reconnect_has_no_actual_duration(test_engine, db_session, queued_print):
  137. """DB-C9: the printer was offline and the print ended at some unknown time;
  138. the reconnect closes it now, so now minus its start is not its duration."""
  139. notif = await _complete(test_engine, queued_print["printer"], "aborted", extra={"_reconciled": True})
  140. archive_data = notif.on_print_complete.await_args.kwargs["archive_data"]
  141. assert archive_data["actual_time_seconds"] is None
  142. async def test_completion_stops_the_start_estimate_before_writing_the_cost(test_engine, db_session, queued_print):
  143. """COST-4: the background estimate must not outlive the real cost."""
  144. with patch("backend.app.main._settle_archive_cost_estimate", new_callable=AsyncMock) as settle:
  145. await _complete(test_engine, queued_print["printer"], "completed")
  146. settle.assert_awaited_once_with(queued_print["archive"])
  147. async def test_a_failed_print(test_engine, db_session, queued_print):
  148. await _complete(test_engine, queued_print["printer"], "failed")
  149. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  150. assert archive.status == "failed"
  151. assert item.status == "failed"
  152. assert [log.status for log in logs] == ["failed"]
  153. async def test_a_second_completion_charges_nothing_more(test_engine, db_session, queued_print):
  154. """A duplicate completion callback (reconnect replay) is idempotent."""
  155. await _complete(test_engine, queued_print["printer"], "completed")
  156. main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
  157. await _complete(test_engine, queued_print["printer"], "completed")
  158. _archive, _item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  159. assert len(charges) == 1
  160. async def test_a_duplicate_completion_logs_the_run_once(test_engine, db_session, queued_print):
  161. await _complete(test_engine, queued_print["printer"], "completed")
  162. main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
  163. await _complete(test_engine, queued_print["printer"], "completed")
  164. _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  165. assert [(log.status, log.queue_item_id) for log in logs] == [("completed", queued_print["item"])]
  166. async def test_a_completion_takes_its_own_reason(test_engine, db_session, queued_print):
  167. """A reprint that completes carries no reason of an earlier run."""
  168. archive = await db_session.get(PrintArchive, queued_print["archive"])
  169. archive.failure_reason = "spaghetti"
  170. await db_session.commit()
  171. await _complete(test_engine, queued_print["printer"], "completed")
  172. archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  173. assert (archive.status, archive.failure_reason) == ("completed", None)
  174. assert [(log.status, log.failure_reason) for log in logs] == [("completed", None)]
  175. class TestAStopWhileThePrinterIsOffline:
  176. """The stop could not reach the printer, so no completion may ever come (#2603)."""
  177. async def _stop(self, async_client, queued_print):
  178. with (
  179. patch("backend.app.services.print_control.stop_print_by_user", return_value=False),
  180. patch("backend.app.api.routes.print_queue.ws_manager") as ws,
  181. ):
  182. ws.send_archive_updated = AsyncMock()
  183. response = await async_client.post(f"/api/v1/queue/{queued_print['item']}/stop")
  184. assert response.status_code == 200, response.text
  185. return ws
  186. async def test_logs_the_run_as_cancelled_and_tells_the_archives_page(self, async_client, db_session, queued_print):
  187. ws = await self._stop(async_client, queued_print)
  188. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  189. assert (archive.status, archive.failure_reason) == ("cancelled", "userCancelled")
  190. assert item.status == "cancelled"
  191. assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
  192. ("cancelled", "userCancelled", queued_print["item"])
  193. ]
  194. assert ws.send_archive_updated.await_args.args[0] == {"id": queued_print["archive"], "status": "cancelled"}
  195. async def test_frees_the_budget_held_for_the_print(self, async_client, db_session, queued_print):
  196. """QS/I5: no completion will come to release it, which left the cost
  197. centre's budget reserved until Bambuddy restarted."""
  198. from backend.app.models.finance import BudgetReservation
  199. hold = BudgetReservation(
  200. cost_center_id=queued_print["center"], amount=5.0, source_type="print_queue", source_id=queued_print["item"]
  201. )
  202. db_session.add(hold)
  203. await db_session.commit()
  204. await self._stop(async_client, queued_print)
  205. await db_session.refresh(hold)
  206. assert hold.status == "released"
  207. async def test_a_stop_that_reached_the_printer_leaves_it_to_the_print_s_end(
  208. self, async_client, db_session, queued_print
  209. ):
  210. """The print's end charges the run and releases the hold itself."""
  211. from backend.app.models.finance import BudgetReservation
  212. hold = BudgetReservation(
  213. cost_center_id=queued_print["center"], amount=5.0, source_type="print_queue", source_id=queued_print["item"]
  214. )
  215. db_session.add(hold)
  216. await db_session.commit()
  217. with (
  218. patch("backend.app.services.print_control.stop_print_by_user", return_value=True),
  219. patch("backend.app.api.routes.print_queue.ws_manager"),
  220. ):
  221. await async_client.post(f"/api/v1/queue/{queued_print['item']}/stop")
  222. await db_session.refresh(hold)
  223. assert hold.status == "active"
  224. async def test_a_completion_that_arrives_after_all_replaces_it(
  225. self, async_client, test_engine, db_session, queued_print
  226. ):
  227. """The printer finished the print offline and reports it on reconnect."""
  228. await self._stop(async_client, queued_print)
  229. await _complete(test_engine, queued_print["printer"], "completed")
  230. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  231. assert (archive.status, archive.failure_reason) == ("completed", None)
  232. assert item.status == "cancelled"
  233. assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
  234. ("completed", None, queued_print["item"])
  235. ]
  236. class TestACompletionNoArchiveWasBoundTo:
  237. """A print Bambuddy did not start is matched to its archive by name (L-W3)."""
  238. async def _printing(self, db_session, printer_id: int, name: str) -> int:
  239. archive = PrintArchive(
  240. printer_id=printer_id,
  241. filename=f"{name}.3mf",
  242. print_name=name,
  243. file_path=f"archives/test/{name}.3mf",
  244. file_size=1,
  245. status="printing",
  246. )
  247. db_session.add(archive)
  248. await db_session.commit()
  249. return archive.id
  250. @pytest.mark.parametrize(
  251. ("printed", "other"),
  252. [
  253. ("Clip", "Cable Clip"), # a name inside another
  254. ("Clip_A", "ClipXA"), # "_" is a LIKE wildcard
  255. ],
  256. )
  257. async def test_another_archive_whose_name_contains_it_is_left_alone(
  258. self, test_engine, db_session, printer_factory, printed, other
  259. ):
  260. printer = await printer_factory(name="P")
  261. other_id = await self._printing(db_session, printer.id, other)
  262. await _complete(test_engine, printer.id, "completed", name=printed)
  263. db_session.expire_all()
  264. assert (await db_session.get(PrintArchive, other_id)).status == "printing"
  265. async def test_its_own_archive_is_completed(self, test_engine, db_session, printer_factory):
  266. printer = await printer_factory(name="P")
  267. other_id = await self._printing(db_session, printer.id, "Cable Clip")
  268. own_id = await self._printing(db_session, printer.id, "Clip")
  269. await _complete(test_engine, printer.id, "completed", name="Clip")
  270. db_session.expire_all()
  271. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  272. assert (await db_session.get(PrintArchive, other_id)).status == "printing"
  273. async def test_the_printers_underscores_for_spaces_still_match(self, test_engine, db_session, printer_factory):
  274. printer = await printer_factory(name="P")
  275. own_id = await self._printing(db_session, printer.id, "Cable Clip")
  276. await _complete(test_engine, printer.id, "completed", name="Cable_Clip")
  277. db_session.expire_all()
  278. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  279. async def test_a_renamed_archive_is_found_by_its_file_name(self, test_engine, db_session, printer_factory):
  280. printer = await printer_factory(name="P")
  281. own_id = await self._printing(db_session, printer.id, "Clip")
  282. own = await db_session.get(PrintArchive, own_id)
  283. own.print_name = "Clip for the desk"
  284. await db_session.commit()
  285. await _complete(test_engine, printer.id, "completed", name="Clip")
  286. db_session.expire_all()
  287. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  288. async def test_a_print_started_on_the_printer_reports_its_file_name(self, test_engine, db_session, printer_factory):
  289. """The subtask then carries the extensions (#3009)."""
  290. printer = await printer_factory(name="P")
  291. own_id = await self._printing(db_session, printer.id, "Clip")
  292. own = await db_session.get(PrintArchive, own_id)
  293. own.filename = "Clip.gcode.3mf"
  294. await db_session.commit()
  295. await _complete(test_engine, printer.id, "completed", name="Clip.gcode.3mf")
  296. db_session.expire_all()
  297. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  298. async def test_the_layer_timelapse_is_attached_for_the_run_that_ended(test_engine, db_session, queued_print, tmp_path):
  299. """The run is read before the stitch, which can take long enough for the
  300. archive to start printing again (TL-3)."""
  301. stitched = tmp_path / "stitched.mp4"
  302. stitched.write_bytes(b"layer video")
  303. attach = AsyncMock()
  304. with (
  305. patch("backend.app.services.layer_timelapse.on_print_complete", AsyncMock(return_value=stitched)),
  306. patch("backend.app.main._attach_layer_timelapse", attach),
  307. ):
  308. await _complete(test_engine, queued_print["printer"], "completed")
  309. attach.assert_awaited_once()
  310. archive_id, path, _plate, run_started_at = attach.await_args.args
  311. assert (archive_id, path, run_started_at) == (queued_print["archive"], stitched, datetime(2026, 10, 19, 8, 0))
  312. async def test_the_registered_mapping_is_drained_when_the_printer_reported_one(test_engine, db_session, queued_print):
  313. """PAM/I3: left behind, the archive's next run (sent without a mapping)
  314. would charge its usage through this run's trays."""
  315. main_module._print_ams_mappings[queued_print["archive"]] = [4]
  316. try:
  317. await _complete(test_engine, queued_print["printer"], "completed", extra={"ams_mapping": [0]})
  318. assert queued_print["archive"] not in main_module._print_ams_mappings
  319. finally:
  320. main_module._print_ams_mappings.pop(queued_print["archive"], None)
  321. async def test_a_hung_sd_cleanup_does_not_hold_up_the_print_s_end(test_engine, db_session, queued_print, monkeypatch):
  322. """QS/I9: the job is closed (and billed) by its end, not by the stranded-job
  323. closer, however long the printer takes to answer a delete."""
  324. import asyncio
  325. async def hangs(*_args, **_kwargs):
  326. await asyncio.sleep(30)
  327. monkeypatch.setattr(main_module, "_SD_CLEANUP_BUDGET_SECONDS", 0.2)
  328. monkeypatch.setattr(main_module, "_cleanup_sd_card_after_print", hangs)
  329. await asyncio.wait_for(_complete(test_engine, queued_print["printer"], "completed"), timeout=15)
  330. _archive, item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  331. assert item.status == "completed"
  332. assert len(charges) == 1