482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""Tests for extraction service."""
|
|
# ruff: noqa: ANN001, ANN201, PLR2004, SLF001, E501
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.models.extraction import Extraction
|
|
from app.models.sound import Sound
|
|
from app.services.extraction import ExtractionService
|
|
|
|
|
|
class TestExtractionService:
|
|
"""Test extraction service."""
|
|
|
|
@pytest.fixture
|
|
def mock_session(self):
|
|
"""Create a mock session."""
|
|
return Mock(spec=AsyncSession)
|
|
|
|
@pytest.fixture
|
|
def extraction_service(self, mock_session):
|
|
"""Create an extraction service with mock session."""
|
|
with patch("app.services.extraction.Path.mkdir"):
|
|
return ExtractionService(mock_session)
|
|
|
|
def test_init(self, extraction_service) -> None:
|
|
"""Test service initialization."""
|
|
assert extraction_service.session is not None
|
|
assert extraction_service.extraction_repo is not None
|
|
assert extraction_service.sound_repo is not None
|
|
|
|
def test_sanitize_filename(self, extraction_service) -> None:
|
|
"""Test filename sanitization."""
|
|
test_cases = [
|
|
("Hello World", "Hello World"),
|
|
("Test<>Video", "Test__Video"),
|
|
("Bad/File\\Name", "Bad_File_Name"),
|
|
(" Spaces ", "Spaces"),
|
|
(
|
|
"Very long filename that exceeds the maximum length limit and should be truncated to 100 characters maximum",
|
|
"Very long filename that exceeds the maximum length limit and should be truncated to 100 characters m",
|
|
),
|
|
("", "untitled"),
|
|
]
|
|
|
|
for input_name, expected in test_cases:
|
|
result = extraction_service._sanitize_filename(input_name)
|
|
assert result == expected
|
|
|
|
@patch("app.services.extraction.yt_dlp.YoutubeDL")
|
|
@pytest.mark.asyncio
|
|
async def test_detect_service_info_youtube(
|
|
self, mock_ydl_class, extraction_service,
|
|
) -> None:
|
|
"""Test service detection for YouTube."""
|
|
mock_ydl = Mock()
|
|
mock_ydl_class.return_value.__enter__.return_value = mock_ydl
|
|
mock_ydl.extract_info.return_value = {
|
|
"extractor": "youtube",
|
|
"id": "test123",
|
|
"title": "Test Video",
|
|
"duration": 240,
|
|
"uploader": "Test Channel",
|
|
}
|
|
|
|
result = await extraction_service._detect_service_info(
|
|
"https://www.youtube.com/watch?v=test123",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result["service"] == "youtube"
|
|
assert result["service_id"] == "test123"
|
|
assert result["title"] == "Test Video"
|
|
|
|
@patch("app.services.extraction.yt_dlp.YoutubeDL")
|
|
@pytest.mark.asyncio
|
|
async def test_detect_service_info_failure(
|
|
self, mock_ydl_class, extraction_service,
|
|
) -> None:
|
|
"""Test service detection failure."""
|
|
mock_ydl = Mock()
|
|
mock_ydl_class.return_value.__enter__.return_value = mock_ydl
|
|
mock_ydl.extract_info.side_effect = Exception("Network error")
|
|
|
|
result = await extraction_service._detect_service_info("https://invalid.url")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_success(self, extraction_service) -> None:
|
|
"""Test successful extraction creation."""
|
|
url = "https://www.youtube.com/watch?v=test123"
|
|
user_id = 1
|
|
|
|
# Mock repository call - no service detection happens during creation
|
|
mock_extraction = Extraction(
|
|
id=1,
|
|
url=url,
|
|
user_id=user_id,
|
|
service=None, # Service detection deferred to processing
|
|
service_id=None, # Service detection deferred to processing
|
|
title=None, # Service detection deferred to processing
|
|
status="pending",
|
|
)
|
|
extraction_service.extraction_repo.create = AsyncMock(
|
|
return_value=mock_extraction,
|
|
)
|
|
|
|
result = await extraction_service.create_extraction(url, user_id)
|
|
|
|
assert result["id"] == 1
|
|
assert result["service"] is None # Not detected during creation
|
|
assert result["service_id"] is None # Not detected during creation
|
|
assert result["title"] is None # Not detected during creation
|
|
assert result["status"] == "pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_basic(self, extraction_service) -> None:
|
|
"""Test basic extraction creation without validation."""
|
|
url = "https://www.youtube.com/watch?v=test123"
|
|
user_id = 1
|
|
|
|
# Mock repository call - creation always succeeds now
|
|
mock_extraction = Extraction(
|
|
id=2,
|
|
url=url,
|
|
user_id=user_id,
|
|
service=None,
|
|
service_id=None,
|
|
title=None,
|
|
status="pending",
|
|
)
|
|
extraction_service.extraction_repo.create = AsyncMock(
|
|
return_value=mock_extraction,
|
|
)
|
|
|
|
result = await extraction_service.create_extraction(url, user_id)
|
|
|
|
assert result["id"] == 2
|
|
assert result["url"] == url
|
|
assert result["status"] == "pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_any_url(self, extraction_service) -> None:
|
|
"""Test extraction creation accepts any URL."""
|
|
url = "https://invalid.url"
|
|
user_id = 1
|
|
|
|
# Mock repository call - even invalid URLs are accepted during creation
|
|
mock_extraction = Extraction(
|
|
id=3,
|
|
url=url,
|
|
user_id=user_id,
|
|
service=None,
|
|
service_id=None,
|
|
title=None,
|
|
status="pending",
|
|
)
|
|
extraction_service.extraction_repo.create = AsyncMock(
|
|
return_value=mock_extraction,
|
|
)
|
|
|
|
result = await extraction_service.create_extraction(url, user_id)
|
|
|
|
assert result["id"] == 3
|
|
assert result["url"] == url
|
|
assert result["status"] == "pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_extraction_with_service_detection(self, extraction_service) -> None:
|
|
"""Test extraction processing with service detection."""
|
|
extraction_id = 1
|
|
|
|
# Mock extraction without service info
|
|
mock_extraction = Extraction(
|
|
id=extraction_id,
|
|
url="https://www.youtube.com/watch?v=test123",
|
|
user_id=1,
|
|
service=None,
|
|
service_id=None,
|
|
title=None,
|
|
status="pending",
|
|
)
|
|
|
|
extraction_service.extraction_repo.get_by_id = AsyncMock(
|
|
return_value=mock_extraction,
|
|
)
|
|
extraction_service.extraction_repo.update = AsyncMock()
|
|
extraction_service.extraction_repo.get_by_service_and_id = AsyncMock(
|
|
return_value=None,
|
|
)
|
|
|
|
# Mock service detection
|
|
service_info = {
|
|
"service": "youtube",
|
|
"service_id": "test123",
|
|
"title": "Test Video",
|
|
}
|
|
|
|
with (
|
|
patch.object(
|
|
extraction_service, "_detect_service_info", return_value=service_info,
|
|
),
|
|
patch.object(extraction_service, "_extract_media") as mock_extract,
|
|
patch.object(
|
|
extraction_service, "_move_files_to_final_location",
|
|
) as mock_move,
|
|
patch.object(
|
|
extraction_service, "_create_sound_record",
|
|
) as mock_create_sound,
|
|
patch.object(extraction_service, "_normalize_sound"),
|
|
patch.object(extraction_service, "_add_to_main_playlist"),
|
|
):
|
|
mock_sound = Sound(id=42, type="EXT", name="Test", filename="test.mp3")
|
|
mock_extract.return_value = (Path("/fake/audio.mp3"), None)
|
|
mock_move.return_value = (Path("/final/audio.mp3"), None)
|
|
mock_create_sound.return_value = mock_sound
|
|
|
|
result = await extraction_service.process_extraction(extraction_id)
|
|
|
|
# Verify service detection was called
|
|
extraction_service._detect_service_info.assert_called_once_with(
|
|
"https://www.youtube.com/watch?v=test123",
|
|
)
|
|
|
|
# Verify extraction was updated with service info
|
|
extraction_service.extraction_repo.update.assert_called()
|
|
|
|
assert result["status"] == "completed"
|
|
assert result["service"] == "youtube"
|
|
assert result["service_id"] == "test123"
|
|
assert result["title"] == "Test Video"
|
|
|
|
def test_ensure_unique_filename(self, extraction_service) -> None:
|
|
"""Test unique filename generation."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
|
|
# Create original file
|
|
original_file = temp_path / "test.mp3"
|
|
original_file.touch()
|
|
|
|
# Test unique filename generation
|
|
result = extraction_service._ensure_unique_filename(original_file)
|
|
expected = temp_path / "test_1.mp3"
|
|
assert result == expected
|
|
|
|
# Create the first duplicate and test again
|
|
expected.touch()
|
|
result = extraction_service._ensure_unique_filename(original_file)
|
|
expected_2 = temp_path / "test_2.mp3"
|
|
assert result == expected_2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_sound_record(self, extraction_service) -> None:
|
|
"""Test sound record creation."""
|
|
# Create temporary audio file
|
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
audio_path = Path(f.name)
|
|
f.write(b"fake audio data")
|
|
|
|
try:
|
|
extraction = Extraction(
|
|
id=1,
|
|
service="youtube",
|
|
service_id="test123",
|
|
title="Test Video",
|
|
url="https://www.youtube.com/watch?v=test123",
|
|
user_id=1,
|
|
status="processing",
|
|
)
|
|
|
|
mock_sound = Sound(
|
|
id=1,
|
|
type="EXT",
|
|
name="Test Video",
|
|
filename=audio_path.name,
|
|
duration=240000,
|
|
size=1024,
|
|
hash="test_hash",
|
|
is_deletable=True,
|
|
is_music=True,
|
|
is_normalized=False,
|
|
play_count=0,
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"app.services.extraction.get_audio_duration", return_value=240000,
|
|
),
|
|
patch("app.services.extraction.get_file_size", return_value=1024),
|
|
patch(
|
|
"app.services.extraction.get_file_hash", return_value="test_hash",
|
|
),
|
|
):
|
|
extraction_service.sound_repo.create = AsyncMock(
|
|
return_value=mock_sound,
|
|
)
|
|
|
|
result = await extraction_service._create_sound_record(
|
|
audio_path,
|
|
extraction.title,
|
|
extraction.service,
|
|
extraction.service_id,
|
|
)
|
|
|
|
assert result.type == "EXT"
|
|
assert result.name == "Test Video"
|
|
assert result.is_deletable is True
|
|
assert result.is_music is True
|
|
assert result.is_normalized is False
|
|
|
|
finally:
|
|
audio_path.unlink()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalize_sound_success(self, extraction_service) -> None:
|
|
"""Test sound normalization."""
|
|
sound = Sound(
|
|
id=1,
|
|
type="EXT",
|
|
name="Test Sound",
|
|
filename="test.mp3",
|
|
duration=240000,
|
|
size=1024,
|
|
hash="test_hash",
|
|
is_normalized=False,
|
|
)
|
|
sound_id = 1
|
|
|
|
# Mock sound repository to return the sound
|
|
extraction_service.sound_repo.get_by_id = AsyncMock(return_value=sound)
|
|
|
|
mock_normalizer = Mock()
|
|
mock_normalizer.normalize_sound = AsyncMock(
|
|
return_value={"status": "normalized"},
|
|
)
|
|
|
|
with patch(
|
|
"app.services.extraction.SoundNormalizerService",
|
|
return_value=mock_normalizer,
|
|
):
|
|
# Should not raise exception
|
|
await extraction_service._normalize_sound(sound_id)
|
|
extraction_service.sound_repo.get_by_id.assert_called_once_with(sound_id)
|
|
mock_normalizer.normalize_sound.assert_called_once_with(sound)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalize_sound_failure(self, extraction_service) -> None:
|
|
"""Test sound normalization failure."""
|
|
sound = Sound(
|
|
id=1,
|
|
type="EXT",
|
|
name="Test Sound",
|
|
filename="test.mp3",
|
|
duration=240000,
|
|
size=1024,
|
|
hash="test_hash",
|
|
is_normalized=False,
|
|
)
|
|
sound_id = 1
|
|
|
|
# Mock sound repository to return the sound
|
|
extraction_service.sound_repo.get_by_id = AsyncMock(return_value=sound)
|
|
|
|
mock_normalizer = Mock()
|
|
mock_normalizer.normalize_sound = AsyncMock(
|
|
return_value={"status": "error", "error": "Test error"},
|
|
)
|
|
|
|
with patch(
|
|
"app.services.extraction.SoundNormalizerService",
|
|
return_value=mock_normalizer,
|
|
):
|
|
# Should not raise exception even on failure
|
|
await extraction_service._normalize_sound(sound_id)
|
|
extraction_service.sound_repo.get_by_id.assert_called_once_with(sound_id)
|
|
mock_normalizer.normalize_sound.assert_called_once_with(sound)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_extraction_by_id(self, extraction_service) -> None:
|
|
"""Test getting extraction by ID."""
|
|
extraction = Extraction(
|
|
id=1,
|
|
service="youtube",
|
|
service_id="test123",
|
|
url="https://www.youtube.com/watch?v=test123",
|
|
user_id=1,
|
|
title="Test Video",
|
|
status="completed",
|
|
sound_id=42,
|
|
)
|
|
|
|
extraction_service.extraction_repo.get_by_id = AsyncMock(
|
|
return_value=extraction,
|
|
)
|
|
|
|
result = await extraction_service.get_extraction_by_id(1)
|
|
|
|
assert result is not None
|
|
assert result["id"] == 1
|
|
assert result["service"] == "youtube"
|
|
assert result["service_id"] == "test123"
|
|
assert result["title"] == "Test Video"
|
|
assert result["status"] == "completed"
|
|
assert result["sound_id"] == 42
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_extraction_by_id_not_found(self, extraction_service) -> None:
|
|
"""Test getting extraction by ID when not found."""
|
|
extraction_service.extraction_repo.get_by_id = AsyncMock(return_value=None)
|
|
|
|
result = await extraction_service.get_extraction_by_id(999)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_extractions(self, extraction_service) -> None:
|
|
"""Test getting user extractions."""
|
|
extractions = [
|
|
Extraction(
|
|
id=1,
|
|
service="youtube",
|
|
service_id="test123",
|
|
url="https://www.youtube.com/watch?v=test123",
|
|
user_id=1,
|
|
title="Test Video 1",
|
|
status="completed",
|
|
sound_id=42,
|
|
),
|
|
Extraction(
|
|
id=2,
|
|
service="youtube",
|
|
service_id="test456",
|
|
url="https://www.youtube.com/watch?v=test456",
|
|
user_id=1,
|
|
title="Test Video 2",
|
|
status="pending",
|
|
),
|
|
]
|
|
|
|
extraction_service.extraction_repo.get_by_user = AsyncMock(
|
|
return_value=extractions,
|
|
)
|
|
|
|
result = await extraction_service.get_user_extractions(1)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["id"] == 1
|
|
assert result[0]["title"] == "Test Video 1"
|
|
assert result[1]["id"] == 2
|
|
assert result[1]["title"] == "Test Video 2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_extractions(self, extraction_service) -> None:
|
|
"""Test getting pending extractions."""
|
|
pending_extractions = [
|
|
Extraction(
|
|
id=1,
|
|
service="youtube",
|
|
service_id="test123",
|
|
url="https://www.youtube.com/watch?v=test123",
|
|
user_id=1,
|
|
title="Pending Video",
|
|
status="pending",
|
|
),
|
|
]
|
|
|
|
extraction_service.extraction_repo.get_pending_extractions = AsyncMock(
|
|
return_value=pending_extractions,
|
|
)
|
|
|
|
result = await extraction_service.get_pending_extractions()
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["id"] == 1
|
|
assert result[0]["status"] == "pending"
|