feat: Implement background extraction processor with concurrency control

- 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.
This commit is contained in:
JSC
2025-07-29 01:06:29 +02:00
parent c993230f98
commit 9b5f83eef0
11 changed files with 1860 additions and 4 deletions

View File

@@ -0,0 +1,408 @@
"""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"