| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- """
- Tests for the external camera service.
- These tests cover pure functions and frame parsing logic.
- """
- from unittest.mock import patch
- import pytest
- class TestFormatMjpegFrame:
- """Tests for MJPEG frame formatting."""
- def test_format_mjpeg_frame_basic(self):
- """Verify MJPEG frame is formatted correctly with boundary and headers."""
- from backend.app.services.external_camera import _format_mjpeg_frame
- # Minimal JPEG data (just SOI and EOI markers)
- jpeg_data = b"\xff\xd8\xff\xd9"
- result = _format_mjpeg_frame(jpeg_data)
- # Check boundary
- assert result.startswith(b"--frame\r\n")
- # Check content type
- assert b"Content-Type: image/jpeg\r\n" in result
- # Check content length
- assert b"Content-Length: 4\r\n" in result
- # Check frame data is included
- assert jpeg_data in result
- # Check ends with CRLF
- assert result.endswith(b"\r\n")
- def test_format_mjpeg_frame_larger_data(self):
- """Verify content length is correct for larger frames."""
- from backend.app.services.external_camera import _format_mjpeg_frame
- # Simulate a larger JPEG (1000 bytes)
- jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
- result = _format_mjpeg_frame(jpeg_data)
- assert b"Content-Length: 1000\r\n" in result
- class TestGetFfmpegPath:
- """Tests for ffmpeg path detection."""
- def test_get_ffmpeg_path_from_shutil_which(self):
- """Verify ffmpeg found via shutil.which is returned."""
- from backend.app.services.external_camera import get_ffmpeg_path
- with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
- result = get_ffmpeg_path()
- assert result == "/usr/bin/ffmpeg"
- def test_get_ffmpeg_path_fallback_to_common_paths(self):
- """Verify common paths are checked when shutil.which fails."""
- from backend.app.services.external_camera import get_ffmpeg_path
- with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
- # First common path exists
- mock_exists.return_value = True
- result = get_ffmpeg_path()
- assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
- def test_get_ffmpeg_path_returns_none_when_not_found(self):
- """Verify None is returned when ffmpeg not found anywhere."""
- from backend.app.services.external_camera import get_ffmpeg_path
- with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
- result = get_ffmpeg_path()
- assert result is None
- class TestJpegFrameExtraction:
- """Tests for JPEG frame extraction from buffer."""
- def test_extract_single_frame_from_buffer(self):
- """Test extracting a complete JPEG frame from buffer."""
- # JPEG markers
- jpeg_start = b"\xff\xd8"
- jpeg_end = b"\xff\xd9"
- # Create a buffer with one complete frame
- frame_content = b"\x00" * 100
- buffer = jpeg_start + frame_content + jpeg_end
- # Find frame boundaries
- start_idx = buffer.find(jpeg_start)
- end_idx = buffer.find(jpeg_end, start_idx + 2)
- assert start_idx == 0
- assert end_idx == 102
- # Extract frame
- frame = buffer[start_idx : end_idx + 2]
- assert frame == buffer
- assert len(frame) == 104
- def test_extract_frame_with_leading_garbage(self):
- """Test extracting frame when buffer has leading garbage data."""
- jpeg_start = b"\xff\xd8"
- jpeg_end = b"\xff\xd9"
- # Buffer with garbage before the JPEG
- garbage = b"\x00\x01\x02\x03"
- frame_content = b"\xff" * 50
- buffer = garbage + jpeg_start + frame_content + jpeg_end
- start_idx = buffer.find(jpeg_start)
- assert start_idx == 4 # After garbage
- end_idx = buffer.find(jpeg_end, start_idx + 2)
- frame = buffer[start_idx : end_idx + 2]
- assert frame.startswith(jpeg_start)
- assert frame.endswith(jpeg_end)
- assert len(frame) == 54 # 2 + 50 + 2
- def test_incomplete_frame_detection(self):
- """Test detection of incomplete frame (no end marker)."""
- jpeg_start = b"\xff\xd8"
- # Incomplete buffer - no end marker
- buffer = jpeg_start + b"\x00" * 100
- start_idx = buffer.find(jpeg_start)
- end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
- assert start_idx == 0
- assert end_idx == -1 # Not found
- def test_multiple_frames_in_buffer(self):
- """Test extracting first frame when buffer contains multiple frames."""
- jpeg_start = b"\xff\xd8"
- jpeg_end = b"\xff\xd9"
- # Two complete frames
- frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
- frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
- buffer = frame1 + frame2
- # Extract first frame
- start_idx = buffer.find(jpeg_start)
- end_idx = buffer.find(jpeg_end, start_idx + 2)
- first_frame = buffer[start_idx : end_idx + 2]
- assert first_frame == frame1
- assert len(first_frame) == 14
- # Remaining buffer should contain second frame
- remaining = buffer[end_idx + 2 :]
- assert remaining == frame2
- class TestCameraTypeValidation:
- """Tests for camera type handling."""
- @pytest.mark.asyncio
- async def test_capture_frame_unknown_type_returns_none(self):
- """Verify unknown camera type returns None."""
- from backend.app.services.external_camera import capture_frame
- result = await capture_frame("http://example.com", "unknown_type")
- assert result is None
- @pytest.mark.asyncio
- async def test_capture_frame_valid_types(self):
- """Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
- from backend.app.services.external_camera import capture_frame
- # These will fail to connect but shouldn't raise type errors
- for camera_type in ["mjpeg", "rtsp", "snapshot"]:
- # Use a non-routable IP to fail fast
- result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
- # Should return None (failed connection) not raise exception
- assert result is None
- class TestRtspUrlHandling:
- """Tests for RTSP/RTSPS URL handling."""
- def test_rtsps_url_detection(self):
- """Verify rtsps:// and rtsp:// URL schemes are distinct."""
- url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
- url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
- assert url_rtsps.startswith("rtsps://")
- assert not url_rtsp.startswith("rtsps://")
- assert url_rtsp.startswith("rtsp://")
- def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
- """Verify ffmpeg command structure handles both URL schemes identically.
- ffmpeg automatically handles TLS for rtsps:// URLs, so no special
- flags are needed - both URL schemes use the same command structure.
- """
- # Both URL types should use the same basic ffmpeg options
- base_cmd = [
- "ffmpeg",
- "-rtsp_transport",
- "tcp",
- "-i",
- ]
- rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
- rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
- # Command structure is identical for both
- cmd_rtsp = base_cmd + [rtsp_url]
- cmd_rtsps = base_cmd + [rtsps_url]
- # Only the URL differs
- assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
- assert cmd_rtsp[-1] != cmd_rtsps[-1]
- class TestUsbCameraHandling:
- """Tests for USB camera support."""
- def test_list_usb_cameras_returns_list(self):
- """Verify list_usb_cameras returns a list (may be empty if no cameras)."""
- from backend.app.services.external_camera import list_usb_cameras
- result = list_usb_cameras()
- assert isinstance(result, list)
- def test_list_usb_cameras_dict_structure(self):
- """Verify each camera entry has expected fields."""
- from backend.app.services.external_camera import list_usb_cameras
- result = list_usb_cameras()
- for camera in result:
- assert "device" in camera
- assert "name" in camera
- assert camera["device"].startswith("/dev/video")
- @pytest.mark.asyncio
- async def test_capture_frame_usb_type_accepted(self):
- """Verify 'usb' camera type is accepted."""
- from backend.app.services.external_camera import capture_frame
- # Non-existent device should fail gracefully
- result = await capture_frame("/dev/video999", "usb", timeout=1)
- assert result is None
- @pytest.mark.asyncio
- async def test_capture_frame_usb_invalid_device_path(self):
- """Verify invalid USB device paths are rejected."""
- from backend.app.services.external_camera import capture_frame
- # Invalid device path (not /dev/video*)
- result = await capture_frame("/dev/sda1", "usb", timeout=1)
- assert result is None
- result = await capture_frame("http://example.com", "usb", timeout=1)
- assert result is None
|