test_external_camera.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """
  2. Tests for the external camera service.
  3. These tests cover pure functions and frame parsing logic.
  4. """
  5. from unittest.mock import MagicMock, patch
  6. import pytest
  7. class TestFormatMjpegFrame:
  8. """Tests for MJPEG frame formatting."""
  9. def test_format_mjpeg_frame_basic(self):
  10. """Verify MJPEG frame is formatted correctly with boundary and headers."""
  11. from backend.app.services.external_camera import _format_mjpeg_frame
  12. # Minimal JPEG data (just SOI and EOI markers)
  13. jpeg_data = b"\xff\xd8\xff\xd9"
  14. result = _format_mjpeg_frame(jpeg_data)
  15. # Check boundary
  16. assert result.startswith(b"--frame\r\n")
  17. # Check content type
  18. assert b"Content-Type: image/jpeg\r\n" in result
  19. # Check content length
  20. assert b"Content-Length: 4\r\n" in result
  21. # Check frame data is included
  22. assert jpeg_data in result
  23. # Check ends with CRLF
  24. assert result.endswith(b"\r\n")
  25. def test_format_mjpeg_frame_larger_data(self):
  26. """Verify content length is correct for larger frames."""
  27. from backend.app.services.external_camera import _format_mjpeg_frame
  28. # Simulate a larger JPEG (1000 bytes)
  29. jpeg_data = b"\xff\xd8" + b"\x00" * 996 + b"\xff\xd9"
  30. result = _format_mjpeg_frame(jpeg_data)
  31. assert b"Content-Length: 1000\r\n" in result
  32. class TestGetFfmpegPath:
  33. """Tests for ffmpeg path detection."""
  34. def test_get_ffmpeg_path_from_shutil_which(self):
  35. """Verify ffmpeg found via shutil.which is returned."""
  36. from backend.app.services.external_camera import get_ffmpeg_path
  37. with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
  38. result = get_ffmpeg_path()
  39. assert result == "/usr/bin/ffmpeg"
  40. def test_get_ffmpeg_path_fallback_to_common_paths(self):
  41. """Verify common paths are checked when shutil.which fails."""
  42. from backend.app.services.external_camera import get_ffmpeg_path
  43. with patch("shutil.which", return_value=None), patch("pathlib.Path.exists") as mock_exists:
  44. # First common path exists
  45. mock_exists.return_value = True
  46. result = get_ffmpeg_path()
  47. assert result in ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"]
  48. def test_get_ffmpeg_path_returns_none_when_not_found(self):
  49. """Verify None is returned when ffmpeg not found anywhere."""
  50. from backend.app.services.external_camera import get_ffmpeg_path
  51. with patch("shutil.which", return_value=None), patch("pathlib.Path.exists", return_value=False):
  52. result = get_ffmpeg_path()
  53. assert result is None
  54. class TestJpegFrameExtraction:
  55. """Tests for JPEG frame extraction from buffer."""
  56. def test_extract_single_frame_from_buffer(self):
  57. """Test extracting a complete JPEG frame from buffer."""
  58. # JPEG markers
  59. jpeg_start = b"\xff\xd8"
  60. jpeg_end = b"\xff\xd9"
  61. # Create a buffer with one complete frame
  62. frame_content = b"\x00" * 100
  63. buffer = jpeg_start + frame_content + jpeg_end
  64. # Find frame boundaries
  65. start_idx = buffer.find(jpeg_start)
  66. end_idx = buffer.find(jpeg_end, start_idx + 2)
  67. assert start_idx == 0
  68. assert end_idx == 102
  69. # Extract frame
  70. frame = buffer[start_idx : end_idx + 2]
  71. assert frame == buffer
  72. assert len(frame) == 104
  73. def test_extract_frame_with_leading_garbage(self):
  74. """Test extracting frame when buffer has leading garbage data."""
  75. jpeg_start = b"\xff\xd8"
  76. jpeg_end = b"\xff\xd9"
  77. # Buffer with garbage before the JPEG
  78. garbage = b"\x00\x01\x02\x03"
  79. frame_content = b"\xff" * 50
  80. buffer = garbage + jpeg_start + frame_content + jpeg_end
  81. start_idx = buffer.find(jpeg_start)
  82. assert start_idx == 4 # After garbage
  83. end_idx = buffer.find(jpeg_end, start_idx + 2)
  84. frame = buffer[start_idx : end_idx + 2]
  85. assert frame.startswith(jpeg_start)
  86. assert frame.endswith(jpeg_end)
  87. assert len(frame) == 54 # 2 + 50 + 2
  88. def test_incomplete_frame_detection(self):
  89. """Test detection of incomplete frame (no end marker)."""
  90. jpeg_start = b"\xff\xd8"
  91. # Incomplete buffer - no end marker
  92. buffer = jpeg_start + b"\x00" * 100
  93. start_idx = buffer.find(jpeg_start)
  94. end_idx = buffer.find(b"\xff\xd9", start_idx + 2)
  95. assert start_idx == 0
  96. assert end_idx == -1 # Not found
  97. def test_multiple_frames_in_buffer(self):
  98. """Test extracting first frame when buffer contains multiple frames."""
  99. jpeg_start = b"\xff\xd8"
  100. jpeg_end = b"\xff\xd9"
  101. # Two complete frames
  102. frame1 = jpeg_start + b"\x01" * 10 + jpeg_end
  103. frame2 = jpeg_start + b"\x02" * 20 + jpeg_end
  104. buffer = frame1 + frame2
  105. # Extract first frame
  106. start_idx = buffer.find(jpeg_start)
  107. end_idx = buffer.find(jpeg_end, start_idx + 2)
  108. first_frame = buffer[start_idx : end_idx + 2]
  109. assert first_frame == frame1
  110. assert len(first_frame) == 14
  111. # Remaining buffer should contain second frame
  112. remaining = buffer[end_idx + 2 :]
  113. assert remaining == frame2
  114. class TestCameraTypeValidation:
  115. """Tests for camera type handling."""
  116. @pytest.mark.asyncio
  117. async def test_capture_frame_unknown_type_returns_none(self):
  118. """Verify unknown camera type returns None."""
  119. from backend.app.services.external_camera import capture_frame
  120. result = await capture_frame("http://example.com", "unknown_type")
  121. assert result is None
  122. @pytest.mark.asyncio
  123. async def test_capture_frame_valid_types(self):
  124. """Verify valid camera types are accepted (they may fail but shouldn't error on type)."""
  125. from backend.app.services.external_camera import capture_frame
  126. # These will fail to connect but shouldn't raise type errors
  127. for camera_type in ["mjpeg", "rtsp", "snapshot"]:
  128. # Use a non-routable IP to fail fast
  129. result = await capture_frame("http://192.0.2.1/test", camera_type, timeout=1)
  130. # Should return None (failed connection) not raise exception
  131. assert result is None
  132. class TestRtspUrlHandling:
  133. """Tests for RTSP/RTSPS URL handling."""
  134. def test_rtsps_url_detection(self):
  135. """Verify rtsps:// and rtsp:// URL schemes are distinct."""
  136. url_rtsps = "rtsps://user:pass@192.168.1.1:554/stream"
  137. url_rtsp = "rtsp://user:pass@192.168.1.1:554/stream"
  138. assert url_rtsps.startswith("rtsps://")
  139. assert not url_rtsp.startswith("rtsps://")
  140. assert url_rtsp.startswith("rtsp://")
  141. def test_ffmpeg_handles_both_rtsp_and_rtsps(self):
  142. """Verify ffmpeg command structure handles both URL schemes identically.
  143. ffmpeg automatically handles TLS for rtsps:// URLs, so no special
  144. flags are needed - both URL schemes use the same command structure.
  145. """
  146. # Both URL types should use the same basic ffmpeg options
  147. base_cmd = [
  148. "ffmpeg",
  149. "-rtsp_transport",
  150. "tcp",
  151. "-i",
  152. ]
  153. rtsp_url = "rtsp://user:pass@192.168.1.1:554/stream"
  154. rtsps_url = "rtsps://user:pass@192.168.1.1:554/stream"
  155. # Command structure is identical for both
  156. cmd_rtsp = base_cmd + [rtsp_url]
  157. cmd_rtsps = base_cmd + [rtsps_url]
  158. # Only the URL differs
  159. assert cmd_rtsp[:-1] == cmd_rtsps[:-1]
  160. assert cmd_rtsp[-1] != cmd_rtsps[-1]