test_vp_delete_cleanup.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  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):
  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. vp_id = 77
  19. upload_dir = tmp_path / "uploads" / str(vp_id)
  20. upload_dir.mkdir(parents=True)
  21. (upload_dir / "stale.3mf").write_bytes(b"orphaned content")
  22. # Build PendingUpload-like mocks. The route mutates `.status`.
  23. pending_a = MagicMock()
  24. pending_a.file_path = str(upload_dir / "stale.3mf")
  25. pending_a.status = "pending"
  26. pending_b = MagicMock()
  27. pending_b.file_path = str(upload_dir / "another.3mf")
  28. pending_b.status = "pending"
  29. # Unrelated PendingUpload that does NOT belong to this VP — must
  30. # be left alone.
  31. other_pending = MagicMock()
  32. other_pending.file_path = str(tmp_path / "uploads" / "99" / "not-mine.3mf")
  33. other_pending.status = "pending"
  34. # Mock VP row.
  35. vp_row = MagicMock()
  36. vp_row.id = vp_id
  37. vp_row.name = "DeleteMe"
  38. # Mock DB session with the route's two .execute() calls + flush + commit.
  39. select_calls = {"i": 0}
  40. async def fake_execute(query): # noqa: ARG001
  41. """Return the VP row on the first call (vp lookup) and the
  42. in-range PendingUpload rows on the second call (orphan query).
  43. Third call is the DELETE which doesn't need a result."""
  44. select_calls["i"] += 1
  45. result = MagicMock()
  46. if select_calls["i"] == 1:
  47. result.scalar_one_or_none = MagicMock(return_value=vp_row)
  48. elif select_calls["i"] == 2:
  49. scalars = MagicMock()
  50. scalars.all = MagicMock(return_value=[pending_a, pending_b])
  51. result.scalars = MagicMock(return_value=scalars)
  52. return result
  53. db = AsyncMock()
  54. db.execute = fake_execute
  55. db.flush = AsyncMock()
  56. db.commit = AsyncMock()
  57. # Mock the manager: remove_instance, _base_dir, sync_from_db.
  58. fake_manager = MagicMock()
  59. fake_manager.remove_instance = AsyncMock()
  60. fake_manager.sync_from_db = AsyncMock()
  61. fake_manager._base_dir = tmp_path
  62. with patch(
  63. "backend.app.services.virtual_printer.virtual_printer_manager",
  64. fake_manager,
  65. ):
  66. await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
  67. # Both in-range PendingUpload rows must be flipped to "discarded".
  68. assert pending_a.status == "discarded"
  69. assert pending_b.status == "discarded"
  70. # The unrelated row was never returned from the query — left alone.
  71. assert other_pending.status == "pending"
  72. # The on-disk upload_dir is gone.
  73. assert not upload_dir.exists()
  74. # The running instance was stopped before the row was removed.
  75. fake_manager.remove_instance.assert_awaited_once_with(vp_id)
  76. @pytest.mark.asyncio
  77. async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
  78. """A VP with no PendingUpload rows and no upload_dir on disk: the
  79. cleanup path must be a clean no-op, not raise."""
  80. vp_id = 88
  81. vp_row = MagicMock()
  82. vp_row.id = vp_id
  83. vp_row.name = "EmptyDelete"
  84. select_calls = {"i": 0}
  85. async def fake_execute(query): # noqa: ARG001
  86. select_calls["i"] += 1
  87. result = MagicMock()
  88. if select_calls["i"] == 1:
  89. result.scalar_one_or_none = MagicMock(return_value=vp_row)
  90. elif select_calls["i"] == 2:
  91. # No PendingUpload rows match.
  92. scalars = MagicMock()
  93. scalars.all = MagicMock(return_value=[])
  94. result.scalars = MagicMock(return_value=scalars)
  95. return result
  96. db = AsyncMock()
  97. db.execute = fake_execute
  98. db.flush = AsyncMock()
  99. db.commit = AsyncMock()
  100. fake_manager = MagicMock()
  101. fake_manager.remove_instance = AsyncMock()
  102. fake_manager.sync_from_db = AsyncMock()
  103. fake_manager._base_dir = tmp_path # no uploads/<vp_id> exists
  104. with patch(
  105. "backend.app.services.virtual_printer.virtual_printer_manager",
  106. fake_manager,
  107. ):
  108. await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
  109. fake_manager.remove_instance.assert_awaited_once_with(vp_id)
  110. # No directory to remove — and we didn't crash trying to.
  111. assert not (tmp_path / "uploads" / str(vp_id)).exists()