"""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) -> 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"