"""Tests for _capture_finish_photo_from_timelapse (#1397). The polling helper runs in parallel with _scan_for_timelapse_with_retries — it waits for archive.timelapse_path to land in the DB, then extracts the last frame as the finish photo. These tests exercise the four shapes the helper has to handle correctly: 1. timelapse never lands within timeout → return None (caller falls back) 2. timelapse lands, extraction succeeds → return filename 3. timelapse lands, extraction fails → return None (caller falls back) 4. timelapse_path is set but the file doesn't exist on disk → keep polling DB access is patched at the session-maker boundary so these tests run in ~50ms each without standing up a real engine. """ from contextlib import asynccontextmanager from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from backend.app import main as main_module from backend.app.main import _capture_finish_photo_from_timelapse @asynccontextmanager async def _fake_session(archive): """A fake session whose execute().scalar_one_or_none() returns `archive`. `archive` is mutated by the test mid-poll to simulate the real flow: the timelapse-attach background task setting `timelapse_path` after a few poll cycles. """ result = SimpleNamespace(scalar_one_or_none=lambda: archive) session = SimpleNamespace(execute=AsyncMock(return_value=result)) yield session @pytest.fixture def fake_archive(): """Mutable archive stand-in. Tests flip `.timelapse_path` to simulate the timelapse-attach task writing to the DB.""" return SimpleNamespace(id=42, timelapse_path=None) @pytest.fixture(autouse=True) def _fast_poll(monkeypatch): """Shrink poll interval + timeout so tests don't sleep for real.""" monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.01) monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 0.2) @pytest.fixture def patched_session(fake_archive, monkeypatch): """Patch main.async_session so the helper reads our fake archive.""" monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_archive)) return fake_archive async def test_returns_none_when_timelapse_never_lands(tmp_path: Path, patched_session): """Print finished without a timelapse — bail after timeout so the caller falls back to the live-camera grab.""" result, pending = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path, ) assert result is None # Ran out of time rather than concluded: the video may still be on its way, # which is what tells the caller to schedule a background upgrade (#2704). assert pending is True async def test_extracts_frame_when_timelapse_lands(tmp_path: Path, patched_session, monkeypatch): """Simulate the timelapse landing after one poll cycle and extraction succeeding — should return a filename matching the finish_*.jpg pattern.""" # Lay down a stub timelapse file relative to base_dir so the path # join works the way the helper expects. monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) video_relpath = Path("archive/1/print/timelapse.mp4") video_abspath = tmp_path / video_relpath video_abspath.parent.mkdir(parents=True, exist_ok=True) video_abspath.write_bytes(b"x" * 100) # non-empty so the size check passes # Patch extraction to succeed unconditionally — the actual ffmpeg # codepath has its own test file. async def fake_extract(src, dst): dst.write_bytes(b"\xff\xd8" + b"\x00" * 50) # JPEG SOI return True monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0) # Flip the archive into the "timelapse landed" state before the first # poll — the helper picks it up on its initial read. patched_session.timelapse_path = str(video_relpath) with patch( "backend.app.services.camera.extract_video_last_frame", new=fake_extract, ): result, pending = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path / "archive_dir", ) assert result is not None assert result.startswith("finish_") assert result.endswith(".jpg") assert (tmp_path / "archive_dir" / "photos" / result).exists() assert pending is False async def test_returns_none_when_extraction_fails(tmp_path: Path, patched_session, monkeypatch): """Timelapse landed but ffmpeg said no — we don't keep retrying on the same broken file; return None so the caller falls back.""" monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) video_relpath = Path("archive/1/print/timelapse.mp4") video_abspath = tmp_path / video_relpath video_abspath.parent.mkdir(parents=True, exist_ok=True) video_abspath.write_bytes(b"x" * 100) async def fake_extract_fails(src, dst): return False patched_session.timelapse_path = str(video_relpath) with patch( "backend.app.services.camera.extract_video_last_frame", new=fake_extract_fails, ): result, pending = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path / "archive_dir", ) assert result is None # The video arrived and ffmpeg refused it — waiting longer cannot help, so # this must NOT ask for a background retry. assert pending is False async def test_polls_until_file_appears(tmp_path: Path, patched_session, monkeypatch): """timelapse_path is set, but the file isn't on disk yet (the attach background task hasn't finished writing). Should keep polling — and succeed once the file materialises.""" monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.05) monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS", 1.0) video_relpath = Path("archive/1/print/timelapse.mp4") patched_session.timelapse_path = str(video_relpath) # File not present yet. Schedule it to land after ~150ms. import asyncio async def materialise_later(): await asyncio.sleep(0.15) video_abspath = tmp_path / video_relpath video_abspath.parent.mkdir(parents=True, exist_ok=True) video_abspath.write_bytes(b"x" * 100) async def fake_extract(src, dst): dst.write_bytes(b"\xff\xd8") return True materialise = asyncio.create_task(materialise_later()) try: with patch( "backend.app.services.camera.extract_video_last_frame", new=fake_extract, ): result, pending = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path / "archive_dir", ) finally: materialise.cancel() assert result is not None assert result.startswith("finish_") async def test_extracted_frame_is_rotated_when_configured(tmp_path: Path, patched_session, monkeypatch): """#2708: this source hands a path to ffmpeg and never holds the bytes, so it was the one finish-photo source that ignored camera_rotation entirely. A built-in-camera print with a timelapse prefers this source over the live grab, so leaving it out meant the orientation depended on which source won. """ import io from PIL import Image monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) video_relpath = Path("archive/1/print/timelapse.mp4") video_abspath = tmp_path / video_relpath video_abspath.parent.mkdir(parents=True, exist_ok=True) video_abspath.write_bytes(b"x" * 100) async def fake_extract(src, dst): buf = io.BytesIO() Image.new("RGB", (64, 32), (0, 0, 255)).save(buf, format="JPEG") dst.write_bytes(buf.getvalue()) return True monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0) patched_session.timelapse_path = str(video_relpath) with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract): result, _ = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path / "archive_dir", rotation=90, ) assert result is not None written = tmp_path / "archive_dir" / "photos" / result # 64x32 turned a quarter turn: the file on disk is the rotated one, not # what ffmpeg wrote. assert Image.open(io.BytesIO(written.read_bytes())).size == (32, 64) async def test_extracted_frame_is_untouched_without_a_rotation(tmp_path: Path, patched_session, monkeypatch): """The default path must not decode and re-encode ffmpeg's output for nothing — that would cost a generation of JPEG quality on every print.""" monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path) video_relpath = Path("archive/1/print/timelapse.mp4") video_abspath = tmp_path / video_relpath video_abspath.parent.mkdir(parents=True, exist_ok=True) video_abspath.write_bytes(b"x" * 100) extracted = b"\xff\xd8" + b"\x00" * 50 async def fake_extract(src, dst): dst.write_bytes(extracted) return True monkeypatch.setattr(main_module, "_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS", 0.0) patched_session.timelapse_path = str(video_relpath) with patch("backend.app.services.camera.extract_video_last_frame", new=fake_extract): result, _ = await _capture_finish_photo_from_timelapse( archive_id=42, archive_dir=tmp_path / "archive_dir", ) assert (tmp_path / "archive_dir" / "photos" / result).read_bytes() == extracted