""" Unit tests for pytubefix downloader """ import pytest from unittest.mock import Mock, AsyncMock, patch, MagicMock from pathlib import Path import asyncio from backend.models.video_download import ( DownloadMethod, DownloadPreferences, VideoDownloadResult, DownloadStatus, VideoQuality, VideoMetadata, TranscriptData, DownloaderException, VideoNotAvailableError ) from backend.services.video_downloaders.pytubefix_downloader import PytubefixDownloader class TestPytubefixDownloader: """Test pytubefix downloader functionality""" @pytest.fixture def mock_config(self, tmp_path): """Mock configuration for testing""" return { 'output_dir': str(tmp_path), 'timeout': 60 } @pytest.fixture def downloader(self, mock_config): """Create downloader instance for testing""" return PytubefixDownloader(config=mock_config) def test_initialization(self, downloader, mock_config): """Test downloader initialization""" assert downloader.method == DownloadMethod.PYTUBEFIX assert downloader.output_dir == Path(mock_config['output_dir']) assert downloader.output_dir.exists() def test_capabilities(self, downloader): """Test downloader capabilities""" assert downloader.supports_audio_only() is True assert downloader.supports_quality_selection() is True assert "mp4" in downloader.get_supported_formats() assert "mp3" in downloader.get_supported_formats() @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_successful_audio_download(self, mock_youtube_class, downloader): """Test successful audio-only download""" # Setup mock YouTube object mock_yt = Mock() mock_yt.title = "Test Video" mock_yt.description = "Test description" mock_yt.length = 240 # 4 minutes mock_yt.views = 1000000 mock_yt.publish_date = None mock_yt.author = "Test Author" mock_yt.thumbnail_url = "http://example.com/thumb.jpg" mock_yt.keywords = ["test", "video"] # Setup mock audio stream mock_stream = Mock() mock_stream.download.return_value = str(downloader.output_dir / "test_temp_audio.mp4") mock_streams = Mock() mock_streams.filter.return_value.order_by.return_value.desc.return_value.first.return_value = mock_stream mock_yt.streams = mock_streams mock_youtube_class.return_value = mock_yt # Create test audio file test_audio_file = downloader.output_dir / "test123_temp_audio.mp4" test_audio_file.write_text("fake audio content") # Mock ffmpeg not available (so it just renames) with patch('ffmpeg', side_effect=ImportError): url = "https://youtube.com/watch?v=test123" preferences = DownloadPreferences(prefer_audio_only=True) result = await downloader.download_video(url, preferences) assert result.status == DownloadStatus.COMPLETED assert result.video_id == "test123" assert result.method == DownloadMethod.PYTUBEFIX assert result.audio_path is not None assert result.video_path is None assert result.metadata.title == "Test Video" assert result.metadata.duration_seconds == 240 @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_video_too_long_rejection(self, mock_youtube_class, downloader): """Test rejection of videos that exceed duration limit""" mock_yt = Mock() mock_yt.title = "Long Video" mock_yt.length = 7200 # 2 hours mock_yt.views = 1000 mock_yt.author = "Test Author" mock_youtube_class.return_value = mock_yt url = "https://youtube.com/watch?v=long123" preferences = DownloadPreferences(max_duration_minutes=60) # 1 hour limit result = await downloader.download_video(url, preferences) assert result.status == DownloadStatus.FAILED assert "too long" in result.error_message.lower() @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_private_video_error(self, mock_youtube_class, downloader): """Test handling of private video error""" mock_youtube_class.side_effect = Exception("Video is private") url = "https://youtube.com/watch?v=private123" preferences = DownloadPreferences() with pytest.raises(VideoNotAvailableError, match="Video not available"): await downloader.download_video(url, preferences) @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_age_restricted_video_error(self, mock_youtube_class, downloader): """Test handling of age-restricted video error""" mock_youtube_class.side_effect = Exception("Age restricted content") url = "https://youtube.com/watch?v=restricted123" preferences = DownloadPreferences() with pytest.raises(VideoNotAvailableError, match="Age-restricted video"): await downloader.download_video(url, preferences) @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_generic_pytubefix_error(self, mock_youtube_class, downloader): """Test handling of generic pytubefix error""" mock_youtube_class.side_effect = Exception("Network error") url = "https://youtube.com/watch?v=error123" preferences = DownloadPreferences() with pytest.raises(DownloaderException, match="Pytubefix error"): await downloader.download_video(url, preferences) @patch('youtube_transcript_api.YouTubeTranscriptApi') @pytest.mark.asyncio async def test_transcript_extraction_success(self, mock_transcript_api, downloader): """Test successful transcript extraction""" # Mock transcript API mock_api_instance = Mock() mock_transcript = Mock() mock_transcript.snippets = [ Mock(text="Hello world", start=0.0, duration=2.0), Mock(text="This is a test", start=2.0, duration=3.0) ] mock_transcript.is_generated = False mock_transcript.language_code = 'en' mock_api_instance.fetch.return_value = mock_transcript mock_transcript_api.return_value = mock_api_instance # Test transcript extraction transcript = await downloader._extract_transcript(Mock(), "test123") assert transcript is not None assert transcript.text == "Hello world This is a test" assert transcript.language == 'en' assert transcript.is_auto_generated is False assert len(transcript.segments) == 2 assert transcript.source == "youtube-transcript-api" @patch('youtube_transcript_api.YouTubeTranscriptApi') @pytest.mark.asyncio async def test_transcript_extraction_failure(self, mock_transcript_api, downloader): """Test transcript extraction failure""" mock_transcript_api.side_effect = Exception("No transcript available") transcript = await downloader._extract_transcript(Mock(), "test123") assert transcript is None @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_video_and_audio_download(self, mock_youtube_class, downloader): """Test downloading both video and audio""" mock_yt = Mock() mock_yt.title = "Test Video" mock_yt.length = 120 mock_yt.views = 1000 mock_yt.author = "Test Author" # Mock video stream mock_video_stream = Mock() mock_video_stream.download.return_value = str(downloader.output_dir / "test123_temp_video.mp4") # Mock audio stream mock_audio_stream = Mock() mock_audio_stream.download.return_value = str(downloader.output_dir / "test123_temp_audio.mp4") # Setup streams mock mock_streams = Mock() def mock_filter(**kwargs): filter_mock = Mock() filter_mock.order_by.return_value.desc.return_value.first.return_value = ( mock_video_stream if kwargs.get('only_video') else mock_audio_stream ) return filter_mock mock_streams.filter = mock_filter mock_yt.streams = mock_streams mock_youtube_class.return_value = mock_yt # Create test files (downloader.output_dir / "test123_temp_video.mp4").write_text("fake video") (downloader.output_dir / "test123_temp_audio.mp4").write_text("fake audio") # Mock ffmpeg not available with patch('ffmpeg', side_effect=ImportError): url = "https://youtube.com/watch?v=test123" preferences = DownloadPreferences(prefer_audio_only=False, save_video=True) result = await downloader.download_video(url, preferences) assert result.status == DownloadStatus.COMPLETED assert result.video_path is not None assert result.audio_path is not None @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_no_streams_available(self, mock_youtube_class, downloader): """Test handling when no streams are available""" mock_yt = Mock() mock_yt.title = "Test Video" mock_yt.length = 120 # Mock streams returning None mock_streams = Mock() mock_streams.filter.return_value.order_by.return_value.desc.return_value.first.return_value = None mock_yt.streams = mock_streams mock_youtube_class.return_value = mock_yt url = "https://youtube.com/watch?v=test123" preferences = DownloadPreferences(prefer_audio_only=True) result = await downloader.download_video(url, preferences) # Should still complete but with no audio file assert result.status == DownloadStatus.COMPLETED assert result.audio_path is None @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_connection_test_success(self, mock_youtube_class, downloader): """Test successful connection test""" mock_yt = Mock() mock_yt.title = "Test Video" mock_youtube_class.return_value = mock_yt result = await downloader.test_connection() assert result is True @patch('pytubefix.YouTube') @pytest.mark.asyncio async def test_connection_test_failure(self, mock_youtube_class, downloader): """Test failed connection test""" mock_youtube_class.side_effect = Exception("Connection failed") result = await downloader.test_connection() assert result is False @pytest.mark.asyncio async def test_metadata_extraction(self, downloader): """Test metadata extraction from YouTube object""" # Mock YouTube object with all metadata mock_yt = Mock() mock_yt.title = "Test Video Title" mock_yt.description = "This is a test video description" mock_yt.length = 300 # 5 minutes mock_yt.views = 1500000 mock_yt.publish_date = Mock() mock_yt.publish_date.isoformat.return_value = "2024-01-01T00:00:00" mock_yt.author = "Test Channel" mock_yt.thumbnail_url = "https://example.com/thumbnail.jpg" mock_yt.keywords = ["test", "video", "example"] metadata = await downloader._extract_metadata(mock_yt, "test123") assert metadata.video_id == "test123" assert metadata.title == "Test Video Title" assert metadata.description == "This is a test video description" assert metadata.duration_seconds == 300 assert metadata.view_count == 1500000 assert metadata.upload_date == "2024-01-01T00:00:00" assert metadata.uploader == "Test Channel" assert metadata.thumbnail_url == "https://example.com/thumbnail.jpg" assert metadata.tags == ["test", "video", "example"] @pytest.mark.asyncio async def test_metadata_extraction_partial(self, downloader): """Test metadata extraction with missing fields""" # Mock YouTube object with minimal metadata mock_yt = Mock() mock_yt.title = "Minimal Video" mock_yt.description = None mock_yt.length = None mock_yt.views = None mock_yt.publish_date = None mock_yt.author = None mock_yt.thumbnail_url = None mock_yt.keywords = [] metadata = await downloader._extract_metadata(mock_yt, "minimal123") assert metadata.video_id == "minimal123" assert metadata.title == "Minimal Video" assert metadata.description is None assert metadata.duration_seconds is None assert metadata.view_count is None assert metadata.upload_date is None assert metadata.uploader is None assert metadata.thumbnail_url is None assert metadata.tags == []