2026-07-16 09:05:21 -05:00
|
|
|
"""Tests for the camera delete endpoint's runtime config handling."""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
|
|
|
|
|
|
import ruamel.yaml
|
|
|
|
|
|
|
|
|
|
from frigate.config import FrigateConfig
|
|
|
|
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
|
|
|
|
from frigate.models import Event, Recordings, ReviewSegment
|
|
|
|
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDeleteCameraRuntimeConfig(BaseTestHttp):
|
|
|
|
|
"""Deleting a camera must keep the API and dispatcher on the same config."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
|
|
|
|
self.minimal_config = {
|
|
|
|
|
"mqtt": {"host": "mqtt"},
|
|
|
|
|
"cameras": {
|
|
|
|
|
"front_door": {
|
|
|
|
|
"ffmpeg": {
|
|
|
|
|
"inputs": [
|
|
|
|
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
|
|
|
|
},
|
|
|
|
|
"back_yard": {
|
|
|
|
|
"ffmpeg": {
|
|
|
|
|
"inputs": [
|
|
|
|
|
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"detect": {"height": 720, "width": 1280, "fps": 10},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _write_config_file(self):
|
|
|
|
|
yaml = ruamel.yaml.YAML()
|
|
|
|
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
|
|
|
|
yaml.dump(self.minimal_config, f)
|
|
|
|
|
f.close()
|
|
|
|
|
return f.name
|
|
|
|
|
|
|
|
|
|
def _create_app_with_dispatcher(self, dispatcher):
|
|
|
|
|
from fastapi import Request
|
|
|
|
|
|
|
|
|
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
|
|
|
|
from frigate.api.fastapi_app import create_fastapi_app
|
|
|
|
|
|
|
|
|
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
|
|
|
|
mock_publisher.publisher = MagicMock()
|
|
|
|
|
|
|
|
|
|
app = create_fastapi_app(
|
|
|
|
|
FrigateConfig(**self.minimal_config),
|
|
|
|
|
self.db,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
mock_publisher,
|
|
|
|
|
None,
|
|
|
|
|
dispatcher=dispatcher,
|
|
|
|
|
enforce_default_admin=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def mock_get_current_user(request: Request):
|
|
|
|
|
return {
|
|
|
|
|
"username": request.headers.get("remote-user"),
|
|
|
|
|
"role": request.headers.get("remote-role"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def mock_get_allowed_cameras_for_filter(request: Request):
|
|
|
|
|
return list(self.minimal_config.get("cameras", {}).keys())
|
|
|
|
|
|
|
|
|
|
app.dependency_overrides[get_current_user] = mock_get_current_user
|
|
|
|
|
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
|
|
|
|
mock_get_allowed_cameras_for_filter
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return app, mock_publisher
|
|
|
|
|
|
|
|
|
|
@patch("frigate.api.camera.requests.delete")
|
|
|
|
|
@patch("frigate.api.camera.cleanup_camera_files")
|
|
|
|
|
@patch("frigate.api.camera.cleanup_camera_db")
|
|
|
|
|
@patch("frigate.api.camera.find_config_file")
|
|
|
|
|
def test_delete_syncs_dispatcher_and_prunes_runtime_state(
|
|
|
|
|
self, mock_find_config, mock_cleanup_db, mock_cleanup_files, mock_go2rtc_delete
|
|
|
|
|
):
|
|
|
|
|
"""Deleting a camera swaps every config reference and prunes its state."""
|
|
|
|
|
config_path = self._write_config_file()
|
|
|
|
|
mock_find_config.return_value = config_path
|
|
|
|
|
mock_cleanup_db.return_value = ({}, [])
|
|
|
|
|
|
|
|
|
|
dispatcher = MagicMock()
|
|
|
|
|
dispatcher.comms = []
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
app, _ = self._create_app_with_dispatcher(dispatcher)
|
|
|
|
|
|
|
|
|
|
with AuthTestClient(app) as client:
|
|
|
|
|
resp = client.delete("/cameras/front_door")
|
|
|
|
|
|
|
|
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
|
self.assertTrue(resp.json()["success"])
|
|
|
|
|
|
|
|
|
|
# the dispatcher must be moved onto the same new object the API
|
|
|
|
|
# now serves, and that object must no longer contain the camera
|
|
|
|
|
self.assertIs(dispatcher.config, app.frigate_config)
|
|
|
|
|
self.assertNotIn("front_door", dispatcher.config.cameras)
|
|
|
|
|
self.assertIn("back_yard", dispatcher.config.cameras)
|
|
|
|
|
|
|
|
|
|
# surviving cameras' overrides are re-layered onto the new object
|
2026-07-16 13:30:41 -05:00
|
|
|
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
2026-07-16 09:05:21 -05:00
|
|
|
|
|
|
|
|
# the deleted camera's persisted overrides are pruned
|
|
|
|
|
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
|
|
|
|
|
"front_door"
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
os.unlink(config_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|