NoDataFile #1

Merged
lukas.pupkalipinski merged 70 commits from NoDataFile into main 2026-01-09 18:42:18 +01:00
3 changed files with 568 additions and 1 deletions
Showing only changes of commit de58161014 - Show all commits

View File

@ -112,7 +112,7 @@ The current implementation stores anime series metadata in `data` files (JSON fo
--- ---
### Task 2: Create Startup Migration Script ### Task 2: Create Startup Migration Script
**File:** `src/server/services/startup_migration.py` **File:** `src/server/services/startup_migration.py`

View File

@ -0,0 +1,206 @@
"""Startup migration runner for data file to database migration.
This module provides functions to run the data file migration automatically
during application startup. The migration checks for existing data files
in the anime directory and migrates them to the database.
Usage:
This module is intended to be called from the FastAPI lifespan context.
Example:
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... initialization ...
await ensure_migration_on_startup()
yield
# ... cleanup ...
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
from src.server.database.connection import get_db_session
from src.server.services.config_service import ConfigService
from src.server.services.data_migration_service import (
MigrationResult,
get_data_migration_service,
)
logger = logging.getLogger(__name__)
async def run_startup_migration(anime_directory: str) -> MigrationResult:
"""Run data file migration for the given anime directory.
Checks if there are data files to migrate and runs the migration
if needed. This function is idempotent - running it multiple times
will only migrate files that haven't been migrated yet.
Args:
anime_directory: Path to the anime directory containing
series folders with data files
Returns:
MigrationResult: Results of the migration operation,
including counts of migrated, skipped, and failed items
Note:
This function creates its own database session and commits
the transaction at the end of the migration.
"""
service = get_data_migration_service()
# Check if migration is needed
if not service.is_migration_needed(anime_directory):
logger.info(
"No data files found to migrate in: %s",
anime_directory
)
return MigrationResult(total_found=0)
logger.info(
"Starting data file migration from: %s",
anime_directory
)
# Get database session and run migration
async with get_db_session() as db:
result = await service.migrate_all(anime_directory, db)
# Log results
if result.migrated > 0 or result.failed > 0:
logger.info(
"Migration complete: %d migrated, %d skipped, %d failed",
result.migrated,
result.skipped,
result.failed
)
if result.errors:
for error in result.errors:
logger.warning("Migration error: %s", error)
return result
def _get_anime_directory_from_config() -> Optional[str]:
"""Get anime directory from application configuration.
Attempts to load the configuration file and extract the
anime_directory setting from the 'other' config section.
Returns:
Anime directory path if configured, None otherwise
"""
try:
config_service = ConfigService()
config = config_service.load_config()
# anime_directory is stored in the 'other' dict
anime_dir = config.other.get("anime_directory")
if anime_dir:
anime_dir = str(anime_dir).strip()
if anime_dir:
return anime_dir
return None
except Exception as e:
logger.warning(
"Could not load anime directory from config: %s",
e
)
return None
async def ensure_migration_on_startup() -> Optional[MigrationResult]:
"""Ensure data file migration runs during application startup.
This function should be called during FastAPI application startup.
It loads the anime directory from configuration and runs the
migration if the directory is configured and contains data files.
Returns:
MigrationResult if migration was run, None if skipped
(e.g., when no anime directory is configured)
Behavior:
- Returns None if anime_directory is not configured (first run)
- Returns None if anime_directory does not exist
- Returns MigrationResult with total_found=0 if no data files exist
- Returns MigrationResult with migration counts if migration ran
Note:
This function catches and logs all exceptions without re-raising,
ensuring that startup migration failures don't block application
startup. Check the logs for any migration errors.
Example:
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
try:
result = await ensure_migration_on_startup()
if result:
logger.info(
"Migration: %d migrated, %d failed",
result.migrated,
result.failed
)
except Exception as e:
logger.error("Migration failed: %s", e)
yield
await close_db()
"""
# Get anime directory from config
anime_directory = _get_anime_directory_from_config()
if not anime_directory:
logger.debug(
"No anime directory configured, skipping migration"
)
return None
# Validate directory exists
anime_path = Path(anime_directory)
if not anime_path.exists():
logger.warning(
"Anime directory does not exist: %s, skipping migration",
anime_directory
)
return None
if not anime_path.is_dir():
logger.warning(
"Anime directory path is not a directory: %s, skipping migration",
anime_directory
)
return None
logger.info(
"Checking for data files to migrate in: %s",
anime_directory
)
try:
result = await run_startup_migration(anime_directory)
return result
except Exception as e:
logger.error(
"Data file migration failed: %s",
e,
exc_info=True
)
# Return empty result rather than None to indicate we attempted
return MigrationResult(
total_found=0,
failed=1,
errors=[f"Migration failed: {str(e)}"]
)

View File

@ -0,0 +1,361 @@
"""Unit tests for startup migration module.
This module contains comprehensive tests for the startup migration runner,
including testing migration execution, configuration loading, and error handling.
"""
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.data_migration_service import MigrationResult
from src.server.services.startup_migration import (
_get_anime_directory_from_config,
ensure_migration_on_startup,
run_startup_migration,
)
class TestRunStartupMigration:
"""Test run_startup_migration function."""
@pytest.mark.asyncio
async def test_migration_skipped_when_no_data_files(self):
"""Test that migration is skipped when no data files exist."""
with tempfile.TemporaryDirectory() as tmp_dir:
with patch(
'src.server.services.startup_migration.get_data_migration_service'
) as mock_get_service:
mock_service = MagicMock()
mock_service.is_migration_needed.return_value = False
mock_get_service.return_value = mock_service
result = await run_startup_migration(tmp_dir)
assert result.total_found == 0
assert result.migrated == 0
mock_service.migrate_all.assert_not_called()
@pytest.mark.asyncio
async def test_migration_runs_when_data_files_exist(self):
"""Test that migration runs when data files exist."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a data file
series_dir = Path(tmp_dir) / "Test Series"
series_dir.mkdir()
(series_dir / "data").write_text('{"key": "test"}')
expected_result = MigrationResult(
total_found=1,
migrated=1,
skipped=0,
failed=0
)
with patch(
'src.server.services.startup_migration.get_data_migration_service'
) as mock_get_service:
mock_service = MagicMock()
mock_service.is_migration_needed.return_value = True
mock_service.migrate_all = AsyncMock(return_value=expected_result)
mock_get_service.return_value = mock_service
with patch(
'src.server.services.startup_migration.get_db_session'
) as mock_get_db:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(
return_value=mock_db
)
mock_get_db.return_value.__aexit__ = AsyncMock()
result = await run_startup_migration(tmp_dir)
assert result.total_found == 1
assert result.migrated == 1
mock_service.migrate_all.assert_called_once()
@pytest.mark.asyncio
async def test_migration_logs_errors(self):
"""Test that migration errors are logged."""
with tempfile.TemporaryDirectory() as tmp_dir:
expected_result = MigrationResult(
total_found=2,
migrated=1,
skipped=0,
failed=1,
errors=["Error: Could not read file"]
)
with patch(
'src.server.services.startup_migration.get_data_migration_service'
) as mock_get_service:
mock_service = MagicMock()
mock_service.is_migration_needed.return_value = True
mock_service.migrate_all = AsyncMock(return_value=expected_result)
mock_get_service.return_value = mock_service
with patch(
'src.server.services.startup_migration.get_db_session'
) as mock_get_db:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(
return_value=mock_db
)
mock_get_db.return_value.__aexit__ = AsyncMock()
result = await run_startup_migration(tmp_dir)
assert result.failed == 1
assert len(result.errors) == 1
class TestGetAnimeDirectoryFromConfig:
"""Test _get_anime_directory_from_config function."""
def test_returns_anime_directory_when_configured(self):
"""Test returns anime directory when properly configured."""
mock_config = MagicMock()
mock_config.other = {"anime_directory": "/path/to/anime"}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result == "/path/to/anime"
def test_returns_none_when_not_configured(self):
"""Test returns None when anime directory is not configured."""
mock_config = MagicMock()
mock_config.other = {}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result is None
def test_returns_none_when_anime_directory_empty(self):
"""Test returns None when anime directory is empty string."""
mock_config = MagicMock()
mock_config.other = {"anime_directory": ""}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result is None
def test_returns_none_when_anime_directory_whitespace(self):
"""Test returns None when anime directory is whitespace only."""
mock_config = MagicMock()
mock_config.other = {"anime_directory": " "}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result is None
def test_returns_none_when_config_load_fails(self):
"""Test returns None when configuration loading fails."""
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.side_effect = Exception("Config error")
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result is None
def test_strips_whitespace_from_directory(self):
"""Test that whitespace is stripped from anime directory."""
mock_config = MagicMock()
mock_config.other = {"anime_directory": " /path/to/anime "}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
result = _get_anime_directory_from_config()
assert result == "/path/to/anime"
class TestEnsureMigrationOnStartup:
"""Test ensure_migration_on_startup function."""
@pytest.mark.asyncio
async def test_returns_none_when_no_directory_configured(self):
"""Test returns None when anime directory is not configured."""
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value=None
):
result = await ensure_migration_on_startup()
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_directory_does_not_exist(self):
"""Test returns None when anime directory does not exist."""
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value="/nonexistent/path"
):
result = await ensure_migration_on_startup()
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_path_is_file(self):
"""Test returns None when path is a file, not directory."""
with tempfile.NamedTemporaryFile() as tmp_file:
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value=tmp_file.name
):
result = await ensure_migration_on_startup()
assert result is None
@pytest.mark.asyncio
async def test_runs_migration_when_directory_exists(self):
"""Test migration runs when directory exists and is configured."""
with tempfile.TemporaryDirectory() as tmp_dir:
expected_result = MigrationResult(total_found=0)
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value=tmp_dir
):
with patch(
'src.server.services.startup_migration.run_startup_migration',
new_callable=AsyncMock,
return_value=expected_result
) as mock_run:
result = await ensure_migration_on_startup()
assert result is not None
assert result.total_found == 0
mock_run.assert_called_once_with(tmp_dir)
@pytest.mark.asyncio
async def test_catches_migration_errors(self):
"""Test that migration errors are caught and logged."""
with tempfile.TemporaryDirectory() as tmp_dir:
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value=tmp_dir
):
with patch(
'src.server.services.startup_migration.run_startup_migration',
new_callable=AsyncMock,
side_effect=Exception("Database error")
):
result = await ensure_migration_on_startup()
# Should return error result, not raise
assert result is not None
assert result.failed == 1
assert len(result.errors) == 1
assert "Database error" in result.errors[0]
@pytest.mark.asyncio
async def test_returns_migration_result_with_counts(self):
"""Test returns proper migration result with counts."""
with tempfile.TemporaryDirectory() as tmp_dir:
expected_result = MigrationResult(
total_found=5,
migrated=3,
skipped=1,
failed=1,
errors=["Error 1"]
)
with patch(
'src.server.services.startup_migration._get_anime_directory_from_config',
return_value=tmp_dir
):
with patch(
'src.server.services.startup_migration.run_startup_migration',
new_callable=AsyncMock,
return_value=expected_result
):
result = await ensure_migration_on_startup()
assert result.total_found == 5
assert result.migrated == 3
assert result.skipped == 1
assert result.failed == 1
class TestStartupMigrationIntegration:
"""Integration tests for startup migration workflow."""
@pytest.mark.asyncio
async def test_full_workflow_no_config(self):
"""Test full workflow when config is missing."""
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.side_effect = FileNotFoundError()
MockConfigService.return_value = mock_service
result = await ensure_migration_on_startup()
assert result is None
@pytest.mark.asyncio
async def test_full_workflow_with_config_no_data_files(self):
"""Test full workflow with config but no data files."""
with tempfile.TemporaryDirectory() as tmp_dir:
mock_config = MagicMock()
mock_config.other = {"anime_directory": tmp_dir}
with patch(
'src.server.services.startup_migration.ConfigService'
) as MockConfigService:
mock_service = MagicMock()
mock_service.load_config.return_value = mock_config
MockConfigService.return_value = mock_service
with patch(
'src.server.services.startup_migration.get_data_migration_service'
) as mock_get_service:
migration_service = MagicMock()
migration_service.is_migration_needed.return_value = False
mock_get_service.return_value = migration_service
result = await ensure_migration_on_startup()
assert result is not None
assert result.total_found == 0