diff --git a/.coverage b/.coverage index 1c7be59..0629ed6 100644 Binary files a/.coverage and b/.coverage differ diff --git a/tests/unit/test_page_controller.py b/tests/unit/test_page_controller.py new file mode 100644 index 0000000..7870402 --- /dev/null +++ b/tests/unit/test_page_controller.py @@ -0,0 +1,578 @@ +"""Unit tests for page controller. + +This module tests the FastAPI page controller that serves HTML templates +for various application pages. + +Coverage Target: 85%+ +""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request + + +@pytest.fixture +def mock_request(): + """Create a mock FastAPI request.""" + request = MagicMock(spec=Request) + request.url = MagicMock() + request.url.path = "/" + request.headers = {} + return request + + +class TestRootEndpoint: + """Test the root / endpoint.""" + + @pytest.mark.asyncio + async def test_root_endpoint_calls_render_template(self, mock_request): + """Test root endpoint calls render_template.""" + from src.server.controllers.page_controller import root + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + result = await root(mock_request) + + mock_render.assert_called_once() + call_args = mock_render.call_args[0] + assert call_args[0] == "index.html" + + @pytest.mark.asyncio + async def test_root_endpoint_passes_correct_title(self, mock_request): + """Test root endpoint passes correct title.""" + from src.server.controllers.page_controller import root + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await root(mock_request) + + call_kwargs = mock_render.call_args[1] + assert call_kwargs["title"] == "Aniworld Download Manager" + + +class TestSetupEndpoint: + """Test the /setup endpoint.""" + + @pytest.mark.asyncio + async def test_setup_endpoint_renders_setup_html(self, mock_request): + """Test setup endpoint renders setup.html template.""" + from src.server.controllers.page_controller import setup_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await setup_page(mock_request) + + call_args = mock_render.call_args[0] + assert call_args[0] == "setup.html" + + @pytest.mark.asyncio + async def test_setup_endpoint_title(self, mock_request): + """Test setup endpoint passes correct title.""" + from src.server.controllers.page_controller import setup_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await setup_page(mock_request) + + call_kwargs = mock_render.call_args[1] + assert call_kwargs["title"] == "Setup - Aniworld" + + +class TestLoginEndpoint: + """Test the /login endpoint.""" + + @pytest.mark.asyncio + async def test_login_endpoint_renders_login_html(self, mock_request): + """Test login endpoint renders login.html template.""" + from src.server.controllers.page_controller import login_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await login_page(mock_request) + + call_args = mock_render.call_args[0] + assert call_args[0] == "login.html" + + @pytest.mark.asyncio + async def test_login_endpoint_title(self, mock_request): + """Test login endpoint passes correct title.""" + from src.server.controllers.page_controller import login_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await login_page(mock_request) + + call_kwargs = mock_render.call_args[1] + assert call_kwargs["title"] == "Login - Aniworld" + + +class TestQueueEndpoint: + """Test the /queue endpoint.""" + + @pytest.mark.asyncio + async def test_queue_endpoint_renders_queue_html(self, mock_request): + """Test queue endpoint renders queue.html template.""" + from src.server.controllers.page_controller import queue_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await queue_page(mock_request) + + call_args = mock_render.call_args[0] + assert call_args[0] == "queue.html" + + @pytest.mark.asyncio + async def test_queue_endpoint_title(self, mock_request): + """Test queue endpoint passes correct title.""" + from src.server.controllers.page_controller import queue_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await queue_page(mock_request) + + call_kwargs = mock_render.call_args[1] + assert call_kwargs["title"] == "Download Queue - Aniworld" + + +class TestLoadingEndpoint: + """Test the /loading endpoint.""" + + @pytest.mark.asyncio + async def test_loading_endpoint_renders_loading_html(self, mock_request): + """Test loading endpoint renders loading.html template.""" + from src.server.controllers.page_controller import loading_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await loading_page(mock_request) + + call_args = mock_render.call_args[0] + assert call_args[0] == "loading.html" + + @pytest.mark.asyncio + async def test_loading_endpoint_title(self, mock_request): + """Test loading endpoint passes correct title.""" + from src.server.controllers.page_controller import loading_page + + with patch('src.server.controllers.page_controller.render_template') as mock_render: + mock_render.return_value = "" + + await loading_page(mock_request) + + call_kwargs = mock_render.call_args[1] + assert call_kwargs["title"] == "Initializing - Aniworld" + + +class TestTemplateHelpers: + """Test template helper functions.""" + + def test_get_base_context(self): + """Test getting base context.""" + from src.server.utils.template_helpers import get_base_context + + mock_request = MagicMock(spec=Request) + context = get_base_context(mock_request, "Test Title") + + assert context["request"] == mock_request + assert context["title"] == "Test Title" + assert context["app_name"] == "Aniworld Download Manager" + assert context["version"] == "1.0.0" + + def test_get_base_context_default_title(self): + """Test getting base context with default title.""" + from src.server.utils.template_helpers import get_base_context + + mock_request = MagicMock(spec=Request) + context = get_base_context(mock_request) + + assert context["title"] == "Aniworld" + + def test_render_template_basic(self): + """Test render_template function.""" + from src.server.utils.template_helpers import render_template + + mock_request = MagicMock(spec=Request) + + with patch('src.server.utils.template_helpers.templates.TemplateResponse') as mock_response: + mock_response.return_value = "rendered" + + result = render_template("index.html", mock_request, title="Test") + + assert result == "rendered" + mock_response.assert_called_once() + + def test_render_template_with_context(self): + """Test render_template with additional context.""" + from src.server.utils.template_helpers import render_template + + mock_request = MagicMock(spec=Request) + extra_context = {"user": "test_user", "is_authenticated": True} + + with patch('src.server.utils.template_helpers.templates.TemplateResponse') as mock_response: + mock_response.return_value = "rendered" + + render_template("index.html", mock_request, context=extra_context) + + call_args = mock_response.call_args[0] + context_arg = call_args[1] + assert context_arg["user"] == "test_user" + assert context_arg["is_authenticated"] is True + + def test_render_template_title_generation(self): + """Test render_template generates title from template name.""" + from src.server.utils.template_helpers import render_template + + mock_request = MagicMock(spec=Request) + + with patch('src.server.utils.template_helpers.templates.TemplateResponse') as mock_response: + mock_response.return_value = "rendered" + + render_template("index.html", mock_request) # No title provided + + call_args = mock_response.call_args[0] + context_arg = call_args[1] + # Title should be generated from template name + assert context_arg["title"] == "Index" + + +class TestValidateTemplateExists: + """Test template existence validation.""" + + def test_validate_template_exists_true(self, tmp_path): + """Test validate_template_exists returns True for existing template.""" + from src.server.utils.template_helpers import validate_template_exists + + with patch('src.server.utils.template_helpers.TEMPLATES_DIR', tmp_path): + template_file = tmp_path / "test.html" + template_file.write_text("") + + result = validate_template_exists("test.html") + assert result is True + + def test_validate_template_exists_false(self, tmp_path): + """Test validate_template_exists returns False for missing template.""" + from src.server.utils.template_helpers import validate_template_exists + + with patch('src.server.utils.template_helpers.TEMPLATES_DIR', tmp_path): + result = validate_template_exists("nonexistent.html") + assert result is False + + +class TestListAvailableTemplates: + """Test listing available templates.""" + + def test_list_available_templates(self, tmp_path): + """Test listing available templates.""" + from src.server.utils.template_helpers import list_available_templates + + # Create test template files + (tmp_path / "index.html").write_text("") + (tmp_path / "setup.html").write_text("") + (tmp_path / "README.md").write_text("# README") # Should not be included + + with patch('src.server.utils.template_helpers.TEMPLATES_DIR', tmp_path): + templates = list_available_templates() + + assert "index.html" in templates + assert "setup.html" in templates + assert "README.md" not in templates + assert len(templates) == 2 + + def test_list_available_templates_empty_directory(self, tmp_path): + """Test listing templates from empty directory.""" + from src.server.utils.template_helpers import list_available_templates + + with patch('src.server.utils.template_helpers.TEMPLATES_DIR', tmp_path): + templates = list_available_templates() + + assert templates == [] + + def test_list_available_templates_missing_directory(self): + """Test listing templates when directory doesn't exist.""" + from src.server.utils.template_helpers import list_available_templates + + with patch('src.server.utils.template_helpers.TEMPLATES_DIR', Path("/nonexistent")): + templates = list_available_templates() + + assert templates == [] + + +class TestPrepareSeriesContext: + """Test series context preparation for templates.""" + + def test_prepare_series_context_basic(self): + """Test preparing basic series context.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan", "folder": "AOT"}, + {"key": "one-piece", "name": "One Piece", "folder": "OP"} + ] + + result = prepare_series_context(series_data) + + assert len(result) == 2 + assert result[0]["key"] == "attack-on-titan" + assert result[0]["name"] == "Attack on Titan" + assert result[0]["folder"] == "AOT" + + def test_prepare_series_context_sort_by_name(self): + """Test preparing series context sorted by name.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"key": "one-piece", "name": "One Piece", "folder": "OP"}, + {"key": "attack-on-titan", "name": "Attack on Titan", "folder": "AOT"}, + {"key": "death-note", "name": "Death Note", "folder": "DN"} + ] + + result = prepare_series_context(series_data, sort_by="name") + + assert result[0]["name"] == "Attack on Titan" + assert result[1]["name"] == "Death Note" + assert result[2]["name"] == "One Piece" + + def test_prepare_series_context_sort_by_key(self): + """Test preparing series context sorted by key.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"key": "z-series", "name": "Z Series", "folder": "Z"}, + {"key": "a-series", "name": "A Series", "folder": "A"}, + {"key": "m-series", "name": "M Series", "folder": "M"} + ] + + result = prepare_series_context(series_data, sort_by="key") + + assert result[0]["key"] == "a-series" + assert result[1]["key"] == "m-series" + assert result[2]["key"] == "z-series" + + def test_prepare_series_context_sort_by_folder(self): + """Test preparing series context sorted by folder.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"key": "series1", "name": "Series 1", "folder": "Z Folder"}, + {"key": "series2", "name": "Series 2", "folder": "A Folder"}, + {"key": "series3", "name": "Series 3", "folder": "M Folder"} + ] + + result = prepare_series_context(series_data, sort_by="folder") + + assert result[0]["folder"] == "A Folder" + assert result[1]["folder"] == "M Folder" + assert result[2]["folder"] == "Z Folder" + + def test_prepare_series_context_empty_list(self): + """Test preparing empty series context.""" + from src.server.utils.template_helpers import prepare_series_context + + result = prepare_series_context([]) + + assert result == [] + + def test_prepare_series_context_missing_key(self): + """Test preparing series context with missing key field.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"name": "No Key Series", "folder": "NoKey"}, + {"key": "valid-key", "name": "Valid Series", "folder": "Valid"} + ] + + result = prepare_series_context(series_data) + + # Only the series with key should be included + assert len(result) == 1 + assert result[0]["key"] == "valid-key" + + def test_prepare_series_context_default_values(self): + """Test preparing series context fills in default values.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + {"key": "minimal-series"} # Missing name and folder + ] + + result = prepare_series_context(series_data) + + assert result[0]["key"] == "minimal-series" + assert result[0]["name"] == "minimal-series" # Should use key as default + assert result[0]["folder"] == "" # Should default to empty string + + def test_prepare_series_context_extra_fields(self): + """Test preparing series context preserves extra fields.""" + from src.server.utils.template_helpers import prepare_series_context + + series_data = [ + { + "key": "series1", + "name": "Series 1", + "folder": "S1", + "year": 2020, + "status": "completed", + "episodes": 12 + } + ] + + result = prepare_series_context(series_data) + + assert result[0]["year"] == 2020 + assert result[0]["status"] == "completed" + assert result[0]["episodes"] == 12 + + +class TestGetSeriesByKey: + """Test finding series by key.""" + + def test_get_series_by_key_found(self): + """Test getting series by key when found.""" + from src.server.utils.template_helpers import get_series_by_key + + series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan"}, + {"key": "one-piece", "name": "One Piece"} + ] + + result = get_series_by_key(series_data, "attack-on-titan") + + assert result is not None + assert result["name"] == "Attack on Titan" + + def test_get_series_by_key_not_found(self): + """Test getting series by key when not found.""" + from src.server.utils.template_helpers import get_series_by_key + + series_data = [ + {"key": "attack-on-titan", "name": "Attack on Titan"} + ] + + result = get_series_by_key(series_data, "nonexistent") + + assert result is None + + def test_get_series_by_key_empty_list(self): + """Test getting series by key from empty list.""" + from src.server.utils.template_helpers import get_series_by_key + + result = get_series_by_key([], "any-key") + + assert result is None + + def test_get_series_by_key_case_sensitive(self): + """Test that key matching is case-sensitive.""" + from src.server.utils.template_helpers import get_series_by_key + + series_data = [ + {"key": "Attack-on-Titan", "name": "Attack on Titan"} + ] + + result = get_series_by_key(series_data, "attack-on-titan") + + assert result is None # Different case should not match + + +class TestFilterSeriesByMissingEpisodes: + """Test filtering series by missing episodes.""" + + def test_filter_series_with_missing_episodes(self): + """Test filtering series with missing episodes.""" + from src.server.utils.template_helpers import filter_series_by_missing_episodes + + series_data = [ + { + "key": "series1", + "name": "Series 1", + "missing_episodes": {"season_1": [1, 2, 3]} + }, + { + "key": "series2", + "name": "Series 2", + "missing_episodes": {"season_1": []} + }, + { + "key": "series3", + "name": "Series 3", + "missing_episodes": {"season_1": [5]} + } + ] + + result = filter_series_by_missing_episodes(series_data) + + assert len(result) == 2 + assert result[0]["key"] == "series1" + assert result[1]["key"] == "series3" + + def test_filter_series_no_missing_episodes(self): + """Test filtering when no series have missing episodes.""" + from src.server.utils.template_helpers import filter_series_by_missing_episodes + + series_data = [ + { + "key": "series1", + "name": "Series 1", + "missing_episodes": {"season_1": []} + }, + { + "key": "series2", + "name": "Series 2", + "missing_episodes": {} + } + ] + + result = filter_series_by_missing_episodes(series_data) + + assert len(result) == 0 + + def test_filter_series_empty_list(self): + """Test filtering empty series list.""" + from src.server.utils.template_helpers import filter_series_by_missing_episodes + + result = filter_series_by_missing_episodes([]) + + assert len(result) == 0 + + def test_filter_series_missing_field(self): + """Test filtering when missing_episodes field is missing.""" + from src.server.utils.template_helpers import filter_series_by_missing_episodes + + series_data = [ + {"key": "series1", "name": "Series 1"} # No missing_episodes + ] + + result = filter_series_by_missing_episodes(series_data) + + # Should not crash and should not include this series + assert len(result) == 0 + + def test_filter_series_multiple_seasons(self): + """Test filtering with multiple seasons.""" + from src.server.utils.template_helpers import filter_series_by_missing_episodes + + series_data = [ + { + "key": "series1", + "name": "Series 1", + "missing_episodes": { + "season_1": [1, 2], + "season_2": [] + } + } + ] + + result = filter_series_by_missing_episodes(series_data) + + # Should include because season_1 has missing episodes + assert len(result) == 1