"""Tests for NFO diagnostics and repair API endpoints.""" from unittest.mock import AsyncMock, Mock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app from src.server.services.auth_service import auth_service @pytest.fixture(autouse=True) def reset_auth(): """Reset authentication state before each test.""" original_hash = auth_service._hash auth_service._hash = None auth_service._failed.clear() yield auth_service._hash = original_hash auth_service._failed.clear() @pytest.fixture async def client(): """Create an async test client.""" transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac @pytest.fixture async def authenticated_client(client): """Create an authenticated test client with token.""" await client.post( "/api/auth/setup", json={"master_password": "TestPassword123!"} ) response = await client.post( "/api/auth/login", json={"password": "TestPassword123!"} ) token = response.json()["access_token"] client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_series_app(): """Create mock series app with one test series.""" app_mock = Mock() serie = Mock() serie.key = "test-anime" serie.folder = "Test Anime (2024)" serie.name = "Test Anime" serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)") list_manager = Mock() list_manager.GetList = Mock(return_value=[serie]) app_mock.list = list_manager return app_mock @pytest.fixture def mock_nfo_service(): """Create mock NFO service.""" service = Mock() service.check_nfo_exists = AsyncMock(return_value=False) service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo") return service @pytest.fixture def override_dependencies(mock_series_app, mock_nfo_service): """Override dependencies for NFO tests.""" from src.server.api.nfo import get_nfo_service from src.server.utils.dependencies import get_series_app app.dependency_overrides[get_series_app] = lambda: mock_series_app app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service yield if get_series_app in app.dependency_overrides: del app.dependency_overrides[get_series_app] if get_nfo_service in app.dependency_overrides: del app.dependency_overrides[get_nfo_service] class TestNfoDiagnostics: """Tests for GET /api/nfo/{serie_key}/diagnostics.""" @pytest.mark.asyncio async def test_diagnostics_complete_nfo( self, authenticated_client, override_dependencies ): """Test diagnostics with complete NFO returns no missing tags.""" with patch( "src.server.api.nfo.Path.exists", return_value=True ), patch( "src.server.api.nfo.find_missing_tags", return_value=[] ): response = await authenticated_client.get( "/api/nfo/test-anime/diagnostics" ) assert response.status_code == 200 data = response.json() assert data["has_nfo"] is True assert data["missing_tags"] == [] assert len(data["required_tags"]) > 0 @pytest.mark.asyncio async def test_diagnostics_missing_tags( self, authenticated_client, override_dependencies ): """Test diagnostics with missing tags returns them.""" with patch( "src.server.api.nfo.Path.exists", return_value=True ), patch( "src.server.api.nfo.find_missing_tags", return_value=["plot", "genre", "actor/name"], ): response = await authenticated_client.get( "/api/nfo/test-anime/diagnostics" ) assert response.status_code == 200 data = response.json() assert data["has_nfo"] is True assert "plot" in data["missing_tags"] assert "genre" in data["missing_tags"] assert len(data["missing_tags"]) == 3 @pytest.mark.asyncio async def test_diagnostics_no_nfo_file( self, authenticated_client, override_dependencies ): """Test diagnostics when no NFO exists returns all tags as missing.""" with patch("src.server.api.nfo.Path") as MockPath: # Make nfo_path.exists() return False mock_path_instance = Mock() mock_path_instance.exists.return_value = False mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance) MockPath.return_value = mock_path_instance response = await authenticated_client.get( "/api/nfo/test-anime/diagnostics" ) assert response.status_code == 200 data = response.json() assert data["has_nfo"] is False assert len(data["missing_tags"]) > 0 # All required tags should be listed as missing assert data["missing_tags"] == data["required_tags"] @pytest.mark.asyncio async def test_diagnostics_nonexistent_series_404( self, authenticated_client, override_dependencies, mock_series_app ): """Test diagnostics for non-existent series returns 404.""" # Override to return empty list mock_series_app.list.GetList.return_value = [] response = await authenticated_client.get( "/api/nfo/nonexistent-key/diagnostics" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_diagnostics_unauthenticated_401(self, client): """Test diagnostics requires authentication.""" response = await client.get("/api/nfo/test-anime/diagnostics") # May return 401 or 503 depending on NFO service availability assert response.status_code in (401, 503) class TestNfoRepair: """Tests for POST /api/nfo/{serie_key}/repair.""" @pytest.mark.asyncio async def test_repair_success( self, authenticated_client, override_dependencies ): """Test successful NFO repair.""" with patch("src.server.api.nfo.Path") as MockPath: mock_path = Mock() mock_path.exists.return_value = True mock_path.__truediv__ = Mock(return_value=mock_path) MockPath.return_value = mock_path with patch( "src.server.api.nfo.find_missing_tags", return_value=["plot", "genre"], ), patch( "src.server.api.nfo.NfoRepairService" ) as MockRepairService: mock_instance = Mock() mock_instance.repair_series = AsyncMock(return_value=True) MockRepairService.return_value = mock_instance response = await authenticated_client.post( "/api/nfo/test-anime/repair", json={} ) assert response.status_code == 200 data = response.json() assert data["success"] is True assert "2" in data["message"] # "Fixed 2 missing tags" assert "plot" in data["repaired_tags"] assert "genre" in data["repaired_tags"] @pytest.mark.asyncio async def test_repair_already_complete( self, authenticated_client, override_dependencies ): """Test repair when NFO is already complete.""" with patch("src.server.api.nfo.Path") as MockPath: mock_path = Mock() mock_path.exists.return_value = True mock_path.__truediv__ = Mock(return_value=mock_path) MockPath.return_value = mock_path with patch( "src.server.api.nfo.find_missing_tags", return_value=[] ), patch( "src.server.api.nfo.NfoRepairService" ) as MockRepairService: mock_instance = Mock() mock_instance.repair_series = AsyncMock(return_value=False) MockRepairService.return_value = mock_instance response = await authenticated_client.post( "/api/nfo/test-anime/repair", json={} ) assert response.status_code == 200 data = response.json() assert data["success"] is True assert "already complete" in data["message"] @pytest.mark.asyncio async def test_repair_creates_new_nfo( self, authenticated_client, override_dependencies, mock_nfo_service ): """Test repair when no NFO exists creates a new one.""" with patch("src.server.api.nfo.Path") as MockPath: mock_path = Mock() mock_path.exists.return_value = False mock_path.__truediv__ = Mock(return_value=mock_path) MockPath.return_value = mock_path with patch( "src.server.api.nfo.REQUIRED_TAGS", {"./title": "title", "./plot": "plot"}, ): response = await authenticated_client.post( "/api/nfo/test-anime/repair", json={} ) assert response.status_code == 200 data = response.json() assert data["success"] is True mock_nfo_service.create_tvshow_nfo.assert_awaited_once() @pytest.mark.asyncio async def test_repair_nonexistent_series_404( self, authenticated_client, override_dependencies, mock_series_app ): """Test repair for non-existent series returns 404.""" mock_series_app.list.GetList.return_value = [] response = await authenticated_client.post( "/api/nfo/nonexistent-key/repair", json={} ) assert response.status_code == 404 @pytest.mark.asyncio async def test_repair_unauthenticated_401(self, client): """Test repair requires authentication.""" response = await client.post("/api/nfo/test-anime/repair", json={}) assert response.status_code in (401, 503) @pytest.mark.asyncio async def test_repair_tmdb_api_failure( self, authenticated_client, override_dependencies ): """Test repair handles TMDB API failure gracefully.""" from src.server.nfo.tmdb_client import TMDBAPIError with patch("src.server.api.nfo.Path") as MockPath: mock_path = Mock() mock_path.exists.return_value = True mock_path.__truediv__ = Mock(return_value=mock_path) MockPath.return_value = mock_path with patch( "src.server.api.nfo.find_missing_tags", return_value=["plot"], ), patch( "src.server.api.nfo.NfoRepairService" ) as MockRepairService: mock_instance = Mock() mock_instance.repair_series = AsyncMock( side_effect=TMDBAPIError("No TMDB ID found") ) MockRepairService.return_value = mock_instance response = await authenticated_client.post( "/api/nfo/test-anime/repair", json={} ) assert response.status_code == 400 assert "Cannot repair NFO" in response.json()["detail"]