Files
sdb2-backend/tests/services/test_vlc_player.py
JSC b4f0f54516
All checks were successful
Backend CI / lint (push) Successful in 18m8s
Backend CI / test (push) Successful in 53m35s
Refactor sound and extraction services to include user and timestamp fields
- Updated ExtractionInfo to include user_id, created_at, and updated_at fields.
- Modified ExtractionService to return user and timestamp information in extraction responses.
- Enhanced sound serialization in PlayerState to include extraction URL if available.
- Adjusted PlaylistRepository to load sound extractions when retrieving playlist sounds.
- Added tests for new fields in extraction and sound endpoints, ensuring proper response structure.
- Created new test file endpoints for sound downloads and thumbnail retrievals, including success and error cases.
- Refactored various test cases for consistency and clarity, ensuring proper mocking and assertions.
2025-08-03 20:54:14 +02:00

558 lines
20 KiB
Python

"""Tests for VLC player service."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.models.sound import Sound
from app.models.user import User
from app.services.vlc_player import VLCPlayerService, get_vlc_player_service
class TestVLCPlayerService:
"""Test VLC player service."""
@pytest.fixture
def vlc_service(self):
"""Create a VLC service instance."""
with patch("app.services.vlc_player.subprocess.run") as mock_run:
# Mock VLC executable detection
mock_run.return_value.returncode = 0
return VLCPlayerService()
@pytest.fixture
def vlc_service_with_db(self):
"""Create a VLC service instance with database session factory."""
with patch("app.services.vlc_player.subprocess.run") as mock_run:
# Mock VLC executable detection
mock_run.return_value.returncode = 0
mock_session_factory = Mock()
return VLCPlayerService(mock_session_factory)
@pytest.fixture
def sample_sound(self):
"""Create a sample sound for testing."""
return Sound(
id=1,
type="SDB",
name="Test Sound",
filename="test_audio.mp3",
duration=5000,
size=1024,
hash="test_hash",
is_normalized=False,
normalized_filename=None,
)
@pytest.fixture
def normalized_sound(self):
"""Create a normalized sound for testing."""
return Sound(
id=2,
type="TTS",
name="Normalized Sound",
filename="original.wav",
duration=7500,
size=2048,
hash="normalized_hash",
is_normalized=True,
normalized_filename="normalized.mp3",
)
def test_init(self, vlc_service) -> None:
"""Test VLC service initialization."""
assert vlc_service.vlc_executable is not None
assert isinstance(vlc_service.vlc_executable, str)
@patch("app.services.vlc_player.subprocess.run")
def test_find_vlc_executable_found_in_path(self, mock_run) -> None:
"""Test VLC executable detection when found in PATH."""
mock_run.return_value.returncode = 0
service = VLCPlayerService()
assert service.vlc_executable == "vlc"
@patch("app.services.vlc_player.subprocess.run")
def test_find_vlc_executable_found_by_path(self, mock_run) -> None:
"""Test VLC executable detection when found by absolute path."""
mock_run.return_value.returncode = 1 # which command fails
# Mock Path to return True for the first absolute path
with patch("app.services.vlc_player.Path") as mock_path:
def path_side_effect(path_str):
mock_instance = Mock()
mock_instance.exists.return_value = str(path_str) == "/usr/bin/vlc"
return mock_instance
mock_path.side_effect = path_side_effect
service = VLCPlayerService()
assert service.vlc_executable == "/usr/bin/vlc"
@patch("app.services.vlc_player.subprocess.run")
@patch("app.services.vlc_player.Path")
def test_find_vlc_executable_fallback(self, mock_path, mock_run) -> None:
"""Test VLC executable detection fallback to default."""
# Mock all paths as non-existent
mock_path_instance = Mock()
mock_path_instance.exists.return_value = False
mock_path.return_value = mock_path_instance
# Mock which command as failing
mock_run.return_value.returncode = 1
service = VLCPlayerService()
assert service.vlc_executable == "vlc"
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_play_sound_success(
self,
mock_subprocess,
vlc_service,
sample_sound,
) -> None:
"""Test successful sound playback."""
# Mock subprocess
mock_process = Mock()
mock_process.pid = 12345
mock_subprocess.return_value = mock_process
# Mock the file path utility to avoid Path issues
with patch("app.services.vlc_player.get_sound_file_path") as mock_get_path:
mock_path = Mock()
mock_path.exists.return_value = True
mock_get_path.return_value = mock_path
result = await vlc_service.play_sound(sample_sound)
assert result is True
mock_subprocess.assert_called_once()
args = mock_subprocess.call_args
# Check command arguments
cmd_args = args[1] # keyword arguments
assert "--play-and-exit" in args[0]
assert "--intf" in args[0]
assert "dummy" in args[0]
assert "--no-video" in args[0]
assert "--no-repeat" in args[0]
assert "--no-loop" in args[0]
assert cmd_args["stdout"] == asyncio.subprocess.DEVNULL
assert cmd_args["stderr"] == asyncio.subprocess.DEVNULL
@pytest.mark.asyncio
async def test_play_sound_file_not_found(
self,
vlc_service,
sample_sound,
) -> None:
"""Test sound playback when file doesn't exist."""
# Mock the file path utility to return a non-existent path
with patch("app.services.vlc_player.get_sound_file_path") as mock_get_path:
mock_path = Mock()
mock_path.exists.return_value = False
mock_get_path.return_value = mock_path
result = await vlc_service.play_sound(sample_sound)
assert result is False
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_play_sound_subprocess_error(
self,
mock_subprocess,
vlc_service,
sample_sound,
) -> None:
"""Test sound playback when subprocess fails."""
# Mock the file path utility to return an existing path
with patch("app.services.vlc_player.get_sound_file_path") as mock_get_path:
mock_path = Mock()
mock_path.exists.return_value = True
mock_get_path.return_value = mock_path
# Mock subprocess exception
mock_subprocess.side_effect = Exception("Subprocess failed")
result = await vlc_service.play_sound(sample_sound)
assert result is False
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_stop_all_vlc_instances_success(
self,
mock_subprocess,
vlc_service,
) -> None:
"""Test successful stopping of all VLC instances."""
# Mock pgrep process (find VLC processes)
mock_find_process = Mock()
mock_find_process.returncode = 0
mock_find_process.communicate = AsyncMock(
return_value=(b"12345\n67890\n", b""),
)
# Mock pkill process (kill VLC processes)
mock_kill_process = Mock()
mock_kill_process.communicate = AsyncMock(return_value=(b"", b""))
# Mock verify process (check remaining processes)
mock_verify_process = Mock()
mock_verify_process.returncode = 1 # No processes found
mock_verify_process.communicate = AsyncMock(return_value=(b"", b""))
# Set up subprocess mock to return different processes for each call
mock_subprocess.side_effect = [
mock_find_process,
mock_kill_process,
mock_verify_process,
]
result = await vlc_service.stop_all_vlc_instances()
assert result["success"] is True
assert result["processes_found"] == 2
assert result["processes_killed"] == 2
assert result["processes_remaining"] == 0
assert "Killed 2 VLC processes" in result["message"]
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_stop_all_vlc_instances_no_processes(
self,
mock_subprocess,
vlc_service,
) -> None:
"""Test stopping VLC instances when none are running."""
# Mock pgrep process (no VLC processes found)
mock_find_process = Mock()
mock_find_process.returncode = 1 # No processes found
mock_find_process.communicate = AsyncMock(return_value=(b"", b""))
mock_subprocess.return_value = mock_find_process
result = await vlc_service.stop_all_vlc_instances()
assert result["success"] is True
assert result["processes_found"] == 0
assert result["processes_killed"] == 0
assert result["message"] == "No VLC processes found"
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_stop_all_vlc_instances_partial_kill(
self,
mock_subprocess,
vlc_service,
) -> None:
"""Test stopping VLC instances when some processes remain."""
# Mock pgrep process (find VLC processes)
mock_find_process = Mock()
mock_find_process.returncode = 0
mock_find_process.communicate = AsyncMock(
return_value=(b"12345\n67890\n11111\n", b""),
)
# Mock pkill process (kill VLC processes)
mock_kill_process = Mock()
mock_kill_process.communicate = AsyncMock(return_value=(b"", b""))
# Mock verify process (one process remains)
mock_verify_process = Mock()
mock_verify_process.returncode = 0
mock_verify_process.communicate = AsyncMock(return_value=(b"11111\n", b""))
mock_subprocess.side_effect = [
mock_find_process,
mock_kill_process,
mock_verify_process,
]
result = await vlc_service.stop_all_vlc_instances()
assert result["success"] is True
assert result["processes_found"] == 3
assert result["processes_killed"] == 2
assert result["processes_remaining"] == 1
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_stop_all_vlc_instances_error(
self,
mock_subprocess,
vlc_service,
) -> None:
"""Test stopping VLC instances when an error occurs."""
# Mock subprocess exception
mock_subprocess.side_effect = Exception("Command failed")
result = await vlc_service.stop_all_vlc_instances()
assert result["success"] is False
assert result["processes_found"] == 0
assert result["processes_killed"] == 0
assert "error" in result
assert result["message"] == "Failed to stop VLC processes"
def test_get_vlc_player_service_singleton(self) -> None:
"""Test that get_vlc_player_service returns the same instance."""
with patch("app.services.vlc_player.VLCPlayerService") as mock_service_class:
mock_instance = Mock()
mock_service_class.return_value = mock_instance
# Clear the global instance
import app.services.vlc_player
app.services.vlc_player.vlc_player_service = None
# First call should create new instance
service1 = get_vlc_player_service()
assert service1 == mock_instance
mock_service_class.assert_called_once()
# Second call should return same instance
service2 = get_vlc_player_service()
assert service2 == mock_instance
assert service1 is service2
# Constructor should not be called again
mock_service_class.assert_called_once()
@pytest.mark.asyncio
@patch("app.services.vlc_player.asyncio.create_subprocess_exec")
async def test_play_sound_with_play_count_tracking(
self,
mock_subprocess,
vlc_service_with_db,
sample_sound,
) -> None:
"""Test sound playback with play count tracking."""
# Mock subprocess
mock_process = Mock()
mock_process.pid = 12345
mock_subprocess.return_value = mock_process
# Mock session and repositories
mock_session = AsyncMock()
vlc_service_with_db.db_session_factory.return_value = mock_session
# Mock repositories
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
with patch(
"app.services.vlc_player.SoundRepository",
return_value=mock_sound_repo,
):
with patch(
"app.services.vlc_player.UserRepository",
return_value=mock_user_repo,
):
with patch("app.services.vlc_player.socket_manager") as mock_socket:
# Mock the file path utility
with patch(
"app.services.vlc_player.get_sound_file_path",
) as mock_get_path:
mock_path = Mock()
mock_path.exists.return_value = True
mock_get_path.return_value = mock_path
# Mock sound repository responses
updated_sound = Sound(
id=1,
type="SDB",
name="Test Sound",
filename="test.mp3",
duration=5000,
size=1024,
hash="test_hash",
play_count=1, # Updated count
)
mock_sound_repo.get_by_id.return_value = sample_sound
mock_sound_repo.update.return_value = updated_sound
# Mock admin user
admin_user = User(
id=1,
email="admin@test.com",
name="Admin User",
role="admin",
)
mock_user_repo.get_by_id.return_value = admin_user
# Mock socket broadcast
mock_socket.broadcast_to_all = AsyncMock()
result = await vlc_service_with_db.play_sound(sample_sound)
# Wait a bit for the async task to complete
await asyncio.sleep(0.1)
assert result is True
# Verify subprocess was called
mock_subprocess.assert_called_once()
# Note: The async task runs in the background, so we can't easily
# verify the database operations in this test without more complex
# mocking or using a real async test framework setup
@pytest.mark.asyncio
async def test_record_play_count_success(self, vlc_service_with_db) -> None:
"""Test successful play count recording."""
# Mock session and repositories
mock_session = AsyncMock()
vlc_service_with_db.db_session_factory.return_value = mock_session
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
# Create test sound and user
test_sound = Sound(
id=1,
type="SDB",
name="Test Sound",
filename="test.mp3",
duration=5000,
size=1024,
hash="test_hash",
play_count=0,
)
admin_user = User(
id=1,
email="admin@test.com",
name="Admin User",
role="admin",
)
with patch(
"app.services.vlc_player.SoundRepository",
return_value=mock_sound_repo,
):
with patch(
"app.services.vlc_player.UserRepository",
return_value=mock_user_repo,
):
with patch("app.services.vlc_player.socket_manager") as mock_socket:
# Setup mocks
mock_sound_repo.get_by_id.return_value = test_sound
mock_user_repo.get_by_id.return_value = admin_user
# Mock socket broadcast
mock_socket.broadcast_to_all = AsyncMock()
await vlc_service_with_db._record_play_count(1, "Test Sound")
# Verify sound repository calls
mock_sound_repo.get_by_id.assert_called_once_with(1)
mock_sound_repo.update.assert_called_once_with(
test_sound,
{"play_count": 1},
)
# Verify user repository calls
mock_user_repo.get_by_id.assert_called_once_with(1)
# Verify session operations
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
# Verify socket broadcast
mock_socket.broadcast_to_all.assert_called_once_with(
"sound_played",
{
"sound_id": 1,
"sound_name": "Test Sound",
"user_id": 1,
"user_name": "Admin User",
"play_count": 1,
},
)
@pytest.mark.asyncio
async def test_record_play_count_no_session_factory(self, vlc_service) -> None:
"""Test play count recording when no session factory is available."""
# This should not raise an error and should log a warning
await vlc_service._record_play_count(1, "Test Sound")
# The method should return early without doing anything
@pytest.mark.asyncio
async def test_record_play_count_always_creates_record(
self,
vlc_service_with_db,
) -> None:
"""Test play count recording always creates a new SoundPlayed record."""
# Mock session and repositories
mock_session = AsyncMock()
vlc_service_with_db.db_session_factory.return_value = mock_session
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
# Create test sound and user
test_sound = Sound(
id=1,
type="SDB",
name="Test Sound",
filename="test.mp3",
duration=5000,
size=1024,
hash="test_hash",
play_count=5,
)
admin_user = User(
id=1,
email="admin@test.com",
name="Admin User",
role="admin",
)
with patch(
"app.services.vlc_player.SoundRepository",
return_value=mock_sound_repo,
):
with patch(
"app.services.vlc_player.UserRepository",
return_value=mock_user_repo,
):
with patch("app.services.vlc_player.socket_manager") as mock_socket:
# Setup mocks
mock_sound_repo.get_by_id.return_value = test_sound
mock_user_repo.get_by_id.return_value = admin_user
# Mock socket broadcast
mock_socket.broadcast_to_all = AsyncMock()
await vlc_service_with_db._record_play_count(1, "Test Sound")
# Verify sound play count was updated
mock_sound_repo.update.assert_called_once_with(
test_sound,
{"play_count": 6},
)
# Verify new SoundPlayed record was always added
mock_session.add.assert_called_once()
# Verify commit happened
mock_session.commit.assert_called_once()
def test_uses_shared_sound_path_utility(self, vlc_service, sample_sound) -> None:
"""Test that VLC service uses the shared sound path utility."""
with patch("app.services.vlc_player.get_sound_file_path") as mock_path:
mock_file_path = Mock(spec=Path)
mock_file_path.exists.return_value = False # File doesn't exist
mock_path.return_value = mock_file_path
# This should fail because file doesn't exist
result = asyncio.run(vlc_service.play_sound(sample_sound))
# Verify the utility was called and returned False
mock_path.assert_called_once_with(sample_sound)
assert result is False