test_scheduler_cleanup_library.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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=True,
  70. flow_cali=False,
  71. vibration_cali=True,
  72. layer_inspect=False,
  73. timelapse=False,
  74. use_ams=True,
  75. nozzle_offset_cali=True,
  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(self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None):
  98. if archive_failure:
  99. raise RuntimeError("archive copy failed")
  100. archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
  101. ctx.archive_path = ctx.base_dir / archive_rel_path
  102. ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
  103. ctx.archive_path.write_bytes(Path(source_file).read_bytes())
  104. archive = PrintArchive(
  105. printer_id=printer_id,
  106. filename=original_filename,
  107. file_path=str(archive_rel_path),
  108. file_size=ctx.archive_path.stat().st_size,
  109. content_hash=None,
  110. thumbnail_path=None,
  111. timelapse_path=None,
  112. print_time_seconds=120,
  113. status="completed",
  114. project_id=project_id,
  115. created_by_id=created_by_id,
  116. )
  117. self.db.add(archive)
  118. await self.db.flush()
  119. return archive
  120. patches = [
  121. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  122. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  123. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  124. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  125. patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
  126. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  127. patch(
  128. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  129. ),
  130. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  131. patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
  132. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  133. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  134. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  135. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  136. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  137. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  138. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  139. ]
  140. if unlink_side_effect:
  141. patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
  142. with ExitStack() as stack:
  143. for patcher in patches:
  144. stack.enter_context(patcher)
  145. async with ctx.session_maker() as db:
  146. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  147. await scheduler._start_print(db, item)
  148. async def _queue_snapshot(ctx):
  149. async with ctx.session_maker() as db:
  150. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  151. library_file = await db.get(LibraryFile, ctx.library_file_id)
  152. archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
  153. return item, library_file, archive
  154. @pytest.mark.asyncio
  155. async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
  156. ctx = await queue_factory(cleanup=True)
  157. await _dispatch_library_item(ctx)
  158. item, library_file, archive = await _queue_snapshot(ctx)
  159. assert item.status == "printing"
  160. assert item.library_file_id is None
  161. assert item.archive_id == archive.id
  162. assert library_file is None
  163. assert not ctx.source_path.exists()
  164. @pytest.mark.asyncio
  165. async def test_external_library_file_skips_cleanup(queue_factory):
  166. ctx = await queue_factory(cleanup=True, is_external=True)
  167. await _dispatch_library_item(ctx)
  168. item, library_file, archive = await _queue_snapshot(ctx)
  169. assert item.status == "printing"
  170. assert item.library_file_id == ctx.library_file_id
  171. assert item.archive_id == archive.id
  172. assert library_file is not None
  173. assert ctx.source_path.exists()
  174. @pytest.mark.asyncio
  175. async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
  176. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  177. await _dispatch_library_item(ctx, archive_failure=True)
  178. item, library_file, archive = await _queue_snapshot(ctx)
  179. assert item.status == "failed"
  180. assert item.error_message == "Failed to create archive from library file"
  181. assert item.archive_id is None
  182. assert archive is None
  183. assert library_file is not None
  184. assert ctx.source_path.exists()
  185. assert ctx.thumbnail_path.exists()
  186. ctx.upload.assert_not_awaited()
  187. ctx.start_print.assert_not_called()
  188. @pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
  189. @pytest.mark.asyncio
  190. async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
  191. ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
  192. await _dispatch_library_item(ctx)
  193. item, library_file, archive = await _queue_snapshot(ctx)
  194. assert item.status == "printing"
  195. assert item.archive_id == archive.id
  196. assert library_file is None
  197. assert not ctx.source_path.exists()
  198. assert not ctx.thumbnail_path.exists()
  199. @pytest.mark.asyncio
  200. async def test_archive_copy_survives_library_cleanup(queue_factory):
  201. ctx = await queue_factory(cleanup=True)
  202. await _dispatch_library_item(ctx)
  203. assert not ctx.source_path.exists()
  204. assert ctx.archive_path.exists()
  205. assert ctx.archive_path.read_bytes() == b"library source"
  206. uploaded_path = ctx.upload.await_args.args[2]
  207. assert uploaded_path == ctx.archive_path
  208. @pytest.mark.asyncio
  209. async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
  210. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  211. original_unlink = type(ctx.source_path).unlink
  212. def unlink_with_source_failure(path, *args, **kwargs):
  213. if Path(path) == ctx.source_path:
  214. raise OSError("permission denied")
  215. return original_unlink(path, *args, **kwargs)
  216. with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
  217. await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
  218. item, library_file, archive = await _queue_snapshot(ctx)
  219. assert item.status == "printing"
  220. assert item.archive_id == archive.id
  221. assert item.library_file_id is None
  222. assert library_file is None
  223. assert ctx.source_path.exists()
  224. assert not ctx.thumbnail_path.exists()
  225. assert ctx.archive_path.exists()
  226. assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
  227. assert str(ctx.source_path) in caplog.text
  228. assert "permission denied" in caplog.text