test_print_complete_end_to_end.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  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 unittest.mock import AsyncMock, patch
  11. import pytest
  12. from sqlalchemy import select
  13. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  14. from backend.app import main as main_module
  15. from backend.app.models.archive import PrintArchive
  16. from backend.app.models.finance import CostCenter, WalletTransaction
  17. from backend.app.models.print_log import PrintLogEntry
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.models.settings import Settings
  20. from backend.app.models.user import User
  21. from backend.app.services.bambu_ftp import DeleteResult
  22. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  23. RUN = "run-end-to-end"
  24. @pytest.fixture(autouse=True)
  25. def _clean():
  26. """The completion marks the printer as awaiting a plate clear on the real
  27. printer manager; the next test on this worker must not inherit that."""
  28. from backend.app.services.printer_manager import printer_manager
  29. main_module._active_prints.clear()
  30. yield
  31. main_module._active_prints.clear()
  32. printer_manager._awaiting_plate_clear.clear()
  33. @pytest.fixture
  34. async def queued_print(db_session, printer_factory):
  35. printer = await printer_factory(name="P")
  36. db_session.add(Settings(key="billing_enabled", value="true"))
  37. db_session.add(Settings(key="capture_finish_photo", value="false"))
  38. user = User(username="payer", password_hash="x", is_active=True)
  39. center = CostCenter(name="Lab", is_active=True, is_private=False)
  40. db_session.add_all([user, center])
  41. await db_session.flush()
  42. archive = PrintArchive(
  43. printer_id=printer.id,
  44. filename="part.3mf",
  45. print_name="part",
  46. file_path="archives/test/part.3mf",
  47. file_size=1,
  48. status="printing",
  49. cost=5.0,
  50. created_by_id=user.id,
  51. billing_run_id=RUN,
  52. )
  53. db_session.add(archive)
  54. await db_session.flush()
  55. item = PrintQueueItem(
  56. printer_id=printer.id,
  57. archive_id=archive.id,
  58. status="printing",
  59. position=1,
  60. created_by_id=user.id,
  61. cost_center_id=center.id,
  62. estimated_cost=5.0,
  63. billing_run_id=RUN,
  64. )
  65. db_session.add(item)
  66. await db_session.commit()
  67. main_module._active_prints[(printer.id, "part.3mf")] = archive.id
  68. return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
  69. async def _complete(test_engine, printer_id: int, status: str) -> None:
  70. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  71. tasks_before = set(asyncio.all_tasks())
  72. with (
  73. patch("backend.app.main.async_session", maker),
  74. patch("backend.app.core.database.async_session", maker),
  75. patch("backend.app.main.ws_manager") as ws,
  76. patch("backend.app.main.mqtt_relay") as relay,
  77. patch("backend.app.main.notification_service") as notif,
  78. patch("backend.app.main.smart_plug_manager") as plug,
  79. patch("backend.app.services.spoolman_tracking.async_session", maker),
  80. # The SD-card cleanup of the uploaded file
  81. patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)),
  82. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
  83. ):
  84. ws.send_print_complete = AsyncMock()
  85. ws.send_archive_updated = AsyncMock()
  86. ws.broadcast = AsyncMock()
  87. relay.on_print_complete = AsyncMock()
  88. notif.on_print_complete = AsyncMock()
  89. plug.on_print_complete = AsyncMock()
  90. await main_module.on_print_complete(
  91. printer_id,
  92. {"status": status, "filename": "part.gcode", "subtask_name": "part", "timelapse_was_active": False},
  93. )
  94. spawned = asyncio.all_tasks() - tasks_before
  95. if spawned:
  96. _done, pending = await asyncio.wait(spawned, timeout=10)
  97. # Left running, they would carry on into the next test unpatched
  98. assert not pending, f"background work still running: {pending}"
  99. async def _rows(db_session, archive_id: int, item_id: int):
  100. db_session.expire_all()
  101. archive = await db_session.get(PrintArchive, archive_id)
  102. item = await db_session.get(PrintQueueItem, item_id)
  103. logs = (await db_session.scalars(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))).all()
  104. charges = (
  105. await db_session.scalars(select(WalletTransaction).where(WalletTransaction.transaction_type == "print_charge"))
  106. ).all()
  107. return archive, item, logs, charges
  108. async def test_a_completed_print(test_engine, db_session, queued_print):
  109. await _complete(test_engine, queued_print["printer"], "completed")
  110. archive, item, logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  111. assert archive.status == "completed"
  112. assert item.status == "completed"
  113. assert [log.status for log in logs] == ["completed"]
  114. assert len(charges) == 1
  115. charge = charges[0]
  116. assert (charge.user_id, charge.cost_center_id, charge.print_queue_id, charge.print_run_id) == (
  117. queued_print["user"],
  118. queued_print["center"],
  119. queued_print["item"],
  120. RUN,
  121. )
  122. assert abs(charge.amount) == pytest.approx(5.0)
  123. async def test_a_failed_print(test_engine, db_session, queued_print):
  124. await _complete(test_engine, queued_print["printer"], "failed")
  125. archive, item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  126. assert archive.status == "failed"
  127. assert item.status == "failed"
  128. assert [log.status for log in logs] == ["failed"]
  129. async def test_a_second_completion_charges_nothing_more(test_engine, db_session, queued_print):
  130. """A duplicate completion callback (reconnect replay) is idempotent."""
  131. await _complete(test_engine, queued_print["printer"], "completed")
  132. main_module._active_prints[(queued_print["printer"], "part.3mf")] = queued_print["archive"]
  133. await _complete(test_engine, queued_print["printer"], "completed")
  134. _archive, _item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
  135. assert len(charges) == 1