- Create NFO API models (11 Pydantic models)
- Implement 8 REST API endpoints for NFO management
- Register NFO router in FastAPI app
- Create 18 comprehensive API tests
- Add detailed status documentation
Endpoints:
- GET /api/nfo/{id}/check - Check NFO/media status
- POST /api/nfo/{id}/create - Create NFO & media
- PUT /api/nfo/{id}/update - Update NFO
- GET /api/nfo/{id}/content - Get NFO content
- GET /api/nfo/{id}/media/status - Media status
- POST /api/nfo/{id}/media/download - Download media
- POST /api/nfo/batch/create - Batch operations
- GET /api/nfo/missing - List missing NFOs
Remaining: Refactor to use series_app dependency pattern
496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""Tests for NFO API endpoints.
|
|
|
|
This module tests all NFO management REST API endpoints.
|
|
"""
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
from src.server.fastapi_app import app
|
|
from src.server.services.auth_service import auth_service
|
|
from src.server.models.nfo import (
|
|
MediaFilesStatus,
|
|
NFOCheckResponse,
|
|
NFOCreateResponse,
|
|
)
|
|
|
|
|
|
@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."""
|
|
# Setup master password
|
|
await client.post(
|
|
"/api/auth/setup",
|
|
json={"master_password": "TestPassword123!"}
|
|
)
|
|
|
|
# Login to get token
|
|
response = await client.post(
|
|
"/api/auth/login",
|
|
json={"password": "TestPassword123!"}
|
|
)
|
|
token = response.json()["access_token"]
|
|
|
|
# Add token to default headers
|
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_anime_service():
|
|
"""Create mock anime service."""
|
|
service = Mock()
|
|
serie = Mock()
|
|
serie.key = "test-anime"
|
|
serie.folder = "Test Anime (2024)"
|
|
serie.name = "Test Anime"
|
|
service.get_series_list = Mock(return_value=[serie])
|
|
return service
|
|
|
|
|
|
@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
|
|
|
|
|
|
class TestNFOCheckEndpoint:
|
|
"""Tests for GET /api/nfo/{serie_id}/check endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_nfo_requires_auth(self, client):
|
|
"""Test that check endpoint requires authentication."""
|
|
response = await client.get("/api/nfo/test-anime/check")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_nfo_series_not_found(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service
|
|
):
|
|
"""Test check endpoint with non-existent series."""
|
|
mock_anime_service.get_series_list = Mock(return_value=[])
|
|
|
|
with patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
response = await authenticated_client.get(
|
|
"/api/nfo/nonexistent/check"
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_nfo_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test successful NFO check."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.get(
|
|
"/api/nfo/test-anime/check"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["serie_id"] == "test-anime"
|
|
assert data["serie_folder"] == "Test Anime (2024)"
|
|
assert data["has_nfo"] is False
|
|
|
|
|
|
class TestNFOCreateEndpoint:
|
|
"""Tests for POST /api/nfo/{serie_id}/create endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_requires_auth(self, client):
|
|
"""Test that create endpoint requires authentication."""
|
|
response = await client.post(
|
|
"/api/nfo/test-anime/create",
|
|
json={}
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test successful NFO creation."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/nfo/test-anime/create",
|
|
json={
|
|
"download_poster": True,
|
|
"download_logo": True,
|
|
"download_fanart": True
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["serie_id"] == "test-anime"
|
|
assert "NFO and media files created successfully" in data["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_already_exists(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test NFO creation when NFO already exists."""
|
|
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
|
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/nfo/test-anime/create",
|
|
json={"overwrite_existing": False}
|
|
)
|
|
assert response.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_with_year(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test NFO creation with year parameter."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/nfo/test-anime/create",
|
|
json={
|
|
"year": 2024,
|
|
"download_poster": True
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Verify year was passed to service
|
|
mock_nfo_service.create_tvshow_nfo.assert_called_once()
|
|
call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args[1]
|
|
assert call_kwargs["year"] == 2024
|
|
|
|
|
|
class TestNFOUpdateEndpoint:
|
|
"""Tests for PUT /api/nfo/{serie_id}/update endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_requires_auth(self, client):
|
|
"""Test that update endpoint requires authentication."""
|
|
response = await client.put("/api/nfo/test-anime/update")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_not_found(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test update when NFO doesn't exist."""
|
|
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=False)
|
|
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.put(
|
|
"/api/nfo/test-anime/update"
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test successful NFO update."""
|
|
mock_nfo_service.check_nfo_exists = AsyncMock(return_value=True)
|
|
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.put(
|
|
"/api/nfo/test-anime/update?download_media=true"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "NFO updated successfully" in data["message"]
|
|
|
|
|
|
class TestNFOContentEndpoint:
|
|
"""Tests for GET /api/nfo/{serie_id}/content endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_content_requires_auth(self, client):
|
|
"""Test that content endpoint requires authentication."""
|
|
response = await client.get("/api/nfo/test-anime/content")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_content_nfo_not_found(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test get content when NFO doesn't exist."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.get(
|
|
"/api/nfo/test-anime/content"
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_content_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test successful content retrieval."""
|
|
# Create NFO file
|
|
anime_dir = tmp_path / "Test Anime (2024)"
|
|
anime_dir.mkdir()
|
|
nfo_file = anime_dir / "tvshow.nfo"
|
|
nfo_file.write_text("<tvshow><title>Test</title></tvshow>")
|
|
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.get(
|
|
"/api/nfo/test-anime/content"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "<tvshow>" in data["content"]
|
|
assert data["file_size"] > 0
|
|
|
|
|
|
class TestNFOMissingEndpoint:
|
|
"""Tests for GET /api/nfo/missing endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_missing_requires_auth(self, client):
|
|
"""Test that missing endpoint requires authentication."""
|
|
response = await client.get("/api/nfo/missing")
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_missing_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test getting list of series without NFO."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.get("/api/nfo/missing")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "total_series" in data
|
|
assert "missing_nfo_count" in data
|
|
assert "series" in data
|
|
|
|
|
|
class TestNFOBatchCreateEndpoint:
|
|
"""Tests for POST /api/nfo/batch/create endpoint."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_create_requires_auth(self, client):
|
|
"""Test that batch create endpoint requires authentication."""
|
|
response = await client.post(
|
|
"/api/nfo/batch/create",
|
|
json={"serie_ids": ["test1", "test2"]}
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_create_success(
|
|
self,
|
|
authenticated_client,
|
|
mock_anime_service,
|
|
mock_nfo_service,
|
|
tmp_path
|
|
):
|
|
"""Test successful batch NFO creation."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings, \
|
|
patch(
|
|
'src.server.api.nfo.get_anime_service',
|
|
return_value=mock_anime_service
|
|
), \
|
|
patch(
|
|
'src.server.api.nfo.get_nfo_service',
|
|
return_value=mock_nfo_service
|
|
):
|
|
|
|
mock_settings.anime_directory = str(tmp_path)
|
|
|
|
response = await authenticated_client.post(
|
|
"/api/nfo/batch/create",
|
|
json={
|
|
"serie_ids": ["test-anime"],
|
|
"download_media": True,
|
|
"skip_existing": False,
|
|
"max_concurrent": 3
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 1
|
|
assert "successful" in data
|
|
assert "results" in data
|
|
|
|
|
|
class TestNFOServiceDependency:
|
|
"""Tests for NFO service dependency."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nfo_service_unavailable_without_api_key(
|
|
self,
|
|
authenticated_client
|
|
):
|
|
"""Test NFO endpoints fail gracefully without TMDB API key."""
|
|
with patch('src.server.api.nfo.settings') as mock_settings:
|
|
mock_settings.tmdb_api_key = None
|
|
|
|
response = await authenticated_client.get(
|
|
"/api/nfo/test-anime/check"
|
|
)
|
|
assert response.status_code == 503
|
|
assert "not configured" in response.json()["detail"]
|