test_vp_delete_cleanup.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. """Tests for DELETE /virtual-printers/{vp_id} orphan cleanup.
  2. Before the fix, deleting a VP only stopped the running instance and
  3. removed the row. The on-disk ``base_dir/uploads/<vp_id>/`` directory
  4. lingered, and any ``PendingUpload`` rows that pointed into it remained
  5. in ``pending`` status — showing up as phantom entries in
  6. ``/pending-uploads/``. The route now (a) marks those rows as
  7. ``discarded`` and (b) ``shutil.rmtree``s the upload_dir after the DB
  8. commit succeeds.
  9. """
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from backend.app.api.routes.virtual_printers import delete_virtual_printer
  13. from backend.app.core.printer_scope import ALL_PRINTERS
  14. @pytest.mark.asyncio
  15. async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path, db_session):
  16. """A VP with PendingUpload rows pointing at its upload_dir: after
  17. DELETE, those rows must be flipped to ``discarded`` and the on-disk
  18. directory must be gone."""
  19. from sqlalchemy import select
  20. from backend.app.models.pending_upload import PendingUpload
  21. from backend.app.models.virtual_printer import VirtualPrinter
  22. vp = VirtualPrinter(name="DeleteMe", serial_suffix="391800077")
  23. db_session.add(vp)
  24. await db_session.commit()
  25. vp_id = vp.id
  26. upload_dir = tmp_path / "uploads" / str(vp_id)
  27. upload_dir.mkdir(parents=True)
  28. (upload_dir / "stale.3mf").write_bytes(b"orphaned content")
  29. def pending(name, path):
  30. return PendingUpload(filename=name, file_path=str(path), file_size=1, status="pending")
  31. db_session.add_all(
  32. [
  33. pending("a", upload_dir / "stale.3mf"),
  34. pending("b", upload_dir / "another.3mf"),
  35. # Unrelated PendingUpload that does NOT belong to this VP — must be left alone.
  36. pending("other", tmp_path / "uploads" / "99" / "not-mine.3mf"),
  37. ]
  38. )
  39. await db_session.commit()
  40. fake_manager = MagicMock()
  41. fake_manager.remove_instance = AsyncMock()
  42. fake_manager.sync_from_db = AsyncMock()
  43. fake_manager._base_dir = tmp_path
  44. with patch(
  45. "backend.app.services.virtual_printer.virtual_printer_manager",
  46. fake_manager,
  47. ):
  48. await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None, printer_scope=ALL_PRINTERS)
  49. statuses = dict(
  50. (
  51. await db_session.execute(
  52. select(PendingUpload.filename, PendingUpload.status).execution_options(populate_existing=True)
  53. )
  54. ).all()
  55. )
  56. assert statuses == {"a": "discarded", "b": "discarded", "other": "pending"}
  57. assert await db_session.get(VirtualPrinter, vp_id) is None
  58. # The on-disk upload_dir is gone.
  59. assert not upload_dir.exists()
  60. # The running instance was stopped before the row was removed.
  61. fake_manager.remove_instance.assert_awaited_once_with(vp_id)
  62. @pytest.mark.asyncio
  63. async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
  64. """A VP with no PendingUpload rows and no upload_dir on disk: the
  65. cleanup path must be a clean no-op, not raise."""
  66. vp_id = 88
  67. vp_row = MagicMock()
  68. vp_row.id = vp_id
  69. vp_row.name = "EmptyDelete"
  70. select_calls = {"i": 0}
  71. async def fake_execute(query): # noqa: ARG001
  72. select_calls["i"] += 1
  73. result = MagicMock()
  74. if select_calls["i"] == 1:
  75. result.scalar_one_or_none = MagicMock(return_value=vp_row)
  76. elif select_calls["i"] == 2:
  77. # No PendingUpload rows match.
  78. scalars = MagicMock()
  79. scalars.all = MagicMock(return_value=[])
  80. result.scalars = MagicMock(return_value=scalars)
  81. return result
  82. db = AsyncMock()
  83. db.execute = fake_execute
  84. db.flush = AsyncMock()
  85. db.commit = AsyncMock()
  86. fake_manager = MagicMock()
  87. fake_manager.remove_instance = AsyncMock()
  88. fake_manager.sync_from_db = AsyncMock()
  89. fake_manager._base_dir = tmp_path # no uploads/<vp_id> exists
  90. with patch(
  91. "backend.app.services.virtual_printer.virtual_printer_manager",
  92. fake_manager,
  93. ):
  94. await delete_virtual_printer(vp_id=vp_id, db=db, _=None, printer_scope=ALL_PRINTERS)
  95. fake_manager.remove_instance.assert_awaited_once_with(vp_id)
  96. # No directory to remove — and we didn't crash trying to.
  97. assert not (tmp_path / "uploads" / str(vp_id)).exists()
  98. @pytest.mark.asyncio
  99. async def test_deleting_vp_1_leaves_the_uploads_of_vp_12_alone(tmp_path, db_session):
  100. """uploads/1 is a prefix of uploads/12/... as a string, not as a folder."""
  101. from sqlalchemy import select
  102. from backend.app.api.routes.virtual_printers import drop_virtual_printer
  103. from backend.app.models.pending_upload import PendingUpload
  104. fake_manager = MagicMock()
  105. fake_manager._base_dir = tmp_path
  106. db_session.add_all(
  107. [
  108. PendingUpload(filename="mine", file_path=str(tmp_path / "uploads" / "1" / "a.3mf"), file_size=1),
  109. PendingUpload(filename="vp 12", file_path=str(tmp_path / "uploads" / "12" / "b.3mf"), file_size=1),
  110. ]
  111. )
  112. await db_session.commit()
  113. with patch("backend.app.services.virtual_printer.virtual_printer_manager", fake_manager):
  114. await drop_virtual_printer(db_session, 1)
  115. await db_session.commit()
  116. statement = select(PendingUpload.filename, PendingUpload.status).execution_options(populate_existing=True)
  117. assert dict((await db_session.execute(statement)).all()) == {"mine": "discarded", "vp 12": "pending"}