test_printer_delete_leftovers.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Deleting a printer drops what Bambuddy kept for its id (SQLite gives the id
  2. to the next printer created). See unit/test_forget_printer.py for the full list."""
  3. import pytest
  4. from httpx import AsyncClient
  5. from backend.app import main
  6. from backend.app.models.printer import Printer
  7. from backend.app.services.bambu_mqtt import PrinterState
  8. from backend.app.services.printer_manager import printer_manager
  9. pytestmark = [pytest.mark.integration]
  10. async def test_the_next_printer_given_the_id_starts_clean(async_client: AsyncClient, db_session, tmp_path, monkeypatch):
  11. from backend.app.services import plate_detection
  12. monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
  13. printer = Printer(name="A", serial_number="LEFT-A", ip_address="192.0.2.41", access_code="x")
  14. db_session.add(printer)
  15. await db_session.commit()
  16. pid = printer.id
  17. # Mid-print, dispatched for alice, plate calibrated.
  18. printer_manager.set_current_print_user(pid, 1, "alice")
  19. printer_manager._awaiting_plate_clear.add(pid)
  20. main._active_prints[(pid, "cube.3mf")] = 42
  21. (tmp_path / f"printer_{pid}_ref_0.jpg").write_bytes(b"jpg")
  22. (tmp_path / f"printer_{pid}_metadata.json").write_text("{}")
  23. try:
  24. response = await async_client.delete(f"/api/v1/printers/{pid}")
  25. assert response.status_code == 200, response.text
  26. assert printer_manager.get_current_print_user(pid) is None
  27. assert not printer_manager.is_awaiting_plate_clear(pid)
  28. state = PrinterState(subtask_name="cube", gcode_file="cube.3mf")
  29. assert not main._is_bambuddy_authorized_print_in_memory(pid, state) # the kill switch stops it
  30. assert list(tmp_path.iterdir()) == []
  31. finally:
  32. printer_manager._current_print_user.pop(pid, None)
  33. printer_manager._awaiting_plate_clear.discard(pid)
  34. main._active_prints.pop((pid, "cube.3mf"), None)
  35. async def _print_sessions(db, printer_id: int) -> None:
  36. from datetime import datetime
  37. from backend.app.models.active_print_session import ActivePrintSession
  38. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  39. from backend.app.models.archive import PrintArchive
  40. archive = PrintArchive(filename="cube.3mf", file_path="x", file_size=1, printer_id=printer_id)
  41. db.add(archive)
  42. await db.commit()
  43. db.add_all(
  44. [
  45. ActivePrintSession(printer_id=printer_id, print_name="cube", started_at=datetime(2026, 1, 1)),
  46. ActivePrintSpoolman(printer_id=printer_id, archive_id=archive.id, ams_trays={}),
  47. ]
  48. )
  49. await db.commit()
  50. async def _session_rows(db) -> tuple[list, list]:
  51. from sqlalchemy import select
  52. from backend.app.models.active_print_session import ActivePrintSession
  53. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  54. sessions = (await db.execute(select(ActivePrintSession.printer_id))).scalars().all()
  55. spoolman = (await db.execute(select(ActivePrintSpoolman.printer_id))).scalars().all()
  56. return list(sessions), list(spoolman)
  57. async def test_the_print_being_tracked_goes_with_the_printer(async_client: AsyncClient, db_session):
  58. """A new printer given the id and already printing would restore it, and its
  59. filament would be charged to the deleted printer's spools."""
  60. printer = Printer(name="A", serial_number="LEFT-B", ip_address="192.0.2.42", access_code="x")
  61. db_session.add(printer)
  62. await db_session.commit()
  63. await _print_sessions(db_session, printer.id)
  64. response = await async_client.delete(f"/api/v1/printers/{printer.id}", params={"delete_archives": "false"})
  65. assert response.status_code == 200, response.text
  66. assert await _session_rows(db_session) == ([], [])
  67. async def test_the_startup_repair_drops_print_tracking_of_deleted_printers(db_session):
  68. from sqlalchemy import text
  69. from backend.app.core.database import _repair_sqlite_orphans
  70. printer = Printer(name="A", serial_number="LEFT-C", ip_address="192.0.2.43", access_code="x")
  71. db_session.add(printer)
  72. await db_session.commit()
  73. await _print_sessions(db_session, printer.id)
  74. await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": printer.id}) # the old way
  75. await db_session.commit()
  76. await _repair_sqlite_orphans(await db_session.connection())
  77. await db_session.commit()
  78. assert await _session_rows(db_session) == ([], [])
  79. async def test_an_external_camera_stream_of_the_printer_ends(async_client: AsyncClient, db_session): # noqa: ARG001 - patches the session factory
  80. """An MJPEG or snapshot camera runs no ffmpeg the stop could find: a viewer
  81. left open kept writing the old camera's frames under the id, which plate
  82. detection and the finish photo of the next printer given it would read."""
  83. import asyncio
  84. from unittest.mock import MagicMock, patch
  85. from backend.app.api.routes import camera
  86. from backend.app.core.auth import StreamOpener
  87. printer = Printer(
  88. name="A",
  89. serial_number="LEFT-D",
  90. ip_address="192.0.2.44",
  91. access_code="x",
  92. external_camera_enabled=True,
  93. external_camera_url="http://192.0.2.51/mjpeg",
  94. external_camera_type="mjpeg",
  95. )
  96. db_session.add(printer)
  97. await db_session.commit()
  98. async def _frames(*_args, on_frame=None, **_kwargs):
  99. while True:
  100. on_frame(b"\xff\xd8old\xff\xd9")
  101. yield b"--frame\r\n"
  102. await asyncio.sleep(0)
  103. with patch("backend.app.services.external_camera.generate_mjpeg_stream", _frames):
  104. response = await camera.camera_stream(printer.id, MagicMock(), fps=5, opener=StreamOpener(auth_on=False))
  105. stream = response.body_iterator
  106. await anext(stream)
  107. assert camera._last_frames.get(printer.id)
  108. await camera.forget_printer(printer.id)
  109. with pytest.raises(StopAsyncIteration):
  110. await asyncio.wait_for(anext(stream), 2)
  111. assert printer.id not in camera._last_frames
  112. async def test_a_new_printer_starts_without_plate_references(
  113. async_client: AsyncClient, db_session, tmp_path, monkeypatch
  114. ):
  115. """Ones left under its id by a delete before deletes removed them would show
  116. as its calibration and be compared with its plate."""
  117. from unittest.mock import AsyncMock, patch
  118. from backend.app.services import plate_detection
  119. monkeypatch.setattr(plate_detection, "_get_calibration_dir", lambda: tmp_path)
  120. existing = Printer(name="A", serial_number="LEFT-E", ip_address="192.0.2.45", access_code="x")
  121. db_session.add(existing)
  122. await db_session.commit()
  123. reused = existing.id + 1 # SQLite: the highest id plus one
  124. for printer_id in (existing.id, reused):
  125. (tmp_path / f"printer_{printer_id}_ref_0.jpg").write_bytes(b"jpg")
  126. (tmp_path / f"printer_{printer_id}_metadata.json").write_text("{}")
  127. with patch(
  128. "backend.app.api.routes.printers.printer_manager.test_connection",
  129. AsyncMock(return_value={"success": True}),
  130. ):
  131. response = await async_client.post(
  132. "/api/v1/printers/",
  133. json={
  134. "name": "B",
  135. "serial_number": "LEFT-F",
  136. "ip_address": "192.0.2.46",
  137. "access_code": "12345678",
  138. "is_active": False,
  139. },
  140. )
  141. assert response.status_code == 200, response.text
  142. assert response.json()["id"] == reused
  143. assert sorted(p.name for p in tmp_path.iterdir()) == [
  144. f"printer_{existing.id}_metadata.json",
  145. f"printer_{existing.id}_ref_0.jpg",
  146. ]