- Added `ExtractionProcessor` class to handle extraction queue processing in the background. - Implemented methods for starting, stopping, and queuing extractions with concurrency limits. - Integrated logging for monitoring the processor's status and actions. - Created tests for the extraction processor to ensure functionality and error handling. test: Add unit tests for extraction API endpoints - Created tests for successful extraction creation, authentication checks, and processor status retrieval. - Ensured proper responses for authenticated and unauthenticated requests. test: Implement unit tests for extraction repository - Added tests for creating, retrieving, and updating extractions in the repository. - Mocked database interactions to validate repository behavior without actual database access. test: Add comprehensive tests for extraction service - Developed tests for extraction creation, service detection, and sound record creation. - Included tests for handling duplicate extractions and invalid URLs. test: Add unit tests for extraction background processor - Created tests for the `ExtractionProcessor` class to validate its behavior under various conditions. - Ensured proper handling of extraction queuing, processing, and completion callbacks. fix: Update OAuth service tests to use AsyncMock - Modified OAuth provider tests to use `AsyncMock` for mocking asynchronous HTTP requests.
409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""Tests for extraction service."""
|
|
|
|
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):
|
|
"""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):
|
|
"""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")
|
|
def test_detect_service_info_youtube(self, mock_ydl_class, extraction_service):
|
|
"""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 = 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"
|
|
assert result["duration"] == 240
|
|
|
|
@patch("app.services.extraction.yt_dlp.YoutubeDL")
|
|
def test_detect_service_info_failure(self, mock_ydl_class, extraction_service):
|
|
"""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 = extraction_service._detect_service_info("https://invalid.url")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_success(self, extraction_service):
|
|
"""Test successful extraction creation."""
|
|
url = "https://www.youtube.com/watch?v=test123"
|
|
user_id = 1
|
|
|
|
# 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
|
|
):
|
|
# Mock repository calls
|
|
extraction_service.extraction_repo.get_by_service_and_id = AsyncMock(
|
|
return_value=None
|
|
)
|
|
mock_extraction = Extraction(
|
|
id=1,
|
|
url=url,
|
|
user_id=user_id,
|
|
service="youtube",
|
|
service_id="test123",
|
|
title="Test Video",
|
|
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"] == "youtube"
|
|
assert result["service_id"] == "test123"
|
|
assert result["title"] == "Test Video"
|
|
assert result["status"] == "pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_duplicate(self, extraction_service):
|
|
"""Test extraction creation with duplicate service/service_id."""
|
|
url = "https://www.youtube.com/watch?v=test123"
|
|
user_id = 1
|
|
|
|
# Mock service detection
|
|
service_info = {
|
|
"service": "youtube",
|
|
"service_id": "test123",
|
|
"title": "Test Video",
|
|
}
|
|
|
|
existing_extraction = Extraction(
|
|
id=1,
|
|
url=url,
|
|
user_id=2, # Different user
|
|
service="youtube",
|
|
service_id="test123",
|
|
status="completed",
|
|
)
|
|
|
|
with patch.object(
|
|
extraction_service, "_detect_service_info", return_value=service_info
|
|
):
|
|
extraction_service.extraction_repo.get_by_service_and_id = AsyncMock(
|
|
return_value=existing_extraction
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Extraction already exists"):
|
|
await extraction_service.create_extraction(url, user_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_extraction_invalid_url(self, extraction_service):
|
|
"""Test extraction creation with invalid URL."""
|
|
url = "https://invalid.url"
|
|
user_id = 1
|
|
|
|
with patch.object(
|
|
extraction_service, "_detect_service_info", return_value=None
|
|
):
|
|
with pytest.raises(
|
|
ValueError, match="Unable to detect service information"
|
|
):
|
|
await extraction_service.create_extraction(url, user_id)
|
|
|
|
def test_ensure_unique_filename(self, extraction_service):
|
|
"""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):
|
|
"""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):
|
|
"""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,
|
|
)
|
|
|
|
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)
|
|
mock_normalizer.normalize_sound.assert_called_once_with(sound)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalize_sound_failure(self, extraction_service):
|
|
"""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,
|
|
)
|
|
|
|
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)
|
|
mock_normalizer.normalize_sound.assert_called_once_with(sound)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_extraction_by_id(self, extraction_service):
|
|
"""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):
|
|
"""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):
|
|
"""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):
|
|
"""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"
|