test_reprint_clears_stale_timelapse.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. """Regression for #1707: Telegram (and any image-bearing) notification on a
  2. reprint from archive showed the *original* print's finish photo because the
  3. expected-archive branch never reset ``archive.timelapse_path``.
  4. The source archive row is reused for reprints. With ``timelapse_path`` still
  5. pointing at the original run's downloaded MP4:
  6. - ``_scan_for_timelapse_with_retries`` early-returns ("already has timelapse")
  7. and never downloads the reprint's video.
  8. - ``_capture_finish_photo_from_timelapse`` reads the stale path, extracts the
  9. *original* last frame, and ships it as the reprint's finish photo.
  10. The fix clears ``archive.timelapse_path`` (and unlinks the stale file) at
  11. expected-archive promotion so the scan + photo path run fresh.
  12. """
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from backend.app.core.config import settings as app_settings
  16. from backend.app.main import (
  17. _active_prints,
  18. _expected_print_creators,
  19. _expected_print_registered_at,
  20. _expected_prints,
  21. _print_ams_mappings,
  22. _print_plate_ids,
  23. _timelapse_baselines,
  24. register_expected_print,
  25. )
  26. @pytest.fixture(autouse=True)
  27. def _clear_dicts():
  28. _expected_prints.clear()
  29. _expected_print_registered_at.clear()
  30. _expected_print_creators.clear()
  31. _print_ams_mappings.clear()
  32. _print_plate_ids.clear()
  33. _active_prints.clear()
  34. _timelapse_baselines.clear()
  35. yield
  36. _expected_prints.clear()
  37. _expected_print_registered_at.clear()
  38. _expected_print_creators.clear()
  39. _print_ams_mappings.clear()
  40. _print_plate_ids.clear()
  41. _active_prints.clear()
  42. _timelapse_baselines.clear()
  43. def _patches():
  44. """Common patches for driving on_print_start without side effects."""
  45. return (
  46. patch("backend.app.main.async_session"),
  47. patch("backend.app.main.notification_service"),
  48. patch("backend.app.main.smart_plug_manager"),
  49. patch("backend.app.main.ws_manager"),
  50. patch("backend.app.main.printer_manager"),
  51. patch("backend.app.main.mqtt_relay"),
  52. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  53. patch("backend.app.main._load_objects_from_archive"),
  54. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  55. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  56. patch(
  57. "backend.app.main._list_timelapse_videos",
  58. new=AsyncMock(return_value=([], "/timelapse")),
  59. ),
  60. )
  61. def _build_mocks(mock_printer, mock_archive):
  62. def execute_router(stmt, *args, **kwargs):
  63. sql = str(stmt).lower()
  64. if "from printers" in sql or "from printer " in sql:
  65. return MagicMock(
  66. scalar_one_or_none=MagicMock(return_value=mock_printer),
  67. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
  68. )
  69. if "from print_archives" in sql or "from print_archive" in sql:
  70. return MagicMock(
  71. scalar_one_or_none=MagicMock(return_value=mock_archive),
  72. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
  73. )
  74. return MagicMock(
  75. scalar_one_or_none=MagicMock(return_value=None),
  76. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  77. )
  78. mock_session = AsyncMock()
  79. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  80. mock_session.__aexit__ = AsyncMock()
  81. mock_session.execute = AsyncMock(side_effect=execute_router)
  82. mock_session.commit = AsyncMock()
  83. return mock_session
  84. @pytest.mark.asyncio
  85. async def test_reprint_clears_timelapse_path_and_unlinks_stale_file(tmp_path):
  86. """On reprint promotion, timelapse_path must be reset to None and the old
  87. on-disk video unlinked, so the completion-time scanner and finish-photo
  88. extractor don't reuse the original run's frame."""
  89. mock_printer = MagicMock()
  90. mock_printer.id = 1
  91. mock_printer.auto_archive = True
  92. mock_printer.external_camera_enabled = False
  93. mock_printer.external_camera_url = None
  94. mock_printer.name = "TestP2S"
  95. # Lay down a fake stale timelapse under a tmp base_dir so the unlink
  96. # actually has a file to remove.
  97. relpath = "archives/42/timelapse/original.mp4"
  98. stale_file = tmp_path / relpath
  99. stale_file.parent.mkdir(parents=True, exist_ok=True)
  100. stale_file.write_bytes(b"old timelapse bytes")
  101. assert stale_file.exists()
  102. mock_archive = MagicMock()
  103. mock_archive.id = 42
  104. mock_archive.filename = "MyModel.3mf"
  105. mock_archive.subtask_id = None
  106. mock_archive.print_time_seconds = None
  107. mock_archive.created_by_id = None
  108. mock_archive.printer_id = 1
  109. mock_archive.print_name = "MyModel"
  110. mock_archive.status = "archived"
  111. mock_archive.file_path = "archives/42/MyModel.3mf"
  112. mock_archive.energy_start_kwh = None
  113. mock_archive.timelapse_path = relpath # stale from the original run
  114. mock_archive.timelapse_plate_id = 1 # ...which printed plate 1
  115. # The queue always dispatches a plate; the same one is printing again.
  116. register_expected_print(1, "MyModel.3mf", archive_id=42, ams_mapping=None, plate_id=1)
  117. mock_session = _build_mocks(mock_printer, mock_archive)
  118. (
  119. async_session_p,
  120. notif_p,
  121. plug_p,
  122. ws_p,
  123. pm_p,
  124. relay_p,
  125. _energy,
  126. _load_obj,
  127. _store_spoolman,
  128. _send_start,
  129. _list_tl,
  130. ) = _patches()
  131. with (
  132. async_session_p as mock_session_maker,
  133. notif_p as mock_notif,
  134. plug_p as mock_plug,
  135. ws_p as mock_ws,
  136. pm_p as mock_pm,
  137. relay_p as mock_relay,
  138. _energy,
  139. _load_obj,
  140. _store_spoolman,
  141. _send_start,
  142. _list_tl,
  143. patch.object(app_settings, "base_dir", tmp_path),
  144. ):
  145. mock_session_maker.return_value = mock_session
  146. mock_notif.on_print_start = AsyncMock()
  147. mock_plug.on_print_start = AsyncMock()
  148. mock_ws.send_print_start = AsyncMock()
  149. mock_ws.send_archive_updated = AsyncMock()
  150. mock_relay.on_print_start = AsyncMock()
  151. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  152. from backend.app.main import on_print_start
  153. await on_print_start(1, {"filename": "MyModel.3mf", "subtask_name": "MyModel"})
  154. assert mock_archive.timelapse_path is None, (
  155. "expected-archive branch must clear timelapse_path on reprint so "
  156. "_scan_for_timelapse_with_retries doesn't early-return and "
  157. "_capture_finish_photo_from_timelapse doesn't extract the original "
  158. "run's last frame (#1707)"
  159. )
  160. assert not stale_file.exists(), (
  161. "old timelapse file must be unlinked at reprint promotion to avoid orphans in the archive directory"
  162. )
  163. @pytest.mark.asyncio
  164. async def test_reprint_with_no_timelapse_path_is_noop(tmp_path):
  165. """When archive has no prior timelapse_path (first print, or already
  166. cleared), promotion must still succeed and not raise on the unlink path."""
  167. mock_printer = MagicMock()
  168. mock_printer.id = 1
  169. mock_printer.auto_archive = True
  170. mock_printer.external_camera_enabled = False
  171. mock_printer.external_camera_url = None
  172. mock_printer.name = "TestP2S"
  173. mock_archive = MagicMock()
  174. mock_archive.id = 99
  175. mock_archive.filename = "FreshFile.3mf"
  176. mock_archive.subtask_id = None
  177. mock_archive.print_time_seconds = None
  178. mock_archive.created_by_id = None
  179. mock_archive.printer_id = 1
  180. mock_archive.print_name = "FreshFile"
  181. mock_archive.status = "archived"
  182. mock_archive.file_path = "archives/99/FreshFile.3mf"
  183. mock_archive.energy_start_kwh = None
  184. mock_archive.timelapse_path = None # nothing to clean up
  185. register_expected_print(1, "FreshFile.3mf", archive_id=99, ams_mapping=None)
  186. mock_session = _build_mocks(mock_printer, mock_archive)
  187. (
  188. async_session_p,
  189. notif_p,
  190. plug_p,
  191. ws_p,
  192. pm_p,
  193. relay_p,
  194. _energy,
  195. _load_obj,
  196. _store_spoolman,
  197. _send_start,
  198. _list_tl,
  199. ) = _patches()
  200. with (
  201. async_session_p as mock_session_maker,
  202. notif_p as mock_notif,
  203. plug_p as mock_plug,
  204. ws_p as mock_ws,
  205. pm_p as mock_pm,
  206. relay_p as mock_relay,
  207. _energy,
  208. _load_obj,
  209. _store_spoolman,
  210. _send_start,
  211. _list_tl,
  212. patch.object(app_settings, "base_dir", tmp_path),
  213. ):
  214. mock_session_maker.return_value = mock_session
  215. mock_notif.on_print_start = AsyncMock()
  216. mock_plug.on_print_start = AsyncMock()
  217. mock_ws.send_print_start = AsyncMock()
  218. mock_ws.send_archive_updated = AsyncMock()
  219. mock_relay.on_print_start = AsyncMock()
  220. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  221. from backend.app.main import on_print_start
  222. await on_print_start(1, {"filename": "FreshFile.3mf", "subtask_name": "FreshFile"})
  223. assert mock_archive.timelapse_path is None
  224. assert mock_archive.status == "printing"
  225. @pytest.mark.asyncio
  226. async def test_reprint_with_missing_stale_file_does_not_raise(tmp_path):
  227. """If the stale file referenced by timelapse_path no longer exists on
  228. disk (user deleted, archive purge, container rebuilt with bind-mount
  229. drift), promotion must still clear the field cleanly without raising."""
  230. mock_printer = MagicMock()
  231. mock_printer.id = 1
  232. mock_printer.auto_archive = True
  233. mock_printer.external_camera_enabled = False
  234. mock_printer.external_camera_url = None
  235. mock_printer.name = "TestP2S"
  236. mock_archive = MagicMock()
  237. mock_archive.id = 7
  238. mock_archive.filename = "Ghost.3mf"
  239. mock_archive.subtask_id = None
  240. mock_archive.print_time_seconds = None
  241. mock_archive.created_by_id = None
  242. mock_archive.printer_id = 1
  243. mock_archive.print_name = "Ghost"
  244. mock_archive.status = "archived"
  245. mock_archive.file_path = "archives/7/Ghost.3mf"
  246. mock_archive.energy_start_kwh = None
  247. # Path points at a file that doesn't exist under tmp_path.
  248. mock_archive.timelapse_path = "archives/7/timelapse/vanished.mp4"
  249. register_expected_print(1, "Ghost.3mf", archive_id=7, ams_mapping=None)
  250. mock_session = _build_mocks(mock_printer, mock_archive)
  251. (
  252. async_session_p,
  253. notif_p,
  254. plug_p,
  255. ws_p,
  256. pm_p,
  257. relay_p,
  258. _energy,
  259. _load_obj,
  260. _store_spoolman,
  261. _send_start,
  262. _list_tl,
  263. ) = _patches()
  264. with (
  265. async_session_p as mock_session_maker,
  266. notif_p as mock_notif,
  267. plug_p as mock_plug,
  268. ws_p as mock_ws,
  269. pm_p as mock_pm,
  270. relay_p as mock_relay,
  271. _energy,
  272. _load_obj,
  273. _store_spoolman,
  274. _send_start,
  275. _list_tl,
  276. patch.object(app_settings, "base_dir", tmp_path),
  277. ):
  278. mock_session_maker.return_value = mock_session
  279. mock_notif.on_print_start = AsyncMock()
  280. mock_plug.on_print_start = AsyncMock()
  281. mock_ws.send_print_start = AsyncMock()
  282. mock_ws.send_archive_updated = AsyncMock()
  283. mock_relay.on_print_start = AsyncMock()
  284. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  285. from backend.app.main import on_print_start
  286. await on_print_start(1, {"filename": "Ghost.3mf", "subtask_name": "Ghost"})
  287. assert mock_archive.timelapse_path is None
  288. assert mock_archive.status == "printing"