Files
2026-07-13 12:10:44 +08:00

161 lines
6.2 KiB
Python

import datetime
import sys
import unittest
from unittest.mock import MagicMock, patch
# Mock complex imports before importing maintainer, saving originals so we can
# restore them after import and avoid polluting sys.modules for other tests.
_MOCKED_MODULES = [
"frigate.comms.inter_process",
"frigate.comms.detections_updater",
"frigate.comms.recordings_updater",
"frigate.config.camera.updater",
]
_originals = {name: sys.modules.get(name) for name in _MOCKED_MODULES}
for name in _MOCKED_MODULES:
sys.modules[name] = MagicMock()
# Now import the class under test
from frigate.config import FrigateConfig # noqa: E402
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
# Restore original modules (or remove mock if there was no original)
for name, orig in _originals.items():
if orig is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = orig
class TestMaintainer(unittest.IsolatedAsyncioTestCase):
async def test_move_files_survives_bad_filename(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
# We need to mock end_time_cache to avoid key errors if logic proceeds
maintainer.end_time_cache = {}
# Mock filesystem
# One bad file, one good file
files = ["bad_filename.mp4", "camera@20210101000000+0000.mp4"]
with patch("os.listdir", return_value=files):
with patch("os.path.isfile", return_value=True):
with patch(
"frigate.record.maintainer.psutil.process_iter", return_value=[]
):
with patch("frigate.record.maintainer.logger.warning") as warn:
# Mock validate_and_move_segment to avoid further logic
maintainer.validate_and_move_segment = MagicMock()
try:
await maintainer.move_files()
except ValueError as e:
if "not enough values to unpack" in str(e):
self.fail("move_files() crashed on bad filename!")
raise e
except Exception:
# Ignore other errors (like DB connection) as we only care about the unpack crash
pass
# The bad filename is encountered in multiple loops, but should only warn once.
matching = [
c
for c in warn.call_args_list
if c.args
and isinstance(c.args[0], str)
and "Skipping unexpected files in cache" in c.args[0]
]
self.assertEqual(
1,
len(matching),
f"Expected a single warning for unexpected files, got {len(matching)}",
)
async def test_drops_quiet_segment_when_only_motion_retention(self):
# Regression: when motion retention is enabled but a segment has no
# motion and no review overlaps it, the segment must still be dropped.
# Otherwise it sits in cache forever, accumulates, and triggers the
# "Unable to keep up with recording segments in cache" warning every
# ~10s as the overflow trim in move_files discards the oldest one.
config = MagicMock(spec=FrigateConfig)
camera_config = MagicMock()
camera_config.record.enabled = True
camera_config.record.continuous.days = 0
camera_config.record.motion.days = 1
camera_config.record.event_pre_capture = 5
config.cameras = {"test_cam": camera_config}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now(datetime.UTC)
start_time = now - datetime.timedelta(seconds=20)
end_time = now - datetime.timedelta(seconds=10)
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
maintainer.end_time_cache = {cache_path: (end_time, 10.0)}
# Single processed frame well past end_time with no motion/objects.
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
maintainer.audio_recordings_info["test_cam"] = []
maintainer.drop_segment = MagicMock()
maintainer.recordings_publisher = MagicMock()
result = await maintainer.validate_and_move_segment(
"test_cam",
reviews=[],
recording={"start_time": start_time, "cache_path": cache_path},
)
self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now().timestamp()
ancient = now - 86400
recent = now - 1
maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])]
maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])]
maintainer.object_recordings_info["absent_cam"] = [
(ancient, [], [], []),
(recent, [], [], []),
]
maintainer.audio_recordings_info["absent_cam"] = [
(ancient, 0, []),
(recent, 0, []),
]
grouped_recordings = {"present_cam": [{"start_time": ancient}]}
maintainer._expire_stale_recordings_info(grouped_recordings)
self.assertEqual(
maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])]
)
self.assertEqual(
maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])]
)
if __name__ == "__main__":
unittest.main()