When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:
- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder
The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs
New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests
Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
244 lines
8.0 KiB
Python
244 lines
8.0 KiB
Python
"""Tests for UnresolvedFolderService and UnresolvedFolder model."""
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.database.models import UnresolvedFolder
|
|
from src.server.database.service import UnresolvedFolderService
|
|
|
|
|
|
class TestUnresolvedFolderModel:
|
|
"""Test UnresolvedFolder model."""
|
|
|
|
def test_is_resolved_false_when_no_key(self):
|
|
"""provider_key is None → is_resolved is False."""
|
|
folder = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
provider_key=None,
|
|
resolved_at=None,
|
|
)
|
|
assert folder.is_resolved is False
|
|
|
|
def test_is_resolved_false_when_key_but_no_timestamp(self):
|
|
"""provider_key set but resolved_at is None → is_resolved is False."""
|
|
folder = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
provider_key="test-key",
|
|
resolved_at=None,
|
|
)
|
|
assert folder.is_resolved is False
|
|
|
|
def test_is_resolved_true_when_both_set(self):
|
|
"""Both provider_key and resolved_at set → is_resolved is True."""
|
|
folder = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
provider_key="test-key",
|
|
resolved_at=datetime.now(timezone.utc),
|
|
)
|
|
assert folder.is_resolved is True
|
|
|
|
def test_validate_folder_name_empty_raises(self):
|
|
"""Empty folder_name → raises ValueError during construction."""
|
|
with pytest.raises(ValueError, match="Folder name cannot be empty"):
|
|
UnresolvedFolder(
|
|
folder_name="",
|
|
title="Test",
|
|
)
|
|
|
|
def test_validate_folder_name_too_long_raises(self):
|
|
"""Folder name > 1000 chars → raises ValueError during construction."""
|
|
long_name = "x" * 1001
|
|
with pytest.raises(ValueError, match="Folder name must be 1000 characters"):
|
|
UnresolvedFolder(
|
|
folder_name=long_name,
|
|
title="Test",
|
|
)
|
|
|
|
def test_validate_title_empty_raises(self):
|
|
"""Empty title → raises ValueError during construction."""
|
|
with pytest.raises(ValueError, match="Title cannot be empty"):
|
|
UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="",
|
|
)
|
|
|
|
|
|
class TestUnresolvedFolderService:
|
|
"""Test UnresolvedFolderService methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create(self):
|
|
"""Creates a new unresolved folder record."""
|
|
mock_db = AsyncMock()
|
|
mock_db.flush = AsyncMock()
|
|
mock_db.refresh = AsyncMock()
|
|
|
|
folder = await UnresolvedFolderService.create(
|
|
db=mock_db,
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
search_attempts=1,
|
|
)
|
|
|
|
assert folder.folder_name == "Test (2020)"
|
|
assert folder.title == "Test"
|
|
assert folder.year == 2020
|
|
assert folder.search_attempts == 1
|
|
mock_db.add.assert_called_once()
|
|
mock_db.flush.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_folder_name_found(self):
|
|
"""Found → returns UnresolvedFolder."""
|
|
mock_db = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
)
|
|
mock_db.execute.return_value = mock_result
|
|
|
|
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Test (2020)")
|
|
|
|
assert folder is not None
|
|
assert folder.folder_name == "Test (2020)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_by_folder_name_not_found(self):
|
|
"""Not found → returns None."""
|
|
mock_db = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = None
|
|
mock_db.execute.return_value = mock_result
|
|
|
|
folder = await UnresolvedFolderService.get_by_folder_name(mock_db, "Unknown")
|
|
|
|
assert folder is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_all_unresolved(self):
|
|
"""Returns only unresolved folders (no provider_key)."""
|
|
mock_db = AsyncMock()
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [
|
|
UnresolvedFolder(folder_name="Folder1", title="Title1", year=2020),
|
|
UnresolvedFolder(folder_name="Folder2", title="Title2", year=2021),
|
|
]
|
|
mock_db.execute.return_value = mock_result
|
|
|
|
folders = await UnresolvedFolderService.get_all_unresolved(mock_db)
|
|
|
|
assert len(folders) == 2
|
|
mock_db.execute.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve(self):
|
|
"""Marks folder as resolved with provider_key."""
|
|
mock_db = AsyncMock()
|
|
mock_db.flush = AsyncMock()
|
|
mock_db.refresh = AsyncMock()
|
|
|
|
existing = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
provider_key=None,
|
|
resolved_at=None,
|
|
)
|
|
|
|
with patch.object(
|
|
UnresolvedFolderService, 'get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=existing
|
|
):
|
|
result = await UnresolvedFolderService.resolve(
|
|
mock_db, "Test (2020)", "test-key"
|
|
)
|
|
|
|
assert result.provider_key == "test-key"
|
|
assert result.resolved_at is not None
|
|
mock_db.flush.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_not_found(self):
|
|
"""Folder not found → returns None."""
|
|
mock_db = AsyncMock()
|
|
|
|
with patch.object(
|
|
UnresolvedFolderService, 'get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await UnresolvedFolderService.resolve(
|
|
mock_db, "Unknown", "test-key"
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete(self):
|
|
"""Deletes unresolved folder record."""
|
|
mock_db = AsyncMock()
|
|
mock_db.flush = AsyncMock()
|
|
|
|
existing = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
)
|
|
|
|
with patch.object(
|
|
UnresolvedFolderService, 'get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=existing
|
|
):
|
|
result = await UnresolvedFolderService.delete(mock_db, "Test (2020)")
|
|
|
|
assert result is True
|
|
mock_db.delete.assert_called_once_with(existing)
|
|
mock_db.flush.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_not_found(self):
|
|
"""Folder not found → returns False."""
|
|
mock_db = AsyncMock()
|
|
|
|
with patch.object(
|
|
UnresolvedFolderService, 'get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=None
|
|
):
|
|
result = await UnresolvedFolderService.delete(mock_db, "Unknown")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_search_result(self):
|
|
"""Increments search_attempts and updates last_search_result."""
|
|
mock_db = AsyncMock()
|
|
mock_db.flush = AsyncMock()
|
|
mock_db.refresh = AsyncMock()
|
|
|
|
existing = UnresolvedFolder(
|
|
folder_name="Test (2020)",
|
|
title="Test",
|
|
year=2020,
|
|
search_attempts=1,
|
|
last_search_result=None,
|
|
)
|
|
|
|
with patch.object(
|
|
UnresolvedFolderService, 'get_by_folder_name',
|
|
new_callable=AsyncMock, return_value=existing
|
|
):
|
|
result = await UnresolvedFolderService.update_search_result(
|
|
mock_db, "Test (2020)", '[{"title": "Test"}]'
|
|
)
|
|
|
|
assert result.search_attempts == 2
|
|
assert result.last_search_result == '[{"title": "Test"}]' |