diff --git a/instructions.md b/instructions.md index 71c7954..030be8e 100644 --- a/instructions.md +++ b/instructions.md @@ -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` diff --git a/src/server/services/startup_migration.py b/src/server/services/startup_migration.py new file mode 100644 index 0000000..08bb05d --- /dev/null +++ b/src/server/services/startup_migration.py @@ -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)}"] + ) diff --git a/tests/unit/test_startup_migration.py b/tests/unit/test_startup_migration.py new file mode 100644 index 0000000..94cb885 --- /dev/null +++ b/tests/unit/test_startup_migration.py @@ -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