test_scheduler_cleanup_library.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. from contextlib import ExitStack
  2. from pathlib import Path
  3. from types import SimpleNamespace
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  7. import backend.app.models # noqa: F401 - populate Base.metadata
  8. import backend.app.services.print_scheduler as scheduler_module
  9. from backend.app.core.database import Base
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.library import LibraryFile
  12. from backend.app.models.print_queue import PrintQueueItem
  13. from backend.app.models.printer import Printer
  14. from backend.app.services.print_scheduler import PrintScheduler
  15. @pytest.fixture
  16. async def queue_factory(tmp_path):
  17. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  18. async with engine.begin() as conn:
  19. await conn.run_sync(Base.metadata.create_all)
  20. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  21. case_counter = 0
  22. async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None):
  23. nonlocal case_counter
  24. case_counter += 1
  25. base_dir = tmp_path / f"case-{case_counter}"
  26. base_dir.mkdir()
  27. source_path = base_dir / "library" / f"source-{case_counter}.3mf"
  28. source_path.parent.mkdir()
  29. source_path.write_bytes(b"library source")
  30. thumbnail_actual_path = None
  31. thumbnail_db_path = None
  32. if thumbnail_path == "relative":
  33. thumbnail_db_path = f"thumbs/preview-{case_counter}.png"
  34. thumbnail_actual_path = base_dir / thumbnail_db_path
  35. elif thumbnail_path == "absolute":
  36. thumbnail_actual_path = tmp_path / f"absolute-preview-{case_counter}.png"
  37. thumbnail_db_path = str(thumbnail_actual_path)
  38. elif thumbnail_path is not None:
  39. thumbnail_actual_path = Path(thumbnail_path)
  40. thumbnail_db_path = str(thumbnail_path)
  41. if thumbnail_actual_path:
  42. thumbnail_actual_path.parent.mkdir(parents=True, exist_ok=True)
  43. thumbnail_actual_path.write_bytes(b"thumbnail")
  44. async with session_maker() as db:
  45. printer = Printer(
  46. name=f"Printer {case_counter}",
  47. serial_number=f"SERIAL-{case_counter}",
  48. ip_address="127.0.0.1",
  49. access_code="access-code",
  50. model="X1C",
  51. )
  52. library_file = LibraryFile(
  53. filename=f"source-{case_counter}.3mf",
  54. file_path=str(source_path),
  55. file_type="3mf",
  56. file_size=source_path.stat().st_size,
  57. file_hash=None,
  58. thumbnail_path=thumbnail_db_path,
  59. file_metadata=None,
  60. is_external=is_external,
  61. )
  62. db.add_all([printer, library_file])
  63. await db.flush()
  64. item = PrintQueueItem(
  65. printer_id=printer.id,
  66. library_file_id=library_file.id,
  67. status="pending",
  68. cleanup_library_after_dispatch=cleanup,
  69. bed_levelling="on",
  70. flow_cali="off",
  71. vibration_cali=True,
  72. layer_inspect=False,
  73. timelapse=False,
  74. use_ams=True,
  75. nozzle_offset_cali="on",
  76. )
  77. db.add(item)
  78. await db.commit()
  79. return SimpleNamespace(
  80. session_maker=session_maker,
  81. base_dir=base_dir,
  82. source_path=source_path,
  83. thumbnail_path=thumbnail_actual_path,
  84. printer_id=printer.id,
  85. library_file_id=library_file.id,
  86. queue_item_id=item.id,
  87. archive_path=None,
  88. upload=AsyncMock(return_value=True),
  89. start_print=MagicMock(return_value=True),
  90. )
  91. try:
  92. yield make_case
  93. finally:
  94. await engine.dispose()
  95. async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
  96. scheduler = PrintScheduler()
  97. async def archive_print(
  98. self,
  99. *,
  100. printer_id,
  101. source_file,
  102. original_filename,
  103. created_by_id=None,
  104. project_id=None,
  105. plate_id=None,
  106. library_file_id=None,
  107. ):
  108. if archive_failure:
  109. raise RuntimeError("archive copy failed")
  110. archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
  111. ctx.archive_path = ctx.base_dir / archive_rel_path
  112. ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
  113. ctx.archive_path.write_bytes(Path(source_file).read_bytes())
  114. archive = PrintArchive(
  115. printer_id=printer_id,
  116. filename=original_filename,
  117. file_path=str(archive_rel_path),
  118. file_size=ctx.archive_path.stat().st_size,
  119. content_hash=None,
  120. thumbnail_path=None,
  121. timelapse_path=None,
  122. print_time_seconds=120,
  123. status="completed",
  124. project_id=project_id,
  125. library_file_id=library_file_id,
  126. created_by_id=created_by_id,
  127. )
  128. self.db.add(archive)
  129. await self.db.flush()
  130. return archive
  131. patches = [
  132. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  133. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  134. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  135. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  136. patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
  137. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  138. patch(
  139. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  140. ),
  141. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  142. patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
  143. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  144. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  145. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  146. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  147. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  148. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  149. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  150. ]
  151. if unlink_side_effect:
  152. patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
  153. with ExitStack() as stack:
  154. for patcher in patches:
  155. stack.enter_context(patcher)
  156. async with ctx.session_maker() as db:
  157. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  158. await scheduler._start_print(db, item)
  159. async def _queue_snapshot(ctx):
  160. async with ctx.session_maker() as db:
  161. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  162. library_file = await db.get(LibraryFile, ctx.library_file_id)
  163. archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
  164. return item, library_file, archive
  165. @pytest.mark.asyncio
  166. async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
  167. ctx = await queue_factory(cleanup=True)
  168. await _dispatch_library_item(ctx)
  169. item, library_file, archive = await _queue_snapshot(ctx)
  170. assert item.status == "printing"
  171. assert item.library_file_id is None
  172. assert item.archive_id == archive.id
  173. assert library_file is None
  174. assert not ctx.source_path.exists()
  175. @pytest.mark.asyncio
  176. async def test_external_library_file_skips_cleanup(queue_factory):
  177. ctx = await queue_factory(cleanup=True, is_external=True)
  178. await _dispatch_library_item(ctx)
  179. item, library_file, archive = await _queue_snapshot(ctx)
  180. assert item.status == "printing"
  181. assert item.library_file_id == ctx.library_file_id
  182. assert item.archive_id == archive.id
  183. assert library_file is not None
  184. assert ctx.source_path.exists()
  185. @pytest.mark.asyncio
  186. async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
  187. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  188. await _dispatch_library_item(ctx, archive_failure=True)
  189. item, library_file, archive = await _queue_snapshot(ctx)
  190. assert item.status == "failed"
  191. assert item.error_message == "Failed to create archive from library file"
  192. assert item.archive_id is None
  193. assert archive is None
  194. assert library_file is not None
  195. assert ctx.source_path.exists()
  196. assert ctx.thumbnail_path.exists()
  197. ctx.upload.assert_not_awaited()
  198. ctx.start_print.assert_not_called()
  199. @pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
  200. @pytest.mark.asyncio
  201. async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
  202. ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
  203. await _dispatch_library_item(ctx)
  204. item, library_file, archive = await _queue_snapshot(ctx)
  205. assert item.status == "printing"
  206. assert item.archive_id == archive.id
  207. assert library_file is None
  208. assert not ctx.source_path.exists()
  209. assert not ctx.thumbnail_path.exists()
  210. @pytest.mark.asyncio
  211. async def test_archive_copy_survives_library_cleanup(queue_factory):
  212. ctx = await queue_factory(cleanup=True)
  213. await _dispatch_library_item(ctx)
  214. assert not ctx.source_path.exists()
  215. assert ctx.archive_path.exists()
  216. assert ctx.archive_path.read_bytes() == b"library source"
  217. uploaded_path = ctx.upload.await_args.args[2]
  218. assert uploaded_path == ctx.archive_path
  219. @pytest.mark.asyncio
  220. async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
  221. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  222. original_unlink = type(ctx.source_path).unlink
  223. def unlink_with_source_failure(path, *args, **kwargs):
  224. if Path(path) == ctx.source_path:
  225. raise OSError("permission denied")
  226. return original_unlink(path, *args, **kwargs)
  227. with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
  228. await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
  229. item, library_file, archive = await _queue_snapshot(ctx)
  230. assert item.status == "printing"
  231. assert item.archive_id == archive.id
  232. assert item.library_file_id is None
  233. assert library_file is None
  234. assert ctx.source_path.exists()
  235. assert not ctx.thumbnail_path.exists()
  236. assert ctx.archive_path.exists()
  237. assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
  238. assert str(ctx.source_path) in caplog.text
  239. assert "permission denied" in caplog.text