feat(setup): track unresolved folders for manual key resolution

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
This commit is contained in:
2026-06-05 21:07:52 +02:00
parent d9738ffb78
commit ecef21eec4
7 changed files with 944 additions and 40 deletions

View File

@@ -0,0 +1,244 @@
"""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"}]'