deltacast-sdi-recorder/backend/tests/test_hls.py

327 lines
11 KiB
Python

"""Tests for HLS preview manager functionality."""
import pytest
import asyncio
from pathlib import Path
from unittest.mock import Mock, AsyncMock, MagicMock, patch, call, PropertyMock
from backend.app.config import Settings
from backend.app.utils.hls import HLSPreviewManager
@pytest.fixture
def settings():
"""Create test settings."""
return Settings(
ffmpeg_path="/usr/bin/ffmpeg",
recording_dir="/recordings",
deltacast_port_count=4,
srt_enabled=True,
srt_latency=5000,
)
@pytest.fixture
def hls_manager(settings, tmp_path):
"""Create HLS preview manager with temp directory."""
return HLSPreviewManager(settings, hls_dir=str(tmp_path / "hls"))
class TestHLSManagerInitialization:
"""Test HLS manager initialization."""
def test_hls_manager_initial_state(self, hls_manager):
"""No previews running initially."""
assert hls_manager._processes == {}
assert len(hls_manager._processes) == 0
class TestHLSCommandBuilder:
"""Test HLS FFmpeg command building."""
def test_build_hls_command_includes_deltacast_input(self, hls_manager):
"""Command includes deltacast:// input."""
command = hls_manager._build_hls_command(0)
assert "deltacast://0" in command
def test_build_hls_command_includes_hls_output(self, hls_manager):
"""Command includes -f hls output."""
command = hls_manager._build_hls_command(0)
assert "-f" in command
assert "hls" in command
def test_build_hls_command_has_correct_video_params(self, hls_manager):
"""Command includes libx264, 5M bitrate, 1280x720."""
command = hls_manager._build_hls_command(0)
assert "libx264" in command
assert "-b:v" in command
assert "5M" in command
assert "-s" in command
assert "1280x720" in command
def test_build_hls_command_has_hls_time_5(self, hls_manager):
"""Command includes -hls_time 5."""
command = hls_manager._build_hls_command(0)
assert "-hls_time" in command
assert "5" in command
def test_build_hls_command_has_zerolatency_tune(self, hls_manager):
"""Command includes -tune zerolatency."""
command = hls_manager._build_hls_command(0)
assert "-tune" in command
assert "zerolatency" in command
def test_build_hls_command_has_hls_list_size_3(self, hls_manager):
"""Command includes -hls_list_size 3."""
command = hls_manager._build_hls_command(0)
assert "-hls_list_size" in command
assert "3" in command
def test_build_hls_command_has_delete_segments_flag(self, hls_manager):
"""Command includes -hls_flags delete_segments."""
command = hls_manager._build_hls_command(0)
assert "-hls_flags" in command
assert "delete_segments" in command
class TestPlaylistPath:
"""Test playlist path generation."""
def test_get_playlist_path(self, hls_manager):
"""Returns correct .m3u8 file path."""
path = hls_manager.get_playlist_path(0)
assert isinstance(path, Path)
assert path.name == "port_0.m3u8"
assert "hls" in str(path)
def test_get_playlist_path_different_ports(self, hls_manager):
"""Returns different paths for different ports."""
path0 = hls_manager.get_playlist_path(0)
path1 = hls_manager.get_playlist_path(1)
path4 = hls_manager.get_playlist_path(4)
assert path0.name == "port_0.m3u8"
assert path1.name == "port_1.m3u8"
assert path4.name == "port_4.m3u8"
class TestPreviewState:
"""Test preview state tracking."""
def test_is_previewing_false_initially(self, hls_manager):
"""is_previewing returns False when not started."""
assert not hls_manager.is_previewing(0)
assert not hls_manager.is_previewing(1)
def test_is_previewing_false_when_not_in_dict(self, hls_manager):
"""is_previewing returns False for unknown port."""
assert not hls_manager.is_previewing(999)
def create_mock_process():
"""Create a properly mocked subprocess.Process."""
mock_process = MagicMock()
mock_process.pid = 12345
mock_process.done.return_value = False
mock_stdin = MagicMock()
mock_stdin.is_closing.return_value = False
mock_process.stdin = mock_stdin
# Make wait an async function
mock_process.wait = AsyncMock(return_value=None)
# Make stdin methods async
mock_stdin.write = MagicMock(return_value=None)
mock_stdin.drain = AsyncMock()
return mock_process
@pytest.mark.asyncio
async def test_start_preview_sets_state(hls_manager, tmp_path):
"""is_previewing returns True after start_preview."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
await hls_manager.start_preview(0)
assert hls_manager.is_previewing(0)
assert hls_manager._processes[0] is not None
assert hls_manager._processes[0].pid == 12345
@pytest.mark.asyncio
async def test_start_preview_noop_when_already_running(hls_manager):
"""start_preview is no-op if already running."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
# Start first time
await hls_manager.start_preview(0)
assert mock_create.call_count == 1
# Try to start again
await hls_manager.start_preview(0)
# Should not create new process
assert mock_create.call_count == 1
@pytest.mark.asyncio
async def test_start_preview_creates_hls_dir(hls_manager, tmp_path):
"""start_preview creates HLS directory if it doesn't exist."""
hls_dir = tmp_path / "new_hls"
manager = HLSPreviewManager(hls_manager.settings, hls_dir=str(hls_dir))
assert not hls_dir.exists()
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
await manager.start_preview(0)
assert hls_dir.exists()
@pytest.mark.asyncio
async def test_stop_preview_noop_when_not_running(hls_manager):
"""stop_preview does nothing if not running."""
# Should not raise, just log and return
await hls_manager.stop_preview(0)
assert 0 not in hls_manager._processes or hls_manager._processes[0] is None
@pytest.mark.asyncio
async def test_stop_preview_graceful_shutdown(hls_manager):
"""stop_preview sends quit command first."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
await hls_manager.start_preview(0)
await hls_manager.stop_preview(0)
# Verify stdin write was called
mock_process.stdin.write.assert_called_with(b"q\n")
mock_process.stdin.drain.assert_called()
# Verify process is cleaned up
assert hls_manager._processes[0] is None
@pytest.mark.asyncio
async def test_stop_preview_sigterm_fallback(hls_manager):
"""stop_preview sends SIGTERM if graceful quit times out."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
# First wait (graceful): timeout
# Second wait (SIGTERM): success
mock_process.wait = AsyncMock(side_effect=[
asyncio.TimeoutError(),
None # Process exits after SIGTERM
])
mock_create.return_value = mock_process
await hls_manager.start_preview(0)
await hls_manager.stop_preview(0)
# Verify terminate was called
mock_process.terminate.assert_called_once()
@pytest.mark.asyncio
async def test_stop_preview_sigkill_fallback(hls_manager):
"""stop_preview sends SIGKILL if SIGTERM times out."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
# Both waits timeout: graceful then SIGTERM
# Then SIGKILL and final wait
mock_process.wait = AsyncMock(side_effect=[
asyncio.TimeoutError(), # Graceful timeout
asyncio.TimeoutError(), # SIGTERM timeout
None # Process exits after SIGKILL
])
mock_create.return_value = mock_process
await hls_manager.start_preview(0)
await hls_manager.stop_preview(0)
# Verify kill was called
mock_process.kill.assert_called_once()
@pytest.mark.asyncio
async def test_stop_preview_handles_process_lookup_error(hls_manager):
"""stop_preview handles ProcessLookupError gracefully."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
# Graceful quit timeout, then ProcessLookupError on terminate
mock_process.wait.return_value = None
mock_process.terminate.side_effect = ProcessLookupError()
mock_create.return_value = mock_process
await hls_manager.start_preview(0)
await hls_manager.stop_preview(0)
# Should clean up gracefully
assert hls_manager._processes[0] is None
@pytest.mark.asyncio
async def test_stop_all_stops_multiple_ports(hls_manager):
"""stop_all stops all running previews."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
# Start previews on ports 0, 1, 2
await hls_manager.start_preview(0)
await hls_manager.start_preview(1)
await hls_manager.start_preview(2)
assert hls_manager.is_previewing(0)
assert hls_manager.is_previewing(1)
assert hls_manager.is_previewing(2)
# Stop all
await hls_manager.stop_all()
# All should be stopped
assert not hls_manager.is_previewing(0)
assert not hls_manager.is_previewing(1)
assert not hls_manager.is_previewing(2)
@pytest.mark.asyncio
async def test_start_preview_failure_is_raised(hls_manager):
"""start_preview raises exception on subprocess creation failure."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_create.side_effect = OSError("FFmpeg not found")
with pytest.raises(OSError):
await hls_manager.start_preview(0)
@pytest.mark.asyncio
async def test_start_preview_builds_correct_command(hls_manager):
"""start_preview builds and executes correct FFmpeg command."""
with patch('asyncio.create_subprocess_exec') as mock_create:
mock_process = create_mock_process()
mock_create.return_value = mock_process
await hls_manager.start_preview(3)
# Get the command that was passed to create_subprocess_exec
call_args = mock_create.call_args
command = call_args[0] # Positional args are unpacked
# Verify command structure
assert command[0] == hls_manager.settings.ffmpeg_path
assert "deltacast://3" in command
assert "-f" in command
assert "hls" in command
assert "-hls_time" in command
assert "5" in command