test_vp_delete_cleanup.py 5.3 KB

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