test_cleanup_forced_timelapse.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Tests for _cleanup_forced_timelapse (#1397).
  2. When Bambuddy forced timelapse on for the finish-photo path, this helper
  3. runs after the extractor (success OR failure — we never leave debris).
  4. It deletes:
  5. - the locally-attached file (clears archive.timelapse_path)
  6. - the printer-side file via FTP DELE, walking the four scanner dirs
  7. These tests pin the four branches:
  8. 1. archive doesn't exist → no-op
  9. 2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
  10. the timelapse)
  11. 3. archive exists, forced=True, local file present → delete local + DB
  12. update + FTP DELE on the first directory that succeeds
  13. 4. archive exists, forced=True, but FTP DELE fails on every dir → local
  14. side still cleaned up; warn log emitted (best-effort)
  15. """
  16. from pathlib import Path
  17. from types import SimpleNamespace
  18. from unittest.mock import AsyncMock, patch
  19. import pytest
  20. from backend.app import main as main_module
  21. from backend.app.main import _cleanup_forced_timelapse
  22. def _fake_session_factory(rows: dict):
  23. """Return an async_session() replacement that yields the given rows.
  24. `rows` is a mapping of model -> object that the test wants returned
  25. from `db.execute(select(...)).scalar_one_or_none()`. The select
  26. target is detected by walking the column descriptions — for these
  27. tests we just look at the model class name.
  28. """
  29. from contextlib import asynccontextmanager
  30. @asynccontextmanager
  31. async def fake_session():
  32. async def execute(stmt):
  33. # The select(...) statement carries the target entity in
  34. # `stmt.column_descriptions[0]["entity"]`. Match by class name.
  35. target_name = stmt.column_descriptions[0]["entity"].__name__
  36. row = rows.get(target_name)
  37. return SimpleNamespace(scalar_one_or_none=lambda: row)
  38. commits: list[None] = []
  39. async def commit():
  40. commits.append(None)
  41. yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
  42. return fake_session
  43. @pytest.fixture(autouse=True)
  44. def patch_app_settings(monkeypatch, tmp_path):
  45. """Point base_dir at a tmp_path so the helper can resolve relative
  46. timelapse paths against a real fs we control."""
  47. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  48. return tmp_path
  49. @pytest.mark.asyncio
  50. async def test_no_archive_is_noop(monkeypatch):
  51. """Archive deleted between print start and cleanup? Don't crash."""
  52. monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
  53. delete_mock = AsyncMock()
  54. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  55. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  56. delete_mock.assert_not_awaited()
  57. @pytest.mark.asyncio
  58. async def test_not_forced_is_noop(monkeypatch, tmp_path):
  59. """User wanted a timelapse → don't delete anything."""
  60. archive = SimpleNamespace(
  61. bambuddy_forced_timelapse=False,
  62. timelapse_path="archive/1/timelapse.mp4",
  63. )
  64. monkeypatch.setattr(
  65. main_module,
  66. "async_session",
  67. _fake_session_factory({"PrintArchive": archive, "Printer": None}),
  68. )
  69. # Lay down a real file so we'd detect a stray delete.
  70. video_path = tmp_path / archive.timelapse_path
  71. video_path.parent.mkdir(parents=True, exist_ok=True)
  72. video_path.write_bytes(b"x" * 100)
  73. delete_mock = AsyncMock(return_value=True)
  74. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  75. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  76. delete_mock.assert_not_awaited()
  77. assert video_path.exists()
  78. # archive.timelapse_path is untouched — we still have the user's video
  79. # tracked correctly.
  80. assert archive.timelapse_path == "archive/1/timelapse.mp4"
  81. @pytest.mark.asyncio
  82. async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
  83. """Happy path: forced=True → local file unlinked, DB row cleared, FTP
  84. DELE called against /timelapse/<filename> (the first dir to succeed)."""
  85. archive = SimpleNamespace(
  86. bambuddy_forced_timelapse=True,
  87. timelapse_path="archive/1/myprint.mp4",
  88. )
  89. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  90. monkeypatch.setattr(
  91. main_module,
  92. "async_session",
  93. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  94. )
  95. video_path = tmp_path / archive.timelapse_path
  96. video_path.parent.mkdir(parents=True, exist_ok=True)
  97. video_path.write_bytes(b"x" * 100)
  98. # FTP DELE succeeds on the first directory we try.
  99. delete_mock = AsyncMock(return_value=True)
  100. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  101. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  102. # Local side: file gone, DB cleared.
  103. assert not video_path.exists()
  104. assert archive.timelapse_path is None
  105. # Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
  106. # first dir the cleanup tries.
  107. delete_mock.assert_awaited()
  108. call = delete_mock.await_args
  109. assert call.args[0] == "10.0.0.5"
  110. assert call.args[1] == "12345678"
  111. assert call.args[2] == "/timelapse/myprint.mp4"
  112. @pytest.mark.asyncio
  113. async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
  114. """If /timelapse/ DELE returns False (file not there), try the other
  115. scanner dirs in order."""
  116. archive = SimpleNamespace(
  117. bambuddy_forced_timelapse=True,
  118. timelapse_path="archive/1/myprint.mp4",
  119. )
  120. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  121. monkeypatch.setattr(
  122. main_module,
  123. "async_session",
  124. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  125. )
  126. video_path = tmp_path / archive.timelapse_path
  127. video_path.parent.mkdir(parents=True, exist_ok=True)
  128. video_path.write_bytes(b"x" * 100)
  129. # First two attempts fail (False), third succeeds (True). Cleanup
  130. # should stop after the third.
  131. delete_mock = AsyncMock(side_effect=[False, False, True])
  132. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  133. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  134. assert delete_mock.await_count == 3
  135. paths_tried = [call.args[2] for call in delete_mock.await_args_list]
  136. assert paths_tried == [
  137. "/timelapse/myprint.mp4",
  138. "/timelapse/video/myprint.mp4",
  139. "/record/myprint.mp4",
  140. ]
  141. @pytest.mark.asyncio
  142. async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
  143. """FTP completely failing must not block local cleanup — the user's
  144. archive UI should reflect that the timelapse is gone immediately,
  145. even if the printer-side file lingers."""
  146. archive = SimpleNamespace(
  147. bambuddy_forced_timelapse=True,
  148. timelapse_path="archive/1/myprint.mp4",
  149. )
  150. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  151. monkeypatch.setattr(
  152. main_module,
  153. "async_session",
  154. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  155. )
  156. video_path = tmp_path / archive.timelapse_path
  157. video_path.parent.mkdir(parents=True, exist_ok=True)
  158. video_path.write_bytes(b"x" * 100)
  159. # Every FTP attempt throws.
  160. delete_mock = AsyncMock(side_effect=OSError("connection refused"))
  161. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  162. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  163. # Local side cleaned up even though all FTP attempts threw.
  164. assert not video_path.exists()
  165. assert archive.timelapse_path is None
  166. # All four dirs were attempted before giving up.
  167. assert delete_mock.await_count == 4