test_run_events.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """Each print of an archive is announced as its run (archive redesign P3).
  2. The print and archive events go out before the print's run exists, so they
  3. cannot name it. ``run/started`` and ``run/completed`` do, once it does: the
  4. run, its archive, printer and plate, and at the end its outcome and figures.
  5. Notifications of a print's end carry the archive and run ids too.
  6. """
  7. from __future__ import annotations
  8. from datetime import datetime
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  13. from backend.app import main as main_module
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
  16. from backend.app.services.mqtt_relay import MQTTRelayService
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. T1 = datetime(2026, 10, 31, 9)
  19. @pytest.fixture
  20. def relay():
  21. relay = MagicMock(on_run_started=AsyncMock(), on_run_completed=AsyncMock())
  22. with patch.object(main_module, "mqtt_relay", relay):
  23. yield relay
  24. @pytest.fixture
  25. def sessions(test_engine):
  26. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  27. with patch.object(main_module, "async_session", maker):
  28. yield maker
  29. async def _archive(db_session, printer_id: int) -> int:
  30. archive = PrintArchive(
  31. printer_id=printer_id,
  32. filename="part.3mf",
  33. print_name="part",
  34. file_path="archives/test/part.3mf",
  35. file_size=1,
  36. status="printing",
  37. started_at=T1,
  38. subtask_id="job-1",
  39. )
  40. db_session.add(archive)
  41. await db_session.commit()
  42. return archive.id
  43. async def test_a_print_start_announces_its_run_once(db_session, printer_factory, relay, sessions):
  44. printer = await printer_factory(name="A")
  45. archive_id = await _archive(db_session, printer.id)
  46. await main_module._open_run_for(archive_id, printer.id, "job-1")
  47. # A restart resuming the print finds the run it opened.
  48. await main_module._open_run_for(archive_id, printer.id, "job-1")
  49. relay.on_run_started.assert_awaited_once()
  50. run, archive, serial, name = relay.on_run_started.await_args.args
  51. opened = await db_session.scalar(
  52. select(PrintLogEntry.id)
  53. .where(PrintLogEntry.archive_id == archive_id)
  54. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  55. )
  56. assert (run.id, archive.id, serial, name) == (opened, archive_id, printer.serial_number, "A")
  57. async def test_a_print_end_announces_its_run(db_session, printer_factory, relay, sessions):
  58. printer = await printer_factory(name="A")
  59. archive_id = await _archive(db_session, printer.id)
  60. run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status="failed", started_at=T1)
  61. db_session.add(run)
  62. await db_session.commit()
  63. await main_module._publish_run_completed(run.id, printer.id)
  64. relay.on_run_completed.assert_awaited_once()
  65. published, archive, serial, _ = relay.on_run_completed.await_args.args
  66. assert (published.id, archive.id, serial) == (run.id, archive_id, printer.serial_number)
  67. async def test_no_run_written_announces_nothing(relay, sessions):
  68. await main_module._publish_run_completed(None, 1)
  69. relay.on_run_completed.assert_not_awaited()
  70. def _relay() -> MQTTRelayService:
  71. service = MQTTRelayService()
  72. service.enabled = True
  73. service.connected = True
  74. service.topic_prefix = "bambuddy"
  75. service._publish = MagicMock()
  76. return service
  77. async def test_the_run_payloads():
  78. run = PrintLogEntry(
  79. id=7,
  80. archive_id=3,
  81. printer_id=2,
  82. plate_id=2,
  83. status="completed",
  84. started_at=T1,
  85. completed_at=T1.replace(hour=10),
  86. duration_seconds=3600,
  87. filament_used_grams=12.5,
  88. cost=0.4,
  89. print_name=None,
  90. )
  91. archive = PrintArchive(id=3, print_name="part", filename="part.3mf", file_path="", file_size=1)
  92. service = _relay()
  93. await service.on_run_started(run, archive, "SERIAL", "A")
  94. await service.on_run_completed(run, archive, "SERIAL", "A")
  95. (started_topic, started), (completed_topic, completed) = (c.args for c in service._publish.call_args_list)
  96. assert started_topic == "bambuddy/printers/SERIAL/run/started"
  97. assert {k: started[k] for k in ("run_id", "archive_id", "printer_id", "plate_id", "print_name")} == {
  98. "run_id": 7,
  99. "archive_id": 3,
  100. "printer_id": 2,
  101. "plate_id": 2,
  102. "print_name": "part",
  103. }
  104. assert completed_topic == "bambuddy/printers/SERIAL/run/completed"
  105. assert (completed["status"], completed["duration_seconds"], completed["filament_used_grams"]) == (
  106. "completed",
  107. 3600,
  108. 12.5,
  109. )
  110. async def test_a_print_end_notification_names_its_archive_and_run(db_session):
  111. from backend.app.services.notification_service import NotificationService
  112. service = NotificationService()
  113. provider = MagicMock(name="provider")
  114. with (
  115. patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[provider])),
  116. patch.object(service, "_build_message_from_template", AsyncMock(return_value=("t", "m"))),
  117. patch.object(service, "_send_to_providers", AsyncMock()) as send,
  118. ):
  119. await service.on_print_complete(
  120. 1, "A", "completed", {"filename": "part.3mf"}, db_session, archive_data={"archive_id": 3, "run_id": 7}
  121. )
  122. variables = send.await_args.kwargs["variables"]
  123. assert (variables["archive_id"], variables["run_id"]) == ("3", "7")
  124. async def test_a_run_replaced_by_the_next_start_is_announced_gone(db_session, printer_factory, relay, sessions):
  125. """Its print ended unseen; a new print opened the printer's next run."""
  126. relay.on_run_discarded = AsyncMock()
  127. printer = await printer_factory(name="A")
  128. archive_id = await _archive(db_session, printer.id)
  129. await main_module._open_run_for(archive_id, printer.id, "job-1")
  130. first = await db_session.scalar(
  131. select(PrintLogEntry.id)
  132. .where(PrintLogEntry.archive_id == archive_id)
  133. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  134. )
  135. archive = await db_session.get(PrintArchive, archive_id)
  136. archive.started_at, archive.subtask_id = T1.replace(hour=11), "job-2"
  137. await db_session.commit()
  138. await main_module._open_run_for(archive_id, printer.id, "job-2")
  139. relay.on_run_discarded.assert_awaited_once()
  140. assert relay.on_run_discarded.await_args.args[0].id == first
  141. assert relay.on_run_started.await_count == 2
  142. async def test_discarding_announces_each_run(db_session, printer_factory, relay, sessions):
  143. from backend.app.models.print_log import RUN_OPEN
  144. from backend.app.services.print_log import discard_open_runs
  145. relay.on_run_discarded = AsyncMock()
  146. printer = await printer_factory(name="A")
  147. archive_id = await _archive(db_session, printer.id)
  148. run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
  149. db_session.add(run)
  150. await db_session.commit()
  151. run_id = run.id
  152. discarded = await discard_open_runs(db_session, printer.id)
  153. await db_session.commit()
  154. await main_module._announce_discarded_runs(discarded, printer.id)
  155. assert [r.id for r in discarded] == [run_id]
  156. relay.on_run_discarded.assert_awaited_once()
  157. assert relay.on_run_discarded.await_args.args[1] == printer.serial_number
  158. async def test_times_are_utc_and_an_unmeasured_duration_says_so():
  159. run = PrintLogEntry(
  160. id=7,
  161. archive_id=3,
  162. printer_id=2,
  163. status="completed",
  164. started_at=T1,
  165. completed_at=T1.replace(hour=10),
  166. duration_seconds=0,
  167. duration_unmeasured=True,
  168. )
  169. service = _relay()
  170. await service.on_run_completed(run, None, "SERIAL", "A")
  171. payload = service._publish.call_args.args[1]
  172. assert (payload["started_at"], payload["completed_at"]) == (
  173. "2026-10-31T09:00:00+00:00",
  174. "2026-10-31T10:00:00+00:00",
  175. )
  176. assert payload["duration_unmeasured"] is True
  177. async def test_the_kill_switch_notification_names_the_print(db_session, printer_factory, sessions):
  178. from backend.app.models.print_log import RUN_OPEN
  179. printer = await printer_factory(name="A")
  180. archive_id = await _archive(db_session, printer.id)
  181. run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
  182. db_session.add(run)
  183. await db_session.commit()
  184. run_id = run.id
  185. with patch.object(main_module, "notification_service") as notify:
  186. notify.on_print_complete = AsyncMock()
  187. assert await main_module._send_kill_switch_provider_notification(printer.id, "A", {"filename": "part.3mf"})
  188. assert notify.on_print_complete.await_args.kwargs["archive_data"] == {"archive_id": archive_id, "run_id": run_id}
  189. async def test_a_stop_while_the_printer_is_offline_announces_the_run_end(
  190. async_client, db_session, printer_factory, relay
  191. ):
  192. from backend.app.models.print_log import RUN_OPEN
  193. from backend.app.models.print_queue import PrintQueueItem
  194. printer = await printer_factory(name="A")
  195. archive_id = await _archive(db_session, printer.id)
  196. item = PrintQueueItem(printer_id=printer.id, archive_id=archive_id, status="printing", position=1)
  197. db_session.add(item)
  198. run = PrintLogEntry(archive_id=archive_id, printer_id=printer.id, status=RUN_OPEN, started_at=T1)
  199. db_session.add(run)
  200. await db_session.commit()
  201. run_id, item_id = run.id, item.id
  202. with (
  203. patch("backend.app.services.print_control.stop_print_by_user", return_value=False),
  204. patch("backend.app.api.routes.print_queue.ws_manager") as ws,
  205. ):
  206. ws.send_archive_updated = AsyncMock()
  207. response = await async_client.post(f"/api/v1/queue/{item_id}/stop")
  208. assert response.status_code == 200, response.text
  209. relay.on_run_completed.assert_awaited_once()
  210. assert relay.on_run_completed.await_args.args[0].id == run_id