youtube-cli/tests/unit/test_service.py
Jarian Cottingham 06877fb2fc feat: add TUI (Textual User Interface) with download queue system
- New TUI using Textual framework with responsive interface
- Search, results, download, and queue screens for YouTube video management
- Download queue system with sequential background downloads
- Status bar with queue count and download progress indicators
- Comprehensive test suite (97 tests) with 100% pass rate
- Modern Python packaging with pyproject.toml and uv support
- Added requirements-tui.txt for TUI-specific dependencies
- Updated setup.py and install.sh for TUI integration
- Enhanced README.md with TUI usage documentation
2026-02-25 15:59:15 -06:00

341 lines
12 KiB
Python

"""
Unit tests for YouTubeService
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestYouTubeService:
"""Tests for YouTubeService class"""
@pytest.fixture
def service(self):
"""Create YouTubeService instance"""
from unittest.mock import MagicMock
from youtube_tui.services.youtube import YouTubeService
with patch("youtube_cli.main.YouTubeCLI"):
service = YouTubeService.__new__(YouTubeService)
service.cli = MagicMock()
# Set up format_duration to use the real implementation
def real_format_duration(seconds):
if not seconds:
return "0:00"
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes}:{secs:02d}"
service.cli.format_duration = real_format_duration
service.console = MagicMock()
return service
@pytest.fixture
def mock_yt_dlp_result(self):
"""Mock yt-dlp search result"""
return {
"id": "abc123",
"title": "Sample Video",
"author": "Sample Channel",
"channel": "Sample Channel",
"channel_id": "channel123",
"length": "10:30",
"view_count": 150000,
"upload_date": "20240115",
"description": "A sample video description",
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
"url": "https://www.youtube.com/watch?v=abc123",
}
@pytest.mark.asyncio
async def test_search_videos_success(self, service, mock_yt_dlp_result):
"""Test successful video search"""
from youtube_tui.models.video import Video
# Setup mock
service.cli.search_videos.return_value = [mock_yt_dlp_result]
service._create_video_from_result = MagicMock(
return_value=MagicMock(spec=Video)
)
# Call the method - patch asyncio.to_thread since that's how it's imported
with patch("asyncio.to_thread") as mock_to_thread:
# The _search function returns a list of Video objects
mock_to_thread.return_value = [mock_yt_dlp_result]
results = await service.search_videos("test query")
# Verify
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_search_videos_empty_result(self, service):
"""Test search with no results"""
service.cli.search_videos.return_value = []
results = await service.search_videos("test query")
assert results == []
@pytest.mark.asyncio
async def test_search_videos_error(self, service):
"""Test search error handling"""
service.cli.search_videos.side_effect = Exception("Network error")
service.console.print = MagicMock()
with pytest.raises(Exception): # SearchError wrapped in asyncio
await service.search_videos("test query")
@pytest.mark.asyncio
async def test_search_videos_custom_page(self, service, mock_yt_dlp_result):
"""Test search with custom page parameter"""
from unittest.mock import MagicMock
from youtube_tui.models.video import Video
service.cli.search_videos.return_value = [mock_yt_dlp_result]
# Mock _create_video_from_result to return proper Video objects
service._create_video_from_result = MagicMock(
return_value=MagicMock(
spec=Video,
video_id="abc123",
title="Sample Video",
channel="Sample Channel",
channel_id="channel123",
duration="10:30",
view_count="150000",
upload_date="20240115",
description="A sample video description",
thumbnail_url="https://i.ytimg.com/vi/abc123/hqdefault.jpg",
url="https://www.youtube.com/watch?v=abc123",
is_short=False,
)
)
# Mock asyncio.to_thread to execute the actual _search function
async def mock_to_thread(func, *args, **kwargs):
# Execute the function synchronously
result = func()
return result
with patch("asyncio.to_thread", mock_to_thread):
await service.search_videos("test query", page=2, per_page=10)
# Note: per_page is ignored per the implementation
service.cli.search_videos.assert_called_once_with(
"test query", service.cli.config, 2, return_results=True
)
@pytest.mark.asyncio
async def test_download_video_success(self, service, mock_video):
"""Test successful video download"""
service.cli.download_video.return_value = True
result = await service.download_video(mock_video, "Music")
assert result is True
service.cli.download_video.assert_called_once()
@pytest.mark.asyncio
async def test_download_video_failure(self, service, mock_video):
"""Test video download failure"""
service.cli.download_video.return_value = False
service.console.print = MagicMock()
result = await service.download_video(mock_video, "Music")
assert result is False
@pytest.mark.asyncio
async def test_download_video_error(self, service, mock_video):
"""Test download error handling"""
service.cli.download_video.side_effect = Exception("Download failed")
service.console.print = MagicMock()
with pytest.raises(Exception): # DownloadError wrapped in asyncio
await service.download_video(mock_video, "Music")
@pytest.mark.asyncio
async def test_download_playlist_success(self, service, mock_video):
"""Test successful playlist download"""
service.cli.download_playlist.return_value = True
result = await service.download_playlist(mock_video, "Music")
assert result is True
service.cli.download_playlist.assert_called_once()
@pytest.mark.asyncio
async def test_download_playlist_error(self, service, mock_video):
"""Test playlist download error handling"""
service.cli.download_playlist.side_effect = Exception("Playlist error")
service.console.print = MagicMock()
with pytest.raises(Exception): # DownloadError wrapped in asyncio
await service.download_playlist(mock_video, "Music")
@pytest.mark.asyncio
async def test_get_categories_success(self, service):
"""Test getting categories"""
service.cli.get_categories.return_value = ["Music", "Videos", "Movies"]
categories = await service.get_categories()
assert categories == ["Music", "Videos", "Movies"]
@pytest.mark.asyncio
async def test_is_video_downloaded_true(self, service):
"""Test checking downloaded video (exists)"""
service.cli.is_video_downloaded.return_value = True
result = await service.is_video_downloaded("video123")
assert result is True
@pytest.mark.asyncio
async def test_is_video_downloaded_false(self, service):
"""Test checking downloaded video (not exists)"""
service.cli.is_video_downloaded.return_value = False
result = await service.is_video_downloaded("video123")
assert result is False
@pytest.mark.asyncio
async def test_add_to_archive_success(self, service, mock_video):
"""Test adding video to archive"""
service.cli.add_to_archive = MagicMock()
await service.add_to_archive(mock_video)
service.cli.add_to_archive.assert_called_once_with(
{
"url": mock_video.url,
"id": mock_video.video_id,
"title": mock_video.title,
}
)
@pytest.mark.asyncio
async def test_add_to_archive_error(self, service, mock_video):
"""Test archive error handling"""
service.cli.add_to_archive.side_effect = Exception("Archive error")
service.console.print = MagicMock()
with pytest.raises(Exception): # ArchiveError wrapped in asyncio
await service.add_to_archive(mock_video)
@pytest.mark.asyncio
async def test_get_archive_success(self, service):
"""Test loading archive"""
mock_archive = {
"video123": {
"url": "https://youtube.com/watch?v=video123",
"id": "video123",
}
}
service.cli.load_archive.return_value = mock_archive
result = await service.get_archive()
assert result == mock_archive
@pytest.mark.asyncio
async def test_get_downloaded_video_ids(self, service):
"""Test getting downloaded video IDs"""
mock_archive = {
"video1": {"url": "https://youtube.com/watch?v=video1"},
"video2": {"url": "https://youtube.com/watch?v=video2"},
}
service.get_archive = AsyncMock(return_value=mock_archive)
result = await service.get_downloaded_video_ids()
assert result == {"video1", "video2"}
@pytest.mark.asyncio
async def test_remove_from_archive_success(self, service):
"""Test removing video from archive"""
service.cli.load_archive.return_value = {"video123": {"url": "test"}}
service.cli.save_archive = MagicMock()
result = await service.remove_from_archive("video123")
assert result is True
@pytest.mark.asyncio
async def test_remove_from_archive_not_found(self, service):
"""Test removing non-existent video from archive"""
service.cli.load_archive.return_value = {"video123": {"url": "test"}}
service.cli.save_archive = MagicMock()
result = await service.remove_from_archive("video999")
assert result is False
service.cli.save_archive.assert_not_called()
@pytest.mark.asyncio
async def test_remove_from_archive_error(self, service):
"""Test archive removal error handling"""
service.cli.load_archive.side_effect = Exception("Archive error")
service.cli.save_archive = MagicMock()
result = await service.remove_from_archive("video123")
assert result is False
def test_create_video_from_result(self, service, mock_yt_dlp_result):
"""Test creating Video from yt-dlp result"""
result = service._create_video_from_result(mock_yt_dlp_result)
assert result.video_id == "abc123"
assert result.title == "Sample Video"
assert result.channel == "Sample Channel"
assert result.duration == "10:30"
assert result.view_count == "150000"
def test_create_video_from_result_channel_field(self, service):
"""Test creating Video with channel field instead of author"""
result_data = {
"id": "abc123",
"title": "Sample Video",
"channel": "Sample Channel",
"channel_id": "channel123",
"length": "5:00",
"view_count": 1000,
"upload_date": "20240101",
"description": "Test",
"thumbnail": "https://example.com/thumb.jpg",
"url": "https://youtube.com/watch?v=abc123",
}
result = service._create_video_from_result(result_data)
assert result.channel == "Sample Channel"
def test_format_duration(self, service):
"""Test duration formatting"""
result = service.format_duration(3665)
# 3665 seconds = 1 hour, 1 minute, 5 seconds
# Format should be HH:MM:SS or MM:SS
assert result in ["1:01:05", "01:01:05"]
def test_format_duration_minutes(self, service):
"""Test duration formatting for minutes"""
result = service.format_duration(125)
assert result == "2:05"
def test_format_duration_seconds(self, service):
"""Test duration formatting for seconds only"""
result = service.format_duration(30)
assert result == "0:30"