test_cleanup_forced_timelapse.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. from backend.app.services.bambu_ftp import DeleteResult
  23. def _fake_session_factory(rows: dict):
  24. """Return an async_session() replacement that yields the given rows.
  25. `rows` is a mapping of model -> object that the test wants returned
  26. from `db.execute(select(...)).scalar_one_or_none()`. The select
  27. target is detected by walking the column descriptions — for these
  28. tests we just look at the model class name.
  29. """
  30. from contextlib import asynccontextmanager
  31. @asynccontextmanager
  32. async def fake_session():
  33. async def execute(stmt):
  34. # The select(...) statement carries the target entity in
  35. # `stmt.column_descriptions[0]["entity"]`. Match by class name.
  36. target_name = stmt.column_descriptions[0]["entity"].__name__
  37. row = rows.get(target_name)
  38. return SimpleNamespace(scalar_one_or_none=lambda: row)
  39. commits: list[None] = []
  40. async def commit():
  41. commits.append(None)
  42. yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
  43. return fake_session
  44. @pytest.fixture(autouse=True)
  45. def patch_app_settings(monkeypatch, tmp_path):
  46. """Point base_dir at a tmp_path so the helper can resolve relative
  47. timelapse paths against a real fs we control."""
  48. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
  49. return tmp_path
  50. @pytest.mark.asyncio
  51. async def test_no_archive_is_noop(monkeypatch):
  52. """Archive deleted between print start and cleanup? Don't crash."""
  53. monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
  54. delete_mock = AsyncMock()
  55. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  56. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  57. delete_mock.assert_not_awaited()
  58. @pytest.mark.asyncio
  59. async def test_not_forced_is_noop(monkeypatch, tmp_path):
  60. """User wanted a timelapse → don't delete anything."""
  61. archive = SimpleNamespace(
  62. bambuddy_forced_timelapse=False,
  63. timelapse_path="archive/1/timelapse.mp4",
  64. )
  65. monkeypatch.setattr(
  66. main_module,
  67. "async_session",
  68. _fake_session_factory({"PrintArchive": archive, "Printer": None}),
  69. )
  70. # Lay down a real file so we'd detect a stray delete.
  71. video_path = tmp_path / archive.timelapse_path
  72. video_path.parent.mkdir(parents=True, exist_ok=True)
  73. video_path.write_bytes(b"x" * 100)
  74. delete_mock = AsyncMock(return_value=DeleteResult.DELETED)
  75. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  76. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  77. delete_mock.assert_not_awaited()
  78. assert video_path.exists()
  79. # archive.timelapse_path is untouched — we still have the user's video
  80. # tracked correctly.
  81. assert archive.timelapse_path == "archive/1/timelapse.mp4"
  82. @pytest.mark.asyncio
  83. async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
  84. """Happy path: forced=True → local file unlinked, DB row cleared, FTP
  85. DELE called against /timelapse/<filename> (the first dir to succeed)."""
  86. archive = SimpleNamespace(
  87. bambuddy_forced_timelapse=True,
  88. timelapse_path="archive/1/myprint.mp4",
  89. )
  90. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  91. monkeypatch.setattr(
  92. main_module,
  93. "async_session",
  94. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  95. )
  96. video_path = tmp_path / archive.timelapse_path
  97. video_path.parent.mkdir(parents=True, exist_ok=True)
  98. video_path.write_bytes(b"x" * 100)
  99. # FTP DELE succeeds on the first directory we try.
  100. delete_mock = AsyncMock(return_value=DeleteResult.DELETED)
  101. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  102. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  103. # Local side: file gone, DB cleared.
  104. assert not video_path.exists()
  105. assert archive.timelapse_path is None
  106. # Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
  107. # first dir the cleanup tries.
  108. delete_mock.assert_awaited()
  109. call = delete_mock.await_args
  110. assert call.args[0] == "10.0.0.5"
  111. assert call.args[1] == "12345678"
  112. assert call.args[2] == "/timelapse/myprint.mp4"
  113. @pytest.mark.asyncio
  114. async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
  115. """If /timelapse/ DELE returns False (file not there), try the other
  116. scanner dirs in order."""
  117. archive = SimpleNamespace(
  118. bambuddy_forced_timelapse=True,
  119. timelapse_path="archive/1/myprint.mp4",
  120. )
  121. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  122. monkeypatch.setattr(
  123. main_module,
  124. "async_session",
  125. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  126. )
  127. video_path = tmp_path / archive.timelapse_path
  128. video_path.parent.mkdir(parents=True, exist_ok=True)
  129. video_path.write_bytes(b"x" * 100)
  130. # First two dirs report NOT_FOUND (file not there), third succeeds.
  131. # Cleanup should stop after the third — and crucially must NOT WARN
  132. # because no real network/auth failure happened (#1721).
  133. delete_mock = AsyncMock(side_effect=[DeleteResult.NOT_FOUND, DeleteResult.NOT_FOUND, DeleteResult.DELETED])
  134. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  135. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  136. assert delete_mock.await_count == 3
  137. paths_tried = [call.args[2] for call in delete_mock.await_args_list]
  138. assert paths_tried == [
  139. "/timelapse/myprint.mp4",
  140. "/timelapse/video/myprint.mp4",
  141. "/record/myprint.mp4",
  142. ]
  143. @pytest.mark.asyncio
  144. async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
  145. """FTP completely failing must not block local cleanup — the user's
  146. archive UI should reflect that the timelapse is gone immediately,
  147. even if the printer-side file lingers."""
  148. archive = SimpleNamespace(
  149. bambuddy_forced_timelapse=True,
  150. timelapse_path="archive/1/myprint.mp4",
  151. )
  152. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  153. monkeypatch.setattr(
  154. main_module,
  155. "async_session",
  156. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  157. )
  158. video_path = tmp_path / archive.timelapse_path
  159. video_path.parent.mkdir(parents=True, exist_ok=True)
  160. video_path.write_bytes(b"x" * 100)
  161. # Every FTP attempt throws.
  162. delete_mock = AsyncMock(side_effect=OSError("connection refused"))
  163. with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
  164. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  165. # Local side cleaned up even though all FTP attempts threw.
  166. assert not video_path.exists()
  167. assert archive.timelapse_path is None
  168. # All four dirs were attempted before giving up.
  169. assert delete_mock.await_count == 4
  170. @pytest.mark.asyncio
  171. async def test_forced_no_warning_when_every_dir_returns_not_found(monkeypatch, tmp_path, caplog):
  172. """#1721: when every candidate dir returns 550 (file not there) the
  173. helper used to emit "Could not delete printer-side timelapse ...
  174. (file may already be gone)" at WARNING. That message landed in support
  175. bundles for healthy printers whose firmware swept the SD card itself.
  176. With DeleteResult.NOT_FOUND signalling, no real failure happened →
  177. must be DEBUG, not WARNING.
  178. """
  179. import logging
  180. archive = SimpleNamespace(
  181. bambuddy_forced_timelapse=True,
  182. timelapse_path="archive/1/myprint.mp4",
  183. )
  184. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="N2S")
  185. monkeypatch.setattr(
  186. main_module,
  187. "async_session",
  188. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  189. )
  190. video_path = tmp_path / archive.timelapse_path
  191. video_path.parent.mkdir(parents=True, exist_ok=True)
  192. video_path.write_bytes(b"x" * 100)
  193. delete_mock = AsyncMock(return_value=DeleteResult.NOT_FOUND)
  194. with (
  195. caplog.at_level(logging.DEBUG, logger="backend.app.main"),
  196. patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock),
  197. ):
  198. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  199. assert delete_mock.await_count == 4
  200. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "[FORCED-TIMELAPSE]" in r.message]
  201. assert warnings == [], f"unexpected WARNING(s): {[w.message for w in warnings]}"
  202. debugs = [
  203. r for r in caplog.records if r.levelno == logging.DEBUG and "No printer-side timelapse to delete" in r.message
  204. ]
  205. assert len(debugs) == 1, "expected the 'nothing to delete' debug summary"
  206. @pytest.mark.asyncio
  207. async def test_forced_warns_when_any_dir_returns_failed(monkeypatch, tmp_path, caplog):
  208. """Counterpart to the above: a real network/auth/transient FAILED on any
  209. dir keeps the WARNING — that's the signal the maintainer actually wants
  210. to see.
  211. """
  212. import logging
  213. archive = SimpleNamespace(
  214. bambuddy_forced_timelapse=True,
  215. timelapse_path="archive/1/myprint.mp4",
  216. )
  217. printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
  218. monkeypatch.setattr(
  219. main_module,
  220. "async_session",
  221. _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
  222. )
  223. video_path = tmp_path / archive.timelapse_path
  224. video_path.parent.mkdir(parents=True, exist_ok=True)
  225. video_path.write_bytes(b"x" * 100)
  226. delete_mock = AsyncMock(
  227. side_effect=[
  228. DeleteResult.NOT_FOUND,
  229. DeleteResult.FAILED,
  230. DeleteResult.NOT_FOUND,
  231. DeleteResult.NOT_FOUND,
  232. ]
  233. )
  234. with (
  235. caplog.at_level(logging.WARNING, logger="backend.app.main"),
  236. patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock),
  237. ):
  238. await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
  239. warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "[FORCED-TIMELAPSE]" in r.message]
  240. assert len(warnings) == 1
  241. assert "network/auth/transient" in warnings[0].message