Aniworld/src/tests/e2e/test_cli_flows.py
Lukas Pupka-Lipinski 3f98dd6ebb Implement application setup and flow middleware
- Add SetupService for detecting application setup completion
- Create ApplicationFlowMiddleware to enforce setup  auth  main flow
- Add setup processing endpoints (/api/auth/setup, /api/auth/setup/status)
- Add Pydantic models for setup requests and responses
- Integrate middleware into FastAPI application
- Fix logging paths to use ./logs consistently
- All existing templates (setup.html, login.html) already working
2025-10-06 12:48:18 +02:00

407 lines
16 KiB
Python

"""
End-to-end tests for CLI flows.
Tests complete CLI workflows including progress bar functionality,
retry logic, user interactions, and error scenarios.
"""
import os
import sys
import tempfile
from unittest.mock import Mock, patch
import pytest
# Add source directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
# Import after path setup
from src.cli.Main import SeriesApp # noqa: E402
@pytest.fixture
def temp_directory():
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir
@pytest.mark.e2e
class TestCLICompleteWorkflows:
"""Test complete CLI workflows from user perspective."""
def test_search_and_download_workflow(self, temp_directory):
"""Test complete search -> select -> download workflow."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Mock search results
mock_search_results = [
{"name": "Test Anime", "link": "test_link"}
]
# Mock series for download
mock_episode_dict = {1: [1, 2, 3], 2: [1, 2]}
mock_series = Mock(
episodeDict=mock_episode_dict,
folder="test_anime",
key="test_key"
)
app.series_list = [mock_series]
# Mock loader
mock_loader = Mock()
mock_loader.Search.return_value = mock_search_results
mock_loader.IsLanguage.return_value = True
mock_loader.Download.return_value = None
app.Loaders.GetLoader.return_value = mock_loader
# Test search workflow
with patch('builtins.input', side_effect=['test query', '1']), \
patch('builtins.print'):
app.search_mode()
# Should have called search and add
mock_loader.Search.assert_called_with('test query')
app.List.add.assert_called_once()
# Test download workflow
with patch('rich.progress.Progress') as mock_progress_class, \
patch('time.sleep'), \
patch('builtins.input', return_value='1'):
mock_progress = Mock()
mock_progress_class.return_value = mock_progress
selected_series = app.get_user_selection()
assert selected_series is not None
app.download_series(selected_series)
# Should have set up progress tracking
mock_progress.start.assert_called_once()
mock_progress.stop.assert_called_once()
# Should have attempted downloads for all episodes
expected_downloads = sum(len(episodes) for episodes in mock_episode_dict.values())
assert mock_loader.Download.call_count == expected_downloads
def test_init_and_rescan_workflow(self, temp_directory):
"""Test initialization and rescanning workflow."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner') as mock_scanner_class, \
patch('src.cli.Main.SerieList') as mock_list_class:
mock_scanner = Mock()
mock_scanner_class.return_value = mock_scanner
mock_list = Mock()
mock_list_class.return_value = mock_list
app = SeriesApp(temp_directory)
app.SerieScanner = mock_scanner
# Test rescan workflow
with patch('rich.progress.Progress') as mock_progress_class, \
patch('builtins.print'):
mock_progress = Mock()
mock_progress_class.return_value = mock_progress
# Simulate init action
app.progress = mock_progress
app.task1 = "task1_id"
# Call reinit workflow
app.SerieScanner.Reinit()
app.SerieScanner.Scan(app.updateFromReinit)
# Should have called scanner methods
mock_scanner.Reinit.assert_called_once()
mock_scanner.Scan.assert_called_once()
def test_error_recovery_workflow(self, temp_directory):
"""Test error recovery in CLI workflows."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Test retry mechanism with eventual success
mock_func = Mock(side_effect=[
Exception("First failure"),
Exception("Second failure"),
None # Success on third try
])
with patch('time.sleep'), patch('builtins.print'):
result = app.retry(mock_func, max_retries=3, delay=0)
assert result is True
assert mock_func.call_count == 3
# Test retry mechanism with persistent failure
mock_func_fail = Mock(side_effect=Exception("Persistent error"))
with patch('time.sleep'), patch('builtins.print'):
result = app.retry(mock_func_fail, max_retries=2, delay=0)
assert result is False
assert mock_func_fail.call_count == 2
@pytest.mark.e2e
class TestCLIUserInteractionFlows:
"""Test CLI user interaction flows."""
def test_user_selection_validation_flow(self, temp_directory):
"""Test user selection with various invalid inputs before success."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
app.series_list = [
Mock(name="Anime 1", folder="anime1"),
Mock(name="Anime 2", folder="anime2")
]
# Test sequence: invalid text -> invalid number -> valid selection
input_sequence = ['invalid_text', '999', '1']
with patch('builtins.input', side_effect=input_sequence), \
patch('builtins.print'):
selected = app.get_user_selection()
assert selected is not None
assert len(selected) == 1
assert selected[0].name == "Anime 1"
def test_search_interaction_flow(self, temp_directory):
"""Test search interaction with various user inputs."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
mock_search_results = [
{"name": "Result 1", "link": "link1"},
{"name": "Result 2", "link": "link2"}
]
mock_loader = Mock()
mock_loader.Search.return_value = mock_search_results
app.Loaders.GetLoader.return_value = mock_loader
# Test sequence: search -> invalid selection -> valid selection
with patch('builtins.input', side_effect=['test search', '999', '1']), \
patch('builtins.print'):
app.search_mode()
# Should have added the selected item
app.List.add.assert_called_once()
def test_main_loop_interaction_flow(self, temp_directory):
"""Test main application loop with user interactions."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
app.series_list = [Mock(name="Test Anime", folder="test")]
# Mock various components
with patch.object(app, 'search_mode') as mock_search, \
patch.object(app, 'get_user_selection', return_value=[Mock()]), \
patch.object(app, 'download_series') as mock_download, \
patch('rich.progress.Progress'), \
patch('builtins.print'):
# Test sequence: search -> download -> exit
with patch('builtins.input', side_effect=['s', 'd', KeyboardInterrupt()]):
try:
app.run()
except KeyboardInterrupt:
pass
mock_search.assert_called_once()
mock_download.assert_called_once()
@pytest.mark.e2e
class TestCLIProgressAndFeedback:
"""Test CLI progress indicators and user feedback."""
def test_download_progress_flow(self, temp_directory):
"""Test download progress tracking throughout workflow."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Mock series with episodes
mock_series = [
Mock(
episodeDict={1: [1, 2], 2: [1]},
folder="anime1",
key="key1"
)
]
# Mock loader
mock_loader = Mock()
mock_loader.IsLanguage.return_value = True
mock_loader.Download.return_value = None
app.Loaders.GetLoader.return_value = mock_loader
with patch('rich.progress.Progress') as mock_progress_class, \
patch('time.sleep'):
mock_progress = Mock()
mock_progress_class.return_value = mock_progress
app.download_series(mock_series)
# Verify progress setup
assert mock_progress.add_task.call_count >= 3 # At least 3 tasks
mock_progress.start.assert_called_once()
mock_progress.stop.assert_called_once()
# Verify progress updates
assert mock_progress.update.call_count > 0
def test_progress_callback_integration(self, temp_directory):
"""Test progress callback integration with download system."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
app.progress = Mock()
app.task3 = "download_task"
# Test various progress states
progress_states = [
{
'status': 'downloading',
'total_bytes': 1000000,
'downloaded_bytes': 250000
},
{
'status': 'downloading',
'total_bytes': 1000000,
'downloaded_bytes': 750000
},
{
'status': 'finished'
}
]
for state in progress_states:
app.print_Download_Progress(state)
# Should have updated progress for each state
assert app.progress.update.call_count == len(progress_states)
# Last call should indicate completion
last_call = app.progress.update.call_args_list[-1]
assert last_call[1].get('completed') == 100
def test_scan_progress_integration(self, temp_directory):
"""Test scanning progress integration."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
app.progress = Mock()
app.task1 = "scan_task"
# Simulate scan progress updates
for i in range(5):
app.updateFromReinit("folder", i)
# Should have updated progress for each folder
assert app.progress.update.call_count == 5
# Each call should advance by 1
for call in app.progress.update.call_args_list:
assert call[1].get('advance') == 1
@pytest.mark.e2e
class TestCLIErrorScenarios:
"""Test CLI error scenarios and recovery."""
def test_network_error_recovery(self, temp_directory):
"""Test recovery from network errors during operations."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Mock network failures
network_error = Exception("Network connection failed")
mock_func = Mock(side_effect=[network_error, network_error, None])
with patch('time.sleep'), patch('builtins.print'):
result = app.retry(mock_func, max_retries=3, delay=0)
assert result is True
assert mock_func.call_count == 3
def test_invalid_directory_handling(self):
"""Test handling of invalid directory paths."""
invalid_directory = "/nonexistent/path/that/does/not/exist"
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
# Should not raise exception during initialization
app = SeriesApp(invalid_directory)
assert app.directory_to_search == invalid_directory
def test_empty_search_results_handling(self, temp_directory):
"""Test handling of empty search results."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Mock empty search results
mock_loader = Mock()
mock_loader.Search.return_value = []
app.Loaders.GetLoader.return_value = mock_loader
with patch('builtins.input', return_value='nonexistent anime'), \
patch('builtins.print') as mock_print:
app.search_mode()
# Should print "No results found" message
print_calls = [call[0][0] for call in mock_print.call_args_list]
assert any("No results found" in call for call in print_calls)
def test_keyboard_interrupt_handling(self, temp_directory):
"""Test graceful handling of keyboard interrupts."""
with patch('src.cli.Main.Loaders'), \
patch('src.cli.Main.SerieScanner'), \
patch('src.cli.Main.SerieList'):
app = SeriesApp(temp_directory)
# Test that KeyboardInterrupt propagates correctly
with patch('builtins.input', side_effect=KeyboardInterrupt()):
with pytest.raises(KeyboardInterrupt):
app.run()