test_print_complete_end_to_end.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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") -> 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. {"status": status, "filename": f"{name}.gcode", "subtask_name": name, "timelapse_was_active": False},
  99. )
  100. spawned = asyncio.all_tasks() - tasks_before
  101. if spawned:
  102. _done, pending = await asyncio.wait(spawned, timeout=10)
  103. # Left running, they would carry on into the next test unpatched
  104. assert not pending, f"background work still running: {pending}"
  105. async def _rows(db_session, archive_id: int, item_id: int):
  106. db_session.expire_all()
  107. archive = await db_session.get(PrintArchive, archive_id)
  108. item = await db_session.get(PrintQueueItem, item_id)
  109. logs = (await db_session.scalars(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))).all()
  110. charges = (
  111. await db_session.scalars(select(WalletTransaction).where(WalletTransaction.transaction_type == "print_charge"))
  112. ).all()
  113. return archive, item, logs, charges
  114. async def test_a_completed_print(test_engine, db_session, queued_print):
  115. await _complete(test_engine, queued_print["printer"], "completed")
  116. archive, item, logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  117. assert archive.status == "completed"
  118. assert item.status == "completed"
  119. assert [log.status for log in logs] == ["completed"]
  120. assert len(charges) == 1
  121. charge = charges[0]
  122. assert (charge.user_id, charge.cost_center_id, charge.print_queue_id, charge.print_run_id) == (
  123. queued_print["user"],
  124. queued_print["center"],
  125. queued_print["item"],
  126. RUN,
  127. )
  128. assert abs(charge.amount) == pytest.approx(5.0)
  129. async def test_a_failed_print(test_engine, db_session, queued_print):
  130. await _complete(test_engine, queued_print["printer"], "failed")
  131. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  132. assert archive.status == "failed"
  133. assert item.status == "failed"
  134. assert [log.status for log in logs] == ["failed"]
  135. async def test_a_second_completion_charges_nothing_more(test_engine, db_session, queued_print):
  136. """A duplicate completion callback (reconnect replay) is idempotent."""
  137. await _complete(test_engine, queued_print["printer"], "completed")
  138. main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
  139. await _complete(test_engine, queued_print["printer"], "completed")
  140. _archive, _item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  141. assert len(charges) == 1
  142. async def test_a_duplicate_completion_logs_the_run_once(test_engine, db_session, queued_print):
  143. await _complete(test_engine, queued_print["printer"], "completed")
  144. main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
  145. await _complete(test_engine, queued_print["printer"], "completed")
  146. _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  147. assert [(log.status, log.queue_item_id) for log in logs] == [("completed", queued_print["item"])]
  148. async def test_a_completion_takes_its_own_reason(test_engine, db_session, queued_print):
  149. """A reprint that completes carries no reason of an earlier run."""
  150. archive = await db_session.get(PrintArchive, queued_print["archive"])
  151. archive.failure_reason = "spaghetti"
  152. await db_session.commit()
  153. await _complete(test_engine, queued_print["printer"], "completed")
  154. archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  155. assert (archive.status, archive.failure_reason) == ("completed", None)
  156. assert [(log.status, log.failure_reason) for log in logs] == [("completed", None)]
  157. class TestAStopWhileThePrinterIsOffline:
  158. """The stop could not reach the printer, so no completion may ever come (#2603)."""
  159. async def _stop(self, async_client, queued_print):
  160. with (
  161. patch("backend.app.services.print_control.stop_print_by_user", return_value=False),
  162. patch("backend.app.api.routes.print_queue.ws_manager") as ws,
  163. ):
  164. ws.send_archive_updated = AsyncMock()
  165. response = await async_client.post(f"/api/v1/queue/{queued_print['item']}/stop")
  166. assert response.status_code == 200, response.text
  167. return ws
  168. async def test_logs_the_run_as_cancelled_and_tells_the_archives_page(self, async_client, db_session, queued_print):
  169. ws = await self._stop(async_client, queued_print)
  170. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  171. assert (archive.status, archive.failure_reason) == ("cancelled", "userCancelled")
  172. assert item.status == "cancelled"
  173. assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
  174. ("cancelled", "userCancelled", queued_print["item"])
  175. ]
  176. assert ws.send_archive_updated.await_args.args[0] == {"id": queued_print["archive"], "status": "cancelled"}
  177. async def test_a_completion_that_arrives_after_all_replaces_it(
  178. self, async_client, test_engine, db_session, queued_print
  179. ):
  180. """The printer finished the print offline and reports it on reconnect."""
  181. await self._stop(async_client, queued_print)
  182. await _complete(test_engine, queued_print["printer"], "completed")
  183. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  184. assert (archive.status, archive.failure_reason) == ("completed", None)
  185. assert item.status == "cancelled"
  186. assert [(log.status, log.failure_reason, log.queue_item_id) for log in logs] == [
  187. ("completed", None, queued_print["item"])
  188. ]
  189. class TestACompletionNoArchiveWasBoundTo:
  190. """A print Bambuddy did not start is matched to its archive by name (L-W3)."""
  191. async def _printing(self, db_session, printer_id: int, name: str) -> int:
  192. archive = PrintArchive(
  193. printer_id=printer_id,
  194. filename=f"{name}.3mf",
  195. print_name=name,
  196. file_path=f"archives/test/{name}.3mf",
  197. file_size=1,
  198. status="printing",
  199. )
  200. db_session.add(archive)
  201. await db_session.commit()
  202. return archive.id
  203. @pytest.mark.parametrize(
  204. ("printed", "other"),
  205. [
  206. ("Clip", "Cable Clip"), # a name inside another
  207. ("Clip_A", "ClipXA"), # "_" is a LIKE wildcard
  208. ],
  209. )
  210. async def test_another_archive_whose_name_contains_it_is_left_alone(
  211. self, test_engine, db_session, printer_factory, printed, other
  212. ):
  213. printer = await printer_factory(name="P")
  214. other_id = await self._printing(db_session, printer.id, other)
  215. await _complete(test_engine, printer.id, "completed", name=printed)
  216. db_session.expire_all()
  217. assert (await db_session.get(PrintArchive, other_id)).status == "printing"
  218. async def test_its_own_archive_is_completed(self, test_engine, db_session, printer_factory):
  219. printer = await printer_factory(name="P")
  220. other_id = await self._printing(db_session, printer.id, "Cable Clip")
  221. own_id = await self._printing(db_session, printer.id, "Clip")
  222. await _complete(test_engine, printer.id, "completed", name="Clip")
  223. db_session.expire_all()
  224. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  225. assert (await db_session.get(PrintArchive, other_id)).status == "printing"
  226. async def test_the_printers_underscores_for_spaces_still_match(self, test_engine, db_session, printer_factory):
  227. printer = await printer_factory(name="P")
  228. own_id = await self._printing(db_session, printer.id, "Cable Clip")
  229. await _complete(test_engine, printer.id, "completed", name="Cable_Clip")
  230. db_session.expire_all()
  231. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  232. async def test_a_renamed_archive_is_found_by_its_file_name(self, test_engine, db_session, printer_factory):
  233. printer = await printer_factory(name="P")
  234. own_id = await self._printing(db_session, printer.id, "Clip")
  235. own = await db_session.get(PrintArchive, own_id)
  236. own.print_name = "Clip for the desk"
  237. await db_session.commit()
  238. await _complete(test_engine, printer.id, "completed", name="Clip")
  239. db_session.expire_all()
  240. assert (await db_session.get(PrintArchive, own_id)).status == "completed"
  241. async def test_a_print_started_on_the_printer_reports_its_file_name(self, test_engine, db_session, printer_factory):
  242. """The subtask then carries the extensions (#3009)."""
  243. printer = await printer_factory(name="P")
  244. own_id = await self._printing(db_session, printer.id, "Clip")
  245. own = await db_session.get(PrintArchive, own_id)
  246. own.filename = "Clip.gcode.3mf"
  247. await db_session.commit()
  248. await _complete(test_engine, printer.id, "completed", name="Clip.gcode.3mf")
  249. db_session.expire_all()
  250. assert (await db_session.get(PrintArchive, own_id)).status == "completed"