test_timelapse_scan_2704.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. """Timelapse scan reliability (#2704).
  2. A Bambu printer in LAN-only mode never reaches Bambu's NTP server, so the clock
  3. behind both the timelapse filename and the FTP mtime drifts freely — the P1S in
  4. the report was six and a half days out. That is why the automatic scan works by
  5. diffing the printer's ``/timelapse`` listing against a snapshot taken when the
  6. print started, and why nothing in that path may fall back to comparing times.
  7. These tests pin the parts that make the diff dependable:
  8. * the candidate is chosen by exclusion, never by ordering (ordering could only
  9. be done on the printer's clock);
  10. * a download that comes up short never attaches and never triggers a delete —
  11. deleting the printer's copy is only safe because the transfer was verified;
  12. * the baseline persisted at print start is what the manual Scan button uses,
  13. instead of the clock-based strategies that cannot work on a drifted printer.
  14. """
  15. import logging
  16. from pathlib import Path
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from backend.app.core.printer_scope import ALL_PRINTERS
  20. from backend.app.services.bambu_ftp import FileListResult
  21. logger = logging.getLogger(__name__)
  22. def _printer():
  23. p = MagicMock()
  24. p.id = 1
  25. p.name = "TestP1S"
  26. p.ip_address = "192.168.1.100"
  27. p.access_code = "12345678"
  28. p.model = "P1S"
  29. return p
  30. def _video(name: str, size: int = 1000):
  31. return {"name": name, "is_directory": False, "path": f"/timelapse/{name}", "size": size}
  32. def _session(archive=None):
  33. session = AsyncMock()
  34. session.__aenter__ = AsyncMock(return_value=session)
  35. session.__aexit__ = AsyncMock()
  36. if archive is not None:
  37. session.get = AsyncMock(return_value=archive)
  38. return session
  39. class TestCandidateSelection:
  40. """Which of the printer's videos belongs to this print."""
  41. @pytest.fixture
  42. def attach(self):
  43. from backend.app.main import _attach_first_unclaimed_timelapse
  44. return _attach_first_unclaimed_timelapse
  45. @pytest.mark.asyncio
  46. async def test_nothing_new_since_baseline_is_not_an_attach(self, attach):
  47. result = await attach(
  48. 42,
  49. _printer(),
  50. [_video("video_2026-07-21_09-17-37.avi")],
  51. {"video_2026-07-21_09-17-37.avi"},
  52. set(),
  53. 1,
  54. logger,
  55. )
  56. assert result is False
  57. @pytest.mark.asyncio
  58. async def test_attaches_the_one_new_file_and_deletes_it_from_the_printer(self, attach):
  59. download = AsyncMock(return_value=b"x" * 1000)
  60. delete = AsyncMock(return_value=True)
  61. service = MagicMock()
  62. service.attach_timelapse = AsyncMock(return_value=True)
  63. with (
  64. patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
  65. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  66. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
  67. patch("backend.app.main.async_session", return_value=_session()),
  68. patch("backend.app.main.ArchiveService", return_value=service),
  69. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  70. ):
  71. result = await attach(
  72. 42,
  73. _printer(),
  74. [_video("old.avi"), _video("video_2026-07-22_06-18-39.avi")],
  75. {"old.avi"},
  76. set(),
  77. 1,
  78. logger,
  79. )
  80. assert result is True
  81. service.attach_timelapse.assert_awaited_once()
  82. assert service.attach_timelapse.await_args.args[2] == "video_2026-07-22_06-18-39.avi"
  83. delete.assert_awaited_once()
  84. assert delete.await_args.args[2] == "/timelapse/video_2026-07-22_06-18-39.avi"
  85. @pytest.mark.asyncio
  86. async def test_skips_a_previous_prints_late_landing_video(self, attach):
  87. """Two files are new since the baseline because the previous print's
  88. video only landed after this print started. It is already attached to
  89. another archive, so it is excluded by name — no timestamps involved."""
  90. download = AsyncMock(return_value=b"y" * 1000)
  91. service = MagicMock()
  92. service.attach_timelapse = AsyncMock(return_value=True)
  93. with (
  94. patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
  95. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  96. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
  97. patch("backend.app.main.async_session", return_value=_session()),
  98. patch("backend.app.main.ArchiveService", return_value=service),
  99. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  100. ):
  101. result = await attach(
  102. 42,
  103. _printer(),
  104. # Listing order puts the previous print's video first, so a
  105. # naive "take the first new one" would grab the wrong video.
  106. [_video("previous_print.avi"), _video("this_print.avi")],
  107. set(),
  108. {"previous_print"},
  109. 1,
  110. logger,
  111. )
  112. assert result is True
  113. assert service.attach_timelapse.await_args.args[2] == "this_print.avi"
  114. @pytest.mark.asyncio
  115. async def test_claimed_match_survives_the_mp4_conversion(self, attach):
  116. """Attached AVIs are converted to MP4 afterwards, which keeps the stem
  117. but changes the extension — so exclusion has to compare stems."""
  118. result = await attach(
  119. 42,
  120. _printer(),
  121. [_video("video_2026-07-22_06-18-39.avi")],
  122. set(),
  123. {"video_2026-07-22_06-18-39"}, # stored as .mp4 on the archive
  124. 1,
  125. logger,
  126. )
  127. assert result is False
  128. @pytest.mark.asyncio
  129. async def test_all_new_files_claimed_keeps_polling(self, attach):
  130. result = await attach(42, _printer(), [_video("a.avi"), _video("b.avi")], set(), {"a", "b"}, 1, logger)
  131. assert result is False
  132. class TestDownloadVerificationGatesTheDelete:
  133. """The printer's copy is the only other copy — it goes only after the
  134. transfer is verified against the size the listing reported."""
  135. @pytest.fixture
  136. def attach(self):
  137. from backend.app.main import _attach_first_unclaimed_timelapse
  138. return _attach_first_unclaimed_timelapse
  139. @pytest.mark.asyncio
  140. async def test_passes_the_listed_size_to_the_downloader(self, attach):
  141. download = AsyncMock(return_value=b"z" * 4096)
  142. service = MagicMock()
  143. service.attach_timelapse = AsyncMock(return_value=True)
  144. with (
  145. patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
  146. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  147. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
  148. patch("backend.app.main.async_session", return_value=_session()),
  149. patch("backend.app.main.ArchiveService", return_value=service),
  150. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  151. ):
  152. await attach(42, _printer(), [_video("new.avi", size=4096)], set(), set(), 1, logger)
  153. assert download.await_args.kwargs["expected_size"] == 4096
  154. @pytest.mark.asyncio
  155. async def test_short_download_does_not_attach_or_delete(self, attach):
  156. """download_file_bytes_async returns None on a size mismatch. The
  157. printer must keep its copy so the next poll round can retry."""
  158. delete = AsyncMock()
  159. service = MagicMock()
  160. service.attach_timelapse = AsyncMock(return_value=True)
  161. with (
  162. patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=None)),
  163. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  164. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
  165. patch("backend.app.main.async_session", return_value=_session()),
  166. patch("backend.app.main.ArchiveService", return_value=service),
  167. ):
  168. result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
  169. assert result is False
  170. service.attach_timelapse.assert_not_awaited()
  171. delete.assert_not_awaited()
  172. @pytest.mark.asyncio
  173. async def test_failed_attach_does_not_delete(self, attach):
  174. delete = AsyncMock()
  175. service = MagicMock()
  176. service.attach_timelapse = AsyncMock(return_value=False)
  177. with (
  178. patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
  179. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=True)),
  180. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
  181. patch("backend.app.main.async_session", return_value=_session()),
  182. patch("backend.app.main.ArchiveService", return_value=service),
  183. ):
  184. result = await attach(42, _printer(), [_video("new.avi")], set(), set(), 1, logger)
  185. assert result is False
  186. delete.assert_not_awaited()
  187. class TestFtpDownloadSizeCheck:
  188. """`download_file` is where a truncated FTPS transfer used to pass for a
  189. complete one — a partial buffer is non-empty, so every caller downstream
  190. treated it as a good file."""
  191. def _client(self, payload: bytes):
  192. from backend.app.services.bambu_ftp import BambuFTPClient
  193. client = BambuFTPClient("192.168.1.100", "12345678")
  194. ftp = MagicMock()
  195. ftp.retrbinary = MagicMock(side_effect=lambda cmd, cb: cb(payload))
  196. client._ftp = ftp
  197. return client
  198. def test_exact_size_passes(self):
  199. assert self._client(b"a" * 500).download_file("/timelapse/v.avi", expected_size=500) == b"a" * 500
  200. def test_short_read_is_a_failure(self):
  201. assert self._client(b"a" * 499).download_file("/timelapse/v.avi", expected_size=500) is None
  202. def test_long_read_is_a_failure(self):
  203. """Not expected in practice, but a mismatch either way means we don't
  204. know what we have, and we're about to delete the original."""
  205. assert self._client(b"a" * 501).download_file("/timelapse/v.avi", expected_size=500) is None
  206. def test_zero_bytes_is_a_failure_even_without_an_expected_size(self):
  207. assert self._client(b"").download_file("/cache/whatever.3mf") is None
  208. def test_unverified_download_still_works_for_callers_that_do_not_pass_a_size(self):
  209. assert self._client(b"abc").download_file("/cache/whatever.3mf") == b"abc"
  210. class TestDeleteIsBestEffort:
  211. """A printer that refuses the delete must not break the flow — the video
  212. is already in the archive, and the diff excludes it by name from then on."""
  213. @pytest.mark.asyncio
  214. async def test_reports_success_on_delete(self):
  215. from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
  216. with patch(
  217. "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)
  218. ) as d:
  219. assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
  220. assert d.await_count == 1
  221. @pytest.mark.asyncio
  222. async def test_not_found_is_success_and_is_not_retried(self):
  223. """550 means the printer already cleaned up; waiting cannot change it."""
  224. from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
  225. with patch(
  226. "backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.NOT_FOUND)
  227. ) as d:
  228. assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is True
  229. assert d.await_count == 1
  230. @pytest.mark.asyncio
  231. async def test_failure_retries_then_gives_up_without_raising(self):
  232. from backend.app.services.bambu_ftp import DeleteResult, delete_archived_timelapse
  233. with (
  234. patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(return_value=DeleteResult.FAILED)),
  235. patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
  236. ):
  237. assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
  238. @pytest.mark.asyncio
  239. async def test_raising_transport_does_not_propagate(self):
  240. from backend.app.services.bambu_ftp import delete_archived_timelapse
  241. with (
  242. patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock(side_effect=OSError("boom"))),
  243. patch("backend.app.services.bambu_ftp.asyncio.sleep", AsyncMock()),
  244. ):
  245. assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=True) is False
  246. class TestBaselineIsPersisted:
  247. """The baseline has to outlive the process: a restart mid-print used to
  248. lose it, and the manual scan never had access to it at all."""
  249. @pytest.mark.asyncio
  250. async def test_written_to_the_archive_row_at_print_start(self):
  251. from backend.app.main import _capture_timelapse_baseline_at_start
  252. archive = MagicMock()
  253. archive.timelapse_baseline = None
  254. session = _session(archive)
  255. with (
  256. patch("backend.app.main.async_session", return_value=session),
  257. patch(
  258. "backend.app.services.bambu_ftp.list_files_result_async",
  259. new=AsyncMock(return_value=FileListResult([_video("a.avi"), _video("b.avi")], True)),
  260. ),
  261. ):
  262. await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
  263. assert archive.timelapse_baseline == ["a.avi", "b.avi"]
  264. session.commit.assert_awaited()
  265. @pytest.mark.asyncio
  266. async def test_no_archive_id_keeps_it_in_memory_only(self):
  267. from backend.app.main import _capture_timelapse_baseline_at_start, _timelapse_baselines
  268. _timelapse_baselines.pop(1, None)
  269. session = _session(MagicMock())
  270. with (
  271. patch("backend.app.main.async_session", return_value=session),
  272. patch(
  273. "backend.app.services.bambu_ftp.list_files_result_async",
  274. new=AsyncMock(return_value=FileListResult([_video("a.avi")], True)),
  275. ),
  276. ):
  277. await _capture_timelapse_baseline_at_start(_printer(), 1, logger)
  278. assert _timelapse_baselines[1] == {"a.avi"}
  279. session.commit.assert_not_awaited()
  280. _timelapse_baselines.pop(1, None)
  281. @pytest.mark.asyncio
  282. async def test_listing_failure_stores_null_not_an_empty_baseline(self):
  283. """An empty list would make every video on the printer look new; NULL
  284. correctly means "no baseline" and falls back to a fresh snapshot."""
  285. from backend.app.main import _capture_timelapse_baseline_at_start
  286. archive = MagicMock()
  287. session = _session(archive)
  288. with (
  289. patch("backend.app.main.async_session", return_value=session),
  290. patch(
  291. "backend.app.services.bambu_ftp.list_files_result_async", new=AsyncMock(side_effect=OSError("ftp down"))
  292. ),
  293. ):
  294. await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
  295. assert archive.timelapse_baseline is None
  296. class TestManualScanUsesTheBaseline:
  297. """The reporter's second symptom: pressing "Scan for Timelapse" found
  298. nothing. Every strategy the endpoint had was clock-based, and their
  299. printer's clock was days out, so it could not match on any of them."""
  300. def _archive(self, baseline):
  301. from datetime import datetime, timezone
  302. a = MagicMock()
  303. a.id = 64
  304. a.printer_id = 1
  305. a.filename = "mops.3mf"
  306. a.timelapse_path = None
  307. a.timelapse_baseline = baseline
  308. a.started_at = datetime(2026, 7, 28, 20, 30, tzinfo=timezone.utc)
  309. a.completed_at = datetime(2026, 7, 28, 21, 19, tzinfo=timezone.utc)
  310. a.created_at = a.completed_at
  311. return a
  312. async def _scan(self, archive, listing, download=None, delete=None, claimed=()):
  313. from backend.app.api.routes import archives as archives_mod
  314. service = MagicMock()
  315. service.get_archive = AsyncMock(return_value=archive)
  316. service.attach_timelapse = AsyncMock(return_value=True)
  317. session = AsyncMock()
  318. session.__aenter__ = AsyncMock(return_value=session)
  319. session.__aexit__ = AsyncMock()
  320. session.execute = AsyncMock(
  321. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=_printer())),
  322. )
  323. with (
  324. patch("backend.app.core.database.async_session", return_value=session),
  325. # Videos other prints of this printer hold
  326. patch(
  327. "backend.app.api.routes.archives._claimed_timelapse_stems",
  328. AsyncMock(return_value={Path(p).stem for p in claimed}),
  329. ),
  330. patch("backend.app.api.routes.archives.ArchiveService", return_value=service),
  331. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
  332. patch(
  333. "backend.app.services.bambu_ftp.get_ftp_retry_settings",
  334. AsyncMock(return_value=(False, 3, 2, 30)),
  335. ),
  336. patch(
  337. "backend.app.services.bambu_ftp.download_file_bytes_async",
  338. download or AsyncMock(return_value=b"x" * 1000),
  339. ),
  340. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
  341. ):
  342. return await archives_mod.scan_timelapse(archive.id, None, ALL_PRINTERS)
  343. @pytest.mark.asyncio
  344. async def test_attaches_the_single_unclaimed_new_file(self):
  345. """The printer's clock is six days out here — exactly the reporter's
  346. case. Nothing in this path looks at a timestamp."""
  347. archive = self._archive(["video_2026-07-21_22-49-47.avi"])
  348. listing = [
  349. _video("video_2026-07-21_22-49-47.avi"),
  350. _video("video_2026-07-22_06-18-39.avi"),
  351. ]
  352. result = await self._scan(archive, listing)
  353. assert result["status"] == "attached"
  354. assert result["filename"] == "video_2026-07-22_06-18-39.avi"
  355. @pytest.mark.asyncio
  356. async def test_deletes_from_the_printer_after_attaching(self):
  357. delete = AsyncMock()
  358. archive = self._archive(["old.avi"])
  359. await self._scan(archive, [_video("old.avi"), _video("new.avi")], delete=delete)
  360. delete.assert_awaited_once()
  361. assert delete.await_args.args[2] == "/timelapse/new.avi"
  362. @pytest.mark.asyncio
  363. async def test_baseline_showing_nothing_new_does_not_guess(self):
  364. """With a baseline saying no new video exists, the clock strategies
  365. must not run — otherwise a coincidental timestamp match attaches
  366. someone else's video and calls it this print's."""
  367. archive = self._archive(["video_2026-07-28_20-30-00.avi"])
  368. # This file's embedded time is minutes from started_at, so the old
  369. # timestamp strategy would have matched it confidently.
  370. listing = [_video("video_2026-07-28_20-30-00.avi")]
  371. result = await self._scan(archive, listing)
  372. assert result["status"] == "not_found"
  373. @pytest.mark.asyncio
  374. async def test_ambiguous_baseline_offers_only_the_plausible_files(self):
  375. archive = self._archive(["old.avi"])
  376. listing = [_video("old.avi"), _video("candidate_a.avi"), _video("candidate_b.avi")]
  377. result = await self._scan(archive, listing)
  378. assert result["status"] == "not_found"
  379. assert {f["name"] for f in result["available_files"]} == {"candidate_a.avi", "candidate_b.avi"}
  380. @pytest.mark.asyncio
  381. async def test_archives_without_a_baseline_keep_the_old_strategies(self):
  382. """Rows predating the persisted baseline still get the best guess the
  383. endpoint can make, rather than nothing at all."""
  384. archive = self._archive(None)
  385. listing = [_video("mops_something.avi")] # matches by print name
  386. result = await self._scan(archive, listing)
  387. assert result["status"] == "attached"
  388. assert result["filename"] == "mops_something.avi"
  389. class TestPollBounds:
  390. """The poll is bounded twice on purpose."""
  391. def test_round_cap_tracks_the_wall_clock_budget(self):
  392. from backend.app.main import (
  393. _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS,
  394. _TIMELAPSE_SCAN_TIMEOUT_SECONDS,
  395. _timelapse_scan_max_attempts,
  396. )
  397. assert (
  398. _timelapse_scan_max_attempts()
  399. == int(_TIMELAPSE_SCAN_TIMEOUT_SECONDS // _TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS) + 1
  400. )
  401. def test_zero_interval_does_not_divide_by_zero(self, monkeypatch):
  402. """The deadline alone can't bound the loop once sleeps are shortened to
  403. nothing, which is exactly what a test or a future tweak would do."""
  404. import backend.app.main as main_mod
  405. monkeypatch.setattr(main_mod, "_TIMELAPSE_SCAN_POLL_INTERVAL_SECONDS", 0)
  406. assert main_mod._timelapse_scan_max_attempts() > 1
  407. def test_budget_is_much_longer_than_the_ladder_it_replaced(self):
  408. """The old [5, 10, 20, 30] ladder gave up after ~65 seconds, while the
  409. support bundles showed videos still arriving at the cutoff."""
  410. from backend.app.main import _TIMELAPSE_SCAN_TIMEOUT_SECONDS
  411. assert _TIMELAPSE_SCAN_TIMEOUT_SECONDS >= 300
  412. class TestFinishPhotoUpgrade:
  413. """The print-complete notification waits ~60s for the timelapse, because
  414. holding it for minutes is worse than sending a live grab. On a P1S the
  415. video routinely lands later than that (p90 167s, worst observed 546s), so
  416. the archive kept the live grab — taken after the end G-code dropped the
  417. bed, which is the worse of the two photos. The upgrade runs afterwards."""
  418. @pytest.mark.asyncio
  419. async def test_replaces_the_live_grab_in_place(self, tmp_path):
  420. """Archive 454 ended up with two finish photos of the same moment. The
  421. frame now goes over the live grab's file, under its name: one photo,
  422. and the archive link a notification already sent (auth off) keeps
  423. working and shows the better photo."""
  424. from backend.app.main import _upgrade_finish_photo_from_timelapse
  425. photos = tmp_path / "photos"
  426. photos.mkdir()
  427. (photos / "finish_live_grab.jpg").write_bytes(b"live")
  428. (photos / "finish_from_timelapse.jpg").write_bytes(b"frame")
  429. archive = MagicMock()
  430. archive.photos = ["finish_upload.jpg", "finish_live_grab.jpg"]
  431. session = _session(archive)
  432. with (
  433. patch(
  434. "backend.app.main._capture_finish_photo_from_timelapse",
  435. AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
  436. ),
  437. patch("backend.app.main.async_session", return_value=session),
  438. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())) as ws,
  439. ):
  440. await _upgrade_finish_photo_from_timelapse(7, tmp_path, replace="finish_live_grab.jpg")
  441. assert archive.photos == ["finish_live_grab.jpg", "finish_upload.jpg"]
  442. assert (photos / "finish_live_grab.jpg").read_bytes() == b"frame"
  443. assert sorted(f.name for f in photos.iterdir()) == ["finish_live_grab.jpg"]
  444. session.commit.assert_awaited()
  445. assert ws.send_archive_updated.await_args.args[0]["photo_added"] == "finish_live_grab.jpg"
  446. @pytest.mark.asyncio
  447. async def test_adds_the_frame_when_the_live_grab_was_deleted_meanwhile(self, tmp_path):
  448. """Nothing to replace: the user removed it from the gallery. Writing
  449. over a name the archive no longer lists would hide the frame."""
  450. from backend.app.main import _upgrade_finish_photo_from_timelapse
  451. photos = tmp_path / "photos"
  452. photos.mkdir()
  453. (photos / "finish_from_timelapse.jpg").write_bytes(b"frame")
  454. archive = MagicMock()
  455. archive.photos = []
  456. session = _session(archive)
  457. with (
  458. patch(
  459. "backend.app.main._capture_finish_photo_from_timelapse",
  460. AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
  461. ),
  462. patch("backend.app.main.async_session", return_value=session),
  463. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  464. ):
  465. await _upgrade_finish_photo_from_timelapse(7, tmp_path, replace="finish_live_grab.jpg")
  466. assert archive.photos == ["finish_from_timelapse.jpg"]
  467. assert (photos / "finish_from_timelapse.jpg").read_bytes() == b"frame"
  468. @pytest.mark.asyncio
  469. async def test_puts_the_timelapse_frame_first_without_a_live_grab_to_replace(self):
  470. """First, because the gallery opens at index 0."""
  471. from backend.app.main import _upgrade_finish_photo_from_timelapse
  472. archive = MagicMock()
  473. archive.photos = ["finish_live_grab.jpg"]
  474. session = _session(archive)
  475. with (
  476. patch(
  477. "backend.app.main._capture_finish_photo_from_timelapse",
  478. AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
  479. ),
  480. patch("backend.app.main.async_session", return_value=session),
  481. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())) as ws,
  482. ):
  483. await _upgrade_finish_photo_from_timelapse(7, MagicMock())
  484. assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
  485. session.commit.assert_awaited()
  486. ws.send_archive_updated.assert_awaited_once()
  487. @pytest.mark.asyncio
  488. async def test_waits_far_longer_than_the_notification_can(self):
  489. from backend.app.main import (
  490. _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
  491. _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS,
  492. _upgrade_finish_photo_from_timelapse,
  493. )
  494. capture = AsyncMock(return_value=(None, True))
  495. with patch("backend.app.main._capture_finish_photo_from_timelapse", capture):
  496. await _upgrade_finish_photo_from_timelapse(7, MagicMock())
  497. assert capture.await_args.kwargs["timeout"] == _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS
  498. assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS > _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
  499. # Covers the 546s worst case seen in the support bundles.
  500. assert _FINISH_PHOTO_UPGRADE_TIMEOUT_SECONDS >= 600
  501. @pytest.mark.asyncio
  502. async def test_video_never_arrives_leaves_the_archive_alone(self):
  503. from backend.app.main import _upgrade_finish_photo_from_timelapse
  504. session = _session(MagicMock())
  505. with (
  506. patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=(None, True))),
  507. patch("backend.app.main.async_session", return_value=session),
  508. ):
  509. await _upgrade_finish_photo_from_timelapse(7, MagicMock())
  510. session.commit.assert_not_awaited()
  511. @pytest.mark.asyncio
  512. async def test_is_idempotent(self):
  513. """A second run must not list the same photo twice."""
  514. from backend.app.main import _upgrade_finish_photo_from_timelapse
  515. archive = MagicMock()
  516. archive.photos = ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
  517. session = _session(archive)
  518. with (
  519. patch(
  520. "backend.app.main._capture_finish_photo_from_timelapse",
  521. AsyncMock(return_value=("finish_from_timelapse.jpg", False)),
  522. ),
  523. patch("backend.app.main.async_session", return_value=session),
  524. ):
  525. await _upgrade_finish_photo_from_timelapse(7, MagicMock())
  526. assert archive.photos == ["finish_from_timelapse.jpg", "finish_live_grab.jpg"]
  527. session.commit.assert_not_awaited()
  528. @pytest.mark.asyncio
  529. async def test_missing_archive_does_not_raise(self):
  530. from backend.app.main import _upgrade_finish_photo_from_timelapse
  531. session = AsyncMock()
  532. session.__aenter__ = AsyncMock(return_value=session)
  533. session.__aexit__ = AsyncMock()
  534. session.get = AsyncMock(return_value=None)
  535. with (
  536. patch("backend.app.main._capture_finish_photo_from_timelapse", AsyncMock(return_value=("f.jpg", False))),
  537. patch("backend.app.main.async_session", return_value=session),
  538. ):
  539. await _upgrade_finish_photo_from_timelapse(7, MagicMock())
  540. session.commit.assert_not_awaited()
  541. @pytest.mark.asyncio
  542. async def test_refuses_to_delete_an_unverified_download(self):
  543. """The safety rule lives with the destructive call, not at the call
  544. sites — an unverified transfer may be a truncated file, and deleting
  545. the source would destroy the only complete copy."""
  546. from backend.app.services.bambu_ftp import delete_archived_timelapse
  547. with patch("backend.app.services.bambu_ftp.delete_file_async", AsyncMock()) as d:
  548. assert await delete_archived_timelapse("1.2.3.4", "code", "/timelapse/v.avi", verified=False) is False
  549. d.assert_not_awaited()
  550. @pytest.mark.asyncio
  551. async def test_verified_is_required_not_defaulted(self):
  552. """A future call site must not be able to silently skip the check."""
  553. import inspect
  554. from backend.app.services.bambu_ftp import delete_archived_timelapse
  555. param = inspect.signature(delete_archived_timelapse).parameters["verified"]
  556. assert param.default is inspect.Parameter.empty
  557. assert param.kind is inspect.Parameter.KEYWORD_ONLY
  558. class TestStaleBaselineCannotSurvive:
  559. """A reprint reuses the archive row, so a baseline left over from the
  560. previous run would have the scan diff this print against the printer's
  561. state before the *last* one — and unlike NULL, a stale list reads as
  562. authoritative and suppresses the fresh-snapshot fallback."""
  563. @pytest.mark.asyncio
  564. async def test_failed_capture_clears_rather_than_leaves_the_old_value(self):
  565. from backend.app.main import _capture_timelapse_baseline_at_start
  566. archive = MagicMock()
  567. archive.timelapse_baseline = ["from_the_previous_run.avi"]
  568. session = _session(archive)
  569. with (
  570. patch("backend.app.main.async_session", return_value=session),
  571. patch(
  572. "backend.app.services.bambu_ftp.list_files_result_async", new=AsyncMock(side_effect=OSError("ftp down"))
  573. ),
  574. ):
  575. await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
  576. assert archive.timelapse_baseline is None
  577. session.commit.assert_awaited()
  578. @pytest.mark.asyncio
  579. async def test_successful_capture_overwrites_the_old_value(self):
  580. from backend.app.main import _capture_timelapse_baseline_at_start
  581. archive = MagicMock()
  582. archive.timelapse_baseline = ["from_the_previous_run.avi"]
  583. session = _session(archive)
  584. with (
  585. patch("backend.app.main.async_session", return_value=session),
  586. patch(
  587. "backend.app.services.bambu_ftp.list_files_result_async",
  588. new=AsyncMock(return_value=FileListResult([_video("now_on_the_printer.avi")], True)),
  589. ),
  590. ):
  591. await _capture_timelapse_baseline_at_start(_printer(), 1, logger, archive_id=7)
  592. assert archive.timelapse_baseline == ["now_on_the_printer.avi"]
  593. class TestFileMustHaveStoppedGrowing:
  594. """Matching the listing's size proves we received what it said, not that
  595. the printer had finished writing. The scan's first look lands seconds after
  596. the print ends — exactly when the video is being written — so a growing
  597. file can be listed short, served short, and pass the length check. That was
  598. survivable while the printer kept its copy; it isn't now that a successful
  599. attach deletes it."""
  600. @pytest.mark.asyncio
  601. async def test_same_size_afterwards_is_settled(self):
  602. from backend.app.services.bambu_ftp import remote_file_settled
  603. with patch(
  604. "backend.app.services.bambu_ftp.list_files_async",
  605. AsyncMock(return_value=[_video("v.avi", size=4096)]),
  606. ):
  607. assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
  608. @pytest.mark.asyncio
  609. async def test_grown_since_download_is_not_settled(self):
  610. """We hold a prefix of the video, not the video."""
  611. from backend.app.services.bambu_ftp import remote_file_settled
  612. with patch(
  613. "backend.app.services.bambu_ftp.list_files_async",
  614. AsyncMock(return_value=[_video("v.avi", size=9000)]),
  615. ):
  616. assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
  617. @pytest.mark.asyncio
  618. async def test_vanished_counts_as_settled(self):
  619. """Nothing left that can grow, and nothing left to delete either."""
  620. from backend.app.services.bambu_ftp import remote_file_settled
  621. with patch(
  622. "backend.app.services.bambu_ftp.list_files_async",
  623. AsyncMock(return_value=[_video("something_else.avi")]),
  624. ):
  625. assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is True
  626. @pytest.mark.asyncio
  627. async def test_listing_failure_is_not_settled(self):
  628. """ "Could not check" must not read as "safe to delete"."""
  629. from backend.app.services.bambu_ftp import remote_file_settled
  630. with patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])):
  631. assert await remote_file_settled("1.2.3.4", "code", "/timelapse/v.avi", 4096) is False
  632. @pytest.mark.asyncio
  633. async def test_scan_discards_a_still_growing_video_without_deleting(self):
  634. from backend.app.main import _attach_first_unclaimed_timelapse
  635. delete = AsyncMock()
  636. service = MagicMock()
  637. service.attach_timelapse = AsyncMock(return_value=True)
  638. with (
  639. patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
  640. patch("backend.app.services.bambu_ftp.remote_file_settled", AsyncMock(return_value=False)),
  641. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete),
  642. patch("backend.app.main.async_session", return_value=_session()),
  643. patch("backend.app.main.ArchiveService", return_value=service),
  644. ):
  645. result = await _attach_first_unclaimed_timelapse(
  646. 42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
  647. )
  648. assert result is False
  649. service.attach_timelapse.assert_not_awaited()
  650. delete.assert_not_awaited()
  651. @pytest.mark.asyncio
  652. async def test_scan_attaches_once_the_video_has_settled(self):
  653. from backend.app.main import _attach_first_unclaimed_timelapse
  654. settled = AsyncMock(return_value=True)
  655. service = MagicMock()
  656. service.attach_timelapse = AsyncMock(return_value=True)
  657. with (
  658. patch("backend.app.services.bambu_ftp.download_file_bytes_async", AsyncMock(return_value=b"x" * 1000)),
  659. patch("backend.app.services.bambu_ftp.remote_file_settled", settled),
  660. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()),
  661. patch("backend.app.main.async_session", return_value=_session()),
  662. patch("backend.app.main.ArchiveService", return_value=service),
  663. patch("backend.app.main.ws_manager", MagicMock(send_archive_updated=AsyncMock())),
  664. ):
  665. result = await _attach_first_unclaimed_timelapse(
  666. 42, _printer(), [_video("new.avi", size=1000)], set(), set(), 1, logger
  667. )
  668. assert result is True
  669. # Checked against what we actually received, not against the listing.
  670. assert settled.await_args.args[3] == 1000
  671. class TestAScanWithoutABaselineSkipsClaimedVideos:
  672. """An archive from before the baseline was stored is matched on name and
  673. clock, which must not hand it a video another archive already holds
  674. (SN-C7)."""
  675. _archive = TestManualScanUsesTheBaseline._archive
  676. _scan = TestManualScanUsesTheBaseline._scan
  677. @pytest.mark.asyncio
  678. async def test_a_short_name_does_not_take_another_archive_s_video(self):
  679. archive = self._archive(None)
  680. archive.filename = "1.3mf" # "1" is in every video's timestamp
  681. listing = [_video("video_2026-07-28_21-19-10.avi"), _video("video_2026-07-28_21-19-40.avi")]
  682. result = await self._scan(archive, listing, claimed=["archive/1/other/video_2026-07-28_21-19-10.mp4"])
  683. assert result.get("filename") != "video_2026-07-28_21-19-10.avi"
  684. @pytest.mark.asyncio
  685. async def test_a_claimed_video_is_still_offered_for_picking_by_hand(self):
  686. """Picking by hand is how a wrong match is put right."""
  687. archive = self._archive(None)
  688. archive.filename = "mops.3mf"
  689. archive.started_at = archive.completed_at = archive.created_at = None
  690. listing = [_video("video_a.avi"), _video("video_b.avi")]
  691. result = await self._scan(archive, listing, claimed=["archive/1/other/video_a.mp4"])
  692. assert result["status"] == "not_found"
  693. assert {f["name"] for f in result["available_files"]} == {"video_a.avi", "video_b.avi"}