Add startup migration runner (Task 2)
This commit is contained in:
361
tests/unit/test_startup_migration.py
Normal file
361
tests/unit/test_startup_migration.py
Normal 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
|
||||
Reference in New Issue
Block a user