- Implemented VLC player API endpoints for playing and stopping sounds. - Added tests for successful playback, error handling, and authentication scenarios. - Created utility function to get sound file paths based on sound properties. - Refactored player service to utilize shared sound path utility. - Enhanced test coverage for sound file path utility with various sound types. - Introduced tests for VLC player service, including subprocess handling and play count tracking.
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""Tests for audio utility functions."""
|
|
|
|
import hashlib
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.models.sound import Sound
|
|
from app.utils.audio import get_audio_duration, get_file_hash, get_file_size, get_sound_file_path
|
|
|
|
|
|
class TestAudioUtils:
|
|
"""Test audio utility functions."""
|
|
|
|
def test_get_file_hash(self):
|
|
"""Test file hash calculation."""
|
|
# Create a temporary file with known content
|
|
test_content = "test content for hashing"
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
|
f.write(test_content)
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Calculate hash using our function
|
|
result_hash = get_file_hash(temp_path)
|
|
|
|
# Calculate expected hash manually
|
|
expected_hash = hashlib.sha256(test_content.encode()).hexdigest()
|
|
|
|
# Verify the hash is correct
|
|
assert result_hash == expected_hash
|
|
assert len(result_hash) == 64 # SHA-256 hash length
|
|
assert isinstance(result_hash, str)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_hash_binary_content(self):
|
|
"""Test file hash calculation with binary content."""
|
|
# Create a temporary file with binary content
|
|
test_bytes = b"\x00\x01\x02\x03\xff\xfe\xfd"
|
|
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
|
f.write(test_bytes)
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Calculate hash using our function
|
|
result_hash = get_file_hash(temp_path)
|
|
|
|
# Calculate expected hash manually
|
|
expected_hash = hashlib.sha256(test_bytes).hexdigest()
|
|
|
|
# Verify the hash is correct
|
|
assert result_hash == expected_hash
|
|
assert len(result_hash) == 64 # SHA-256 hash length
|
|
assert isinstance(result_hash, str)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_hash_empty_file(self):
|
|
"""Test file hash calculation for empty file."""
|
|
# Create an empty temporary file
|
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Calculate hash using our function
|
|
result_hash = get_file_hash(temp_path)
|
|
|
|
# Calculate expected hash for empty content
|
|
expected_hash = hashlib.sha256(b"").hexdigest()
|
|
|
|
# Verify the hash is correct
|
|
assert result_hash == expected_hash
|
|
assert len(result_hash) == 64 # SHA-256 hash length
|
|
assert isinstance(result_hash, str)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_hash_large_file(self):
|
|
"""Test file hash calculation for large file (tests chunked reading)."""
|
|
# Create a large temporary file (larger than 4096 bytes chunk size)
|
|
test_content = "A" * 10000 # 10KB of 'A' characters
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
|
f.write(test_content)
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Calculate hash using our function
|
|
result_hash = get_file_hash(temp_path)
|
|
|
|
# Calculate expected hash manually
|
|
expected_hash = hashlib.sha256(test_content.encode()).hexdigest()
|
|
|
|
# Verify the hash is correct
|
|
assert result_hash == expected_hash
|
|
assert len(result_hash) == 64 # SHA-256 hash length
|
|
assert isinstance(result_hash, str)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_size(self):
|
|
"""Test file size calculation."""
|
|
# Create a temporary file with known content
|
|
test_content = "test content for size calculation"
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
|
f.write(test_content)
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Get size using our function
|
|
result_size = get_file_size(temp_path)
|
|
|
|
# Get expected size using pathlib directly
|
|
expected_size = temp_path.stat().st_size
|
|
|
|
# Verify the size is correct
|
|
assert result_size == expected_size
|
|
assert result_size > 0
|
|
assert isinstance(result_size, int)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_size_empty_file(self):
|
|
"""Test file size calculation for empty file."""
|
|
# Create an empty temporary file
|
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Get size using our function
|
|
result_size = get_file_size(temp_path)
|
|
|
|
# Verify the size is zero
|
|
assert result_size == 0
|
|
assert isinstance(result_size, int)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
def test_get_file_size_binary_file(self):
|
|
"""Test file size calculation for binary file."""
|
|
# Create a temporary file with binary content
|
|
test_bytes = b"\x00\x01\x02\x03\xff\xfe\xfd" * 100 # 700 bytes
|
|
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
|
f.write(test_bytes)
|
|
temp_path = Path(f.name)
|
|
|
|
try:
|
|
# Get size using our function
|
|
result_size = get_file_size(temp_path)
|
|
|
|
# Verify the size is correct
|
|
assert result_size == len(test_bytes)
|
|
assert result_size == 700
|
|
assert isinstance(result_size, int)
|
|
|
|
finally:
|
|
temp_path.unlink()
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_success(self, mock_probe):
|
|
"""Test successful audio duration extraction."""
|
|
# Mock ffmpeg.probe to return duration
|
|
mock_probe.return_value = {"format": {"duration": "123.456"}}
|
|
|
|
temp_path = Path("/fake/path/test.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration is converted correctly (seconds to milliseconds)
|
|
assert duration == 123456 # 123.456 seconds * 1000 = 123456 ms
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_integer_duration(self, mock_probe):
|
|
"""Test audio duration extraction with integer duration."""
|
|
# Mock ffmpeg.probe to return integer duration
|
|
mock_probe.return_value = {"format": {"duration": "60"}}
|
|
|
|
temp_path = Path("/fake/path/test.wav")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration is converted correctly
|
|
assert duration == 60000 # 60 seconds * 1000 = 60000 ms
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_zero_duration(self, mock_probe):
|
|
"""Test audio duration extraction with zero duration."""
|
|
# Mock ffmpeg.probe to return zero duration
|
|
mock_probe.return_value = {"format": {"duration": "0.0"}}
|
|
|
|
temp_path = Path("/fake/path/silent.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration is zero
|
|
assert duration == 0
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_fractional_duration(self, mock_probe):
|
|
"""Test audio duration extraction with fractional seconds."""
|
|
# Mock ffmpeg.probe to return fractional duration
|
|
mock_probe.return_value = {"format": {"duration": "45.123"}}
|
|
|
|
temp_path = Path("/fake/path/test.flac")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration is converted and rounded correctly
|
|
assert duration == 45123 # 45.123 seconds * 1000 = 45123 ms
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_ffmpeg_error(self, mock_probe):
|
|
"""Test audio duration extraction when ffmpeg fails."""
|
|
# Mock ffmpeg.probe to raise an exception
|
|
mock_probe.side_effect = Exception("FFmpeg error: file not found")
|
|
|
|
temp_path = Path("/fake/path/nonexistent.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration defaults to 0 on error
|
|
assert duration == 0
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_missing_format(self, mock_probe):
|
|
"""Test audio duration extraction when format info is missing."""
|
|
# Mock ffmpeg.probe to return data without format info
|
|
mock_probe.return_value = {"streams": []}
|
|
|
|
temp_path = Path("/fake/path/corrupt.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration defaults to 0 when format info is missing
|
|
assert duration == 0
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_missing_duration(self, mock_probe):
|
|
"""Test audio duration extraction when duration is missing."""
|
|
# Mock ffmpeg.probe to return format without duration
|
|
mock_probe.return_value = {"format": {"size": "1024"}}
|
|
|
|
temp_path = Path("/fake/path/noduration.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration defaults to 0 when duration is missing
|
|
assert duration == 0
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
@patch("app.utils.audio.ffmpeg.probe")
|
|
def test_get_audio_duration_invalid_duration(self, mock_probe):
|
|
"""Test audio duration extraction with invalid duration value."""
|
|
# Mock ffmpeg.probe to return invalid duration
|
|
mock_probe.return_value = {"format": {"duration": "invalid"}}
|
|
|
|
temp_path = Path("/fake/path/invalid.mp3")
|
|
duration = get_audio_duration(temp_path)
|
|
|
|
# Verify duration defaults to 0 when duration is invalid
|
|
assert duration == 0
|
|
assert isinstance(duration, int)
|
|
mock_probe.assert_called_once_with(str(temp_path))
|
|
|
|
def test_get_file_hash_nonexistent_file(self):
|
|
"""Test file hash calculation for nonexistent file."""
|
|
nonexistent_path = Path("/fake/nonexistent/file.mp3")
|
|
|
|
# Should raise FileNotFoundError for nonexistent file
|
|
with pytest.raises(FileNotFoundError):
|
|
get_file_hash(nonexistent_path)
|
|
|
|
def test_get_file_size_nonexistent_file(self):
|
|
"""Test file size calculation for nonexistent file."""
|
|
nonexistent_path = Path("/fake/nonexistent/file.mp3")
|
|
|
|
# Should raise FileNotFoundError for nonexistent file
|
|
with pytest.raises(FileNotFoundError):
|
|
get_file_size(nonexistent_path)
|
|
|
|
def test_get_sound_file_path_sdb_original(self):
|
|
"""Test getting sound file path for SDB type original file."""
|
|
sound = Sound(
|
|
id=1,
|
|
name="Test Sound",
|
|
filename="test.mp3",
|
|
type="SDB",
|
|
is_normalized=False,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/originals/soundboard/test.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_sdb_normalized(self):
|
|
"""Test getting sound file path for SDB type normalized file."""
|
|
sound = Sound(
|
|
id=1,
|
|
name="Test Sound",
|
|
filename="original.mp3",
|
|
normalized_filename="normalized.mp3",
|
|
type="SDB",
|
|
is_normalized=True,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/normalized/soundboard/normalized.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_tts_original(self):
|
|
"""Test getting sound file path for TTS type original file."""
|
|
sound = Sound(
|
|
id=2,
|
|
name="TTS Sound",
|
|
filename="tts_file.wav",
|
|
type="TTS",
|
|
is_normalized=False,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/originals/text_to_speech/tts_file.wav")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_tts_normalized(self):
|
|
"""Test getting sound file path for TTS type normalized file."""
|
|
sound = Sound(
|
|
id=2,
|
|
name="TTS Sound",
|
|
filename="original.wav",
|
|
normalized_filename="normalized.mp3",
|
|
type="TTS",
|
|
is_normalized=True,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/normalized/text_to_speech/normalized.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_ext_original(self):
|
|
"""Test getting sound file path for EXT type original file."""
|
|
sound = Sound(
|
|
id=3,
|
|
name="Extracted Sound",
|
|
filename="extracted.mp3",
|
|
type="EXT",
|
|
is_normalized=False,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/originals/extracted/extracted.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_ext_normalized(self):
|
|
"""Test getting sound file path for EXT type normalized file."""
|
|
sound = Sound(
|
|
id=3,
|
|
name="Extracted Sound",
|
|
filename="original.mp3",
|
|
normalized_filename="normalized.mp3",
|
|
type="EXT",
|
|
is_normalized=True,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/normalized/extracted/normalized.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_unknown_type_fallback(self):
|
|
"""Test getting sound file path for unknown type falls back to lowercase."""
|
|
sound = Sound(
|
|
id=4,
|
|
name="Unknown Type Sound",
|
|
filename="unknown.mp3",
|
|
type="CUSTOM",
|
|
is_normalized=False,
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
expected = Path("sounds/originals/custom/unknown.mp3")
|
|
assert result == expected
|
|
|
|
def test_get_sound_file_path_normalized_without_filename(self):
|
|
"""Test getting sound file path when normalized but no normalized_filename."""
|
|
sound = Sound(
|
|
id=5,
|
|
name="Test Sound",
|
|
filename="original.mp3",
|
|
normalized_filename=None,
|
|
type="SDB",
|
|
is_normalized=True, # True but no normalized_filename
|
|
)
|
|
|
|
result = get_sound_file_path(sound)
|
|
# Should fall back to original file
|
|
expected = Path("sounds/originals/soundboard/original.mp3")
|
|
assert result == expected
|