|
|
@@ -4,6 +4,7 @@ Tests for the layer timelapse service.
|
|
|
These tests cover session management and pure logic functions.
|
|
|
"""
|
|
|
|
|
|
+import time
|
|
|
from datetime import datetime
|
|
|
from pathlib import Path
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
@@ -318,3 +319,235 @@ class TestGetActiveSessions:
|
|
|
assert 1 in _active_sessions
|
|
|
|
|
|
cancel_session(1)
|
|
|
+
|
|
|
+
|
|
|
+class TestCleanupOrphanedTimelapseSessions:
|
|
|
+ """_active_sessions is in-memory only, so a process restart mid-print
|
|
|
+ loses track of an active session without ever cleaning up its frames
|
|
|
+ directory (or a stitched-but-not-attached output .mp4). Confirmed live:
|
|
|
+ 38MB of exactly this leftover on Carl's OrangePi after several restarts
|
|
|
+ during testing. cleanup_orphaned_timelapse_sessions() sweeps for it."""
|
|
|
+
|
|
|
+ def _touch_old(self, path, age_seconds=600):
|
|
|
+ import os
|
|
|
+
|
|
|
+ path.touch()
|
|
|
+ old = time.time() - age_seconds
|
|
|
+ os.utime(path, (old, old))
|
|
|
+
|
|
|
+ def _mkdir_old(self, path, age_seconds=600):
|
|
|
+ import os
|
|
|
+
|
|
|
+ path.mkdir(parents=True)
|
|
|
+ old = time.time() - age_seconds
|
|
|
+ os.utime(path, (old, old))
|
|
|
+
|
|
|
+ def test_removes_orphaned_frame_dir_and_stray_output(self, tmp_path):
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ printer_dir = tmp_path / "timelapse_frames" / "1"
|
|
|
+ self._mkdir_old(printer_dir / "20260101_000000")
|
|
|
+ self._touch_old(printer_dir / "timelapse_20260101_000000.mp4")
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 2
|
|
|
+ assert not (printer_dir / "20260101_000000").exists()
|
|
|
+ assert not (printer_dir / "timelapse_20260101_000000.mp4").exists()
|
|
|
+
|
|
|
+ def test_spares_the_currently_active_session(self, tmp_path):
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ TimelapseSession,
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ session = TimelapseSession(1, 100, "/dev/video1", "usb")
|
|
|
+ _active_sessions[1] = session
|
|
|
+ import os
|
|
|
+
|
|
|
+ old = time.time() - 600
|
|
|
+ os.utime(session.frames_dir, (old, old))
|
|
|
+
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 0
|
|
|
+ assert session.frames_dir.exists()
|
|
|
+ _active_sessions.clear()
|
|
|
+
|
|
|
+ def test_spares_recently_modified_entries(self, tmp_path):
|
|
|
+ """Defensive margin: something modified within min_age_seconds is
|
|
|
+ left alone even if it doesn't match an active session, in case this
|
|
|
+ is ever invoked while a session is mid-creation."""
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ printer_dir = tmp_path / "timelapse_frames" / "1"
|
|
|
+ printer_dir.mkdir(parents=True)
|
|
|
+ (printer_dir / "20260101_000000").mkdir()
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 0
|
|
|
+ assert (printer_dir / "20260101_000000").exists()
|
|
|
+
|
|
|
+ def test_no_base_dir_is_a_no_op(self, tmp_path):
|
|
|
+ from backend.app.services.layer_timelapse import cleanup_orphaned_timelapse_sessions
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path / "does-not-exist"
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions()
|
|
|
+
|
|
|
+ assert removed == 0
|
|
|
+
|
|
|
+ def test_ignores_non_numeric_printer_dirs(self, tmp_path):
|
|
|
+ """Defensive: unrelated directories under timelapse_frames/ (there
|
|
|
+ shouldn't be any, but printer_id is parsed from the dir name) must
|
|
|
+ not raise."""
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ (tmp_path / "timelapse_frames" / "not-a-printer-id").mkdir(parents=True)
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions()
|
|
|
+
|
|
|
+ assert removed == 0
|
|
|
+
|
|
|
+ def test_spares_a_session_that_is_mid_stitch(self, tmp_path):
|
|
|
+ """on_print_complete drops the session from _active_sessions before it
|
|
|
+ hands frames_dir to ffmpeg, so for the length of a stitch (up to 300s)
|
|
|
+ the directory matches no active session. Its mtime is the last layer's
|
|
|
+ frame write, which on a tall print's final layer is easily older than
|
|
|
+ the age margin — and the margin's default IS the stitch timeout, so it
|
|
|
+ offers no headroom here. _finalizing_sessions covers that window."""
|
|
|
+ import os
|
|
|
+
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ TimelapseSession,
|
|
|
+ _active_sessions,
|
|
|
+ _finalizing_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ _finalizing_sessions.clear()
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ session = TimelapseSession(1, 100, "/dev/video1", "usb")
|
|
|
+ (session.frames_dir / "layer_00001.jpg").write_bytes(b"x")
|
|
|
+ old = time.time() - 600
|
|
|
+ os.utime(session.frames_dir, (old, old))
|
|
|
+
|
|
|
+ # Exactly the state on_print_complete is in while ffmpeg runs.
|
|
|
+ _active_sessions.pop(1, None)
|
|
|
+ _finalizing_sessions[1] = session.session_id
|
|
|
+
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 0
|
|
|
+ assert session.frames_dir.exists(), "ffmpeg's input was deleted mid-stitch"
|
|
|
+ _finalizing_sessions.clear()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_on_print_complete_clears_the_finalizing_marker(self, tmp_path):
|
|
|
+ """Including when the stitch fails — a leaked marker would make the
|
|
|
+ sweep skip that printer's leftovers forever."""
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ TimelapseSession,
|
|
|
+ _active_sessions,
|
|
|
+ _finalizing_sessions,
|
|
|
+ on_print_complete,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ _finalizing_sessions.clear()
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ session = TimelapseSession(1, 100, "/dev/video1", "usb")
|
|
|
+ session.frame_count = 3
|
|
|
+ _active_sessions[1] = session
|
|
|
+
|
|
|
+ with patch.object(TimelapseSession, "stitch", AsyncMock(side_effect=RuntimeError("ffmpeg died"))):
|
|
|
+ result = await on_print_complete(1)
|
|
|
+
|
|
|
+ assert result is None
|
|
|
+ assert 1 not in _finalizing_sessions
|
|
|
+
|
|
|
+ def test_leaves_unrelated_files_alone(self, tmp_path):
|
|
|
+ """Only this module's own artifacts are swept. A file that is neither a
|
|
|
+ session directory nor timelapse_<id>.mp4 was put there by something
|
|
|
+ else, and age is not a reason to delete it."""
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ printer_dir = tmp_path / "timelapse_frames" / "1"
|
|
|
+ printer_dir.mkdir(parents=True)
|
|
|
+ stranger = printer_dir / "notes.txt"
|
|
|
+ self._touch_old(stranger)
|
|
|
+ self._touch_old(printer_dir / "timelapse_20260101_000000.mp4")
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 1
|
|
|
+ assert stranger.exists()
|
|
|
+ assert not (printer_dir / "timelapse_20260101_000000.mp4").exists()
|
|
|
+
|
|
|
+ def test_a_removal_that_fails_is_not_counted_as_removed(self, tmp_path):
|
|
|
+ """The count and the log line are the only evidence an operator has of
|
|
|
+ what was deleted, so a failed rmtree must not be reported as a success.
|
|
|
+
|
|
|
+ The stub honours rmtree's real contract — ignore_errors=True swallows
|
|
|
+ the failure and returns normally — because that is the whole point: a
|
|
|
+ caller passing it gets a silent no-op that the surrounding
|
|
|
+ ``except OSError`` can never see, and would still count and log the
|
|
|
+ directory as removed. A stub that raised unconditionally would pass
|
|
|
+ either way and prove nothing.
|
|
|
+ """
|
|
|
+ from backend.app.services.layer_timelapse import (
|
|
|
+ _active_sessions,
|
|
|
+ cleanup_orphaned_timelapse_sessions,
|
|
|
+ )
|
|
|
+
|
|
|
+ _active_sessions.clear()
|
|
|
+ printer_dir = tmp_path / "timelapse_frames" / "1"
|
|
|
+ self._mkdir_old(printer_dir / "20260101_000000")
|
|
|
+
|
|
|
+ def rmtree_on_read_only_fs(path, ignore_errors=False, **kwargs):
|
|
|
+ if ignore_errors:
|
|
|
+ return # silently does nothing, exactly like the real thing
|
|
|
+ raise OSError("read-only fs")
|
|
|
+
|
|
|
+ with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
|
|
+ mock_settings.base_dir = tmp_path
|
|
|
+ with patch("backend.app.services.layer_timelapse.shutil.rmtree", rmtree_on_read_only_fs):
|
|
|
+ removed = cleanup_orphaned_timelapse_sessions(min_age_seconds=300)
|
|
|
+
|
|
|
+ assert removed == 0, "a directory that is still on disk was reported as removed"
|
|
|
+ assert (printer_dir / "20260101_000000").exists()
|