migration removed
This commit is contained in:
@@ -1,494 +0,0 @@
|
||||
"""Integration tests for data file to database migration.
|
||||
|
||||
This module tests the complete migration workflow including:
|
||||
- Migration runs on server startup
|
||||
- App starts even if migration fails
|
||||
- Data files are correctly migrated to database
|
||||
- API endpoints save to database
|
||||
- Series list reads from database
|
||||
"""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.services.data_migration_service import DataMigrationService
|
||||
from src.server.services.startup_migration import ensure_migration_on_startup
|
||||
|
||||
|
||||
class TestMigrationStartupIntegration:
|
||||
"""Test migration integration with application startup."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_starts_with_migration(self):
|
||||
"""Test that app starts successfully with migration enabled."""
|
||||
from src.server.fastapi_app import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test"
|
||||
) as client:
|
||||
# App should start and health endpoint should work
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_with_valid_data_files(self):
|
||||
"""Test migration correctly processes data files."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create test data files
|
||||
for i in range(2):
|
||||
series_dir = Path(tmp_dir) / f"Test Series {i}"
|
||||
series_dir.mkdir()
|
||||
data = {
|
||||
"key": f"test-series-{i}",
|
||||
"name": f"Test Series {i}",
|
||||
"site": "aniworld.to",
|
||||
"folder": f"Test Series {i}",
|
||||
"episodeDict": {"1": [1, 2, 3]}
|
||||
}
|
||||
(series_dir / "data").write_text(json.dumps(data))
|
||||
|
||||
# Test migration scan
|
||||
service = DataMigrationService()
|
||||
data_files = service.scan_for_data_files(tmp_dir)
|
||||
|
||||
assert len(data_files) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_handles_corrupted_files(self):
|
||||
"""Test migration handles corrupted data files gracefully."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create valid data file
|
||||
valid_dir = Path(tmp_dir) / "Valid Series"
|
||||
valid_dir.mkdir()
|
||||
valid_data = {
|
||||
"key": "valid-series",
|
||||
"name": "Valid Series",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Valid Series",
|
||||
"episodeDict": {}
|
||||
}
|
||||
(valid_dir / "data").write_text(json.dumps(valid_data))
|
||||
|
||||
# Create corrupted data file
|
||||
invalid_dir = Path(tmp_dir) / "Invalid Series"
|
||||
invalid_dir.mkdir()
|
||||
(invalid_dir / "data").write_text("not valid json {{{")
|
||||
|
||||
# Migration should process valid file and report error for invalid
|
||||
service = DataMigrationService()
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock()
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
# Should have found 2 files
|
||||
assert result.total_found == 2
|
||||
# One should succeed, one should fail
|
||||
assert result.migrated == 1
|
||||
assert result.failed == 1
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
class TestMigrationWithConfig:
|
||||
"""Test migration with configuration file."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_uses_config_anime_directory(self):
|
||||
"""Test that migration reads anime directory from config."""
|
||||
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()
|
||||
|
||||
# Should check the correct directory
|
||||
migration_service.is_migration_needed.assert_called_once_with(
|
||||
tmp_dir
|
||||
)
|
||||
|
||||
|
||||
class TestMigrationIdempotency:
|
||||
"""Test that migration is idempotent."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_skips_existing_entries(self):
|
||||
"""Test that migration skips series already in database."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create data file
|
||||
series_dir = Path(tmp_dir) / "Test Series"
|
||||
series_dir.mkdir()
|
||||
data = {
|
||||
"key": "test-series",
|
||||
"name": "Test Series",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Series",
|
||||
"episodeDict": {"1": [1, 2]}
|
||||
}
|
||||
(series_dir / "data").write_text(json.dumps(data))
|
||||
|
||||
# Mock existing series in database with same episodes
|
||||
existing = MagicMock()
|
||||
existing.id = 1
|
||||
|
||||
# Mock episodes matching data file
|
||||
mock_episodes = [
|
||||
MagicMock(season=1, episode_number=1),
|
||||
MagicMock(season=1, episode_number=2),
|
||||
]
|
||||
|
||||
service = DataMigrationService()
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.EpisodeService'
|
||||
) as MockEpisodeService:
|
||||
MockService.get_by_key = AsyncMock(return_value=existing)
|
||||
MockEpisodeService.get_by_series = AsyncMock(
|
||||
return_value=mock_episodes
|
||||
)
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
# Should skip since data is same
|
||||
assert result.total_found == 1
|
||||
assert result.skipped == 1
|
||||
assert result.migrated == 0
|
||||
# Should not call create
|
||||
MockService.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_updates_changed_episodes(self):
|
||||
"""Test that migration updates series with changed episode data."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create data file with new episodes
|
||||
series_dir = Path(tmp_dir) / "Test Series"
|
||||
series_dir.mkdir()
|
||||
data = {
|
||||
"key": "test-series",
|
||||
"name": "Test Series",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Series",
|
||||
"episodeDict": {"1": [1, 2, 3, 4, 5]} # More episodes
|
||||
}
|
||||
(series_dir / "data").write_text(json.dumps(data))
|
||||
|
||||
# Mock existing series with fewer episodes
|
||||
existing = MagicMock()
|
||||
existing.id = 1
|
||||
|
||||
# Mock existing episodes (fewer than data file)
|
||||
mock_episodes = [
|
||||
MagicMock(season=1, episode_number=1),
|
||||
MagicMock(season=1, episode_number=2),
|
||||
]
|
||||
|
||||
service = DataMigrationService()
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.EpisodeService'
|
||||
) as MockEpisodeService:
|
||||
MockService.get_by_key = AsyncMock(return_value=existing)
|
||||
MockEpisodeService.get_by_series = AsyncMock(
|
||||
return_value=mock_episodes
|
||||
)
|
||||
MockEpisodeService.create = AsyncMock()
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
# Should update since data changed
|
||||
assert result.total_found == 1
|
||||
assert result.migrated == 1
|
||||
# Should create 3 new episodes (3, 4, 5)
|
||||
assert MockEpisodeService.create.call_count == 3
|
||||
|
||||
|
||||
class TestMigrationOnFreshStart:
|
||||
"""Test migration behavior on fresh application start."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_on_fresh_start_no_data_files(self):
|
||||
"""Test migration runs correctly when no data files exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
service = DataMigrationService()
|
||||
|
||||
# No data files should be found
|
||||
data_files = service.scan_for_data_files(tmp_dir)
|
||||
assert len(data_files) == 0
|
||||
|
||||
# is_migration_needed should return False
|
||||
assert service.is_migration_needed(tmp_dir) is False
|
||||
|
||||
# migrate_all should succeed with 0 processed
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
assert result.total_found == 0
|
||||
assert result.migrated == 0
|
||||
assert result.skipped == 0
|
||||
assert result.failed == 0
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
class TestAddSeriesSavesToDatabase:
|
||||
"""Test that adding series via API saves to database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_saves_to_database(self):
|
||||
"""Test add series endpoint saves to database when available."""
|
||||
# Mock database and service
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'src.server.api.anime.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock(return_value=MagicMock(id=1))
|
||||
|
||||
# Mock get_optional_database_session to return our mock
|
||||
with patch(
|
||||
'src.server.api.anime.get_optional_database_session'
|
||||
) as mock_get_db:
|
||||
async def mock_db_gen():
|
||||
yield mock_db
|
||||
mock_get_db.return_value = mock_db_gen()
|
||||
|
||||
# The endpoint should try to save to database
|
||||
# This is a unit-style integration test
|
||||
test_data = {
|
||||
"key": "test-anime-key",
|
||||
"name": "Test Anime",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Anime",
|
||||
"episodeDict": {"1": [1, 2, 3]}
|
||||
}
|
||||
|
||||
# Verify service would be called with correct data
|
||||
# (Full API test done in test_anime_endpoints.py)
|
||||
assert test_data["key"] == "test-anime-key"
|
||||
|
||||
|
||||
class TestScanSavesToDatabase:
|
||||
"""Test that scanning saves results to database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_async_saves_to_database(self):
|
||||
"""Test scan_async method saves series to database."""
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SerieScanner import SerieScanner
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create series folder structure
|
||||
series_folder = Path(tmp_dir) / "Test Anime"
|
||||
series_folder.mkdir()
|
||||
(series_folder / "Season 1").mkdir()
|
||||
(series_folder / "Season 1" / "ep1.mp4").touch()
|
||||
|
||||
# Mock loader
|
||||
mock_loader = MagicMock()
|
||||
mock_loader.getSerie.return_value = Serie(
|
||||
key="test-anime",
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
folder="Test Anime",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
|
||||
# Mock database session
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
# Patch the service at the source module
|
||||
with patch(
|
||||
'src.server.database.service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock()
|
||||
|
||||
scanner = SerieScanner(
|
||||
tmp_dir, mock_loader, db_session=mock_db
|
||||
)
|
||||
|
||||
# Verify scanner has db_session configured
|
||||
assert scanner._db_session is mock_db
|
||||
|
||||
# The scan_async method would use the database
|
||||
# when db_session is set. Testing configuration here.
|
||||
assert scanner._db_session is not None
|
||||
|
||||
|
||||
class TestSerieListReadsFromDatabase:
|
||||
"""Test that SerieList reads from database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_series_from_db(self):
|
||||
"""Test SerieList.load_series_from_db() method."""
|
||||
from src.core.entities.SerieList import SerieList
|
||||
|
||||
# Create mock database session
|
||||
mock_db = AsyncMock()
|
||||
|
||||
# Create mock series in database with spec to avoid mock attributes
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class MockEpisode:
|
||||
season: int
|
||||
episode_number: int
|
||||
|
||||
@dataclass
|
||||
class MockAnimeSeries:
|
||||
key: str
|
||||
name: str
|
||||
site: str
|
||||
folder: str
|
||||
episodes: list
|
||||
|
||||
mock_series = [
|
||||
MockAnimeSeries(
|
||||
key="anime-1",
|
||||
name="Anime 1",
|
||||
site="aniworld.to",
|
||||
folder="Anime 1",
|
||||
episodes=[
|
||||
MockEpisode(1, 1), MockEpisode(1, 2), MockEpisode(1, 3)
|
||||
]
|
||||
),
|
||||
MockAnimeSeries(
|
||||
key="anime-2",
|
||||
name="Anime 2",
|
||||
site="aniworld.to",
|
||||
folder="Anime 2",
|
||||
episodes=[
|
||||
MockEpisode(1, 1), MockEpisode(1, 2), MockEpisode(2, 1)
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
# Patch the service at the source module
|
||||
with patch(
|
||||
'src.server.database.service.AnimeSeriesService.get_all',
|
||||
new_callable=AsyncMock
|
||||
) as mock_get_all:
|
||||
mock_get_all.return_value = mock_series
|
||||
|
||||
# Create SerieList with db_session
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
serie_list = SerieList(
|
||||
tmp_dir, db_session=mock_db, skip_load=True
|
||||
)
|
||||
|
||||
# Load from database
|
||||
await serie_list.load_series_from_db(mock_db)
|
||||
|
||||
# Verify service was called with with_episodes=True
|
||||
mock_get_all.assert_called_once_with(mock_db, with_episodes=True)
|
||||
|
||||
# Verify series were loaded
|
||||
all_series = serie_list.get_all()
|
||||
assert len(all_series) == 2
|
||||
|
||||
# Verify we can look up by key
|
||||
anime1 = serie_list.get_by_key("anime-1")
|
||||
assert anime1 is not None
|
||||
assert anime1.name == "Anime 1"
|
||||
|
||||
|
||||
class TestSearchAndAddWorkflow:
|
||||
"""Test complete search and add workflow with database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_and_add_workflow(self):
|
||||
"""Test searching for anime and adding it saves to database."""
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Mock database
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
with patch('src.core.SeriesApp.Loaders') as MockLoaders:
|
||||
with patch('src.core.SeriesApp.SerieScanner') as MockScanner:
|
||||
with patch('src.core.SeriesApp.SerieList') as MockList:
|
||||
# Setup mocks
|
||||
mock_loader = MagicMock()
|
||||
mock_loader.search.return_value = [
|
||||
{"name": "Test Anime", "key": "test-anime"}
|
||||
]
|
||||
mock_loader.getSerie.return_value = Serie(
|
||||
key="test-anime",
|
||||
name="Test Anime",
|
||||
site="aniworld.to",
|
||||
folder="Test Anime",
|
||||
episodeDict={1: [1, 2, 3]}
|
||||
)
|
||||
|
||||
mock_loaders = MagicMock()
|
||||
mock_loaders.GetLoader.return_value = mock_loader
|
||||
MockLoaders.return_value = mock_loaders
|
||||
|
||||
mock_list = MagicMock()
|
||||
mock_list.GetMissingEpisode.return_value = []
|
||||
mock_list.add_to_db = AsyncMock()
|
||||
MockList.return_value = mock_list
|
||||
|
||||
mock_scanner = MagicMock()
|
||||
MockScanner.return_value = mock_scanner
|
||||
|
||||
# Create SeriesApp with database
|
||||
app = SeriesApp(tmp_dir, db_session=mock_db)
|
||||
|
||||
# Step 1: Search
|
||||
results = await app.search("test anime")
|
||||
assert len(results) == 1
|
||||
assert results[0]["name"] == "Test Anime"
|
||||
|
||||
# Step 2: Add to database
|
||||
serie = mock_loader.getSerie(results[0]["key"])
|
||||
await mock_list.add_to_db(serie, mock_db)
|
||||
|
||||
# Verify add_to_db was called
|
||||
mock_list.add_to_db.assert_called_once_with(
|
||||
serie, mock_db
|
||||
)
|
||||
@@ -318,25 +318,6 @@ class TestConfigServiceBackups:
|
||||
assert len(backups) == 3 # Should only keep max_backups
|
||||
|
||||
|
||||
class TestConfigServiceMigration:
|
||||
"""Test configuration migration."""
|
||||
|
||||
def test_migration_preserves_data(self, config_service, sample_config):
|
||||
"""Test that migration preserves configuration data."""
|
||||
# Manually save config with old version
|
||||
data = sample_config.model_dump()
|
||||
data["version"] = "0.9.0" # Old version
|
||||
|
||||
with open(config_service.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Load should migrate automatically
|
||||
loaded = config_service.load_config()
|
||||
|
||||
assert loaded.name == sample_config.name
|
||||
assert loaded.data_dir == sample_config.data_dir
|
||||
|
||||
|
||||
class TestConfigServiceSingleton:
|
||||
"""Test singleton instance management."""
|
||||
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
"""Unit tests for DataMigrationService.
|
||||
|
||||
This module contains comprehensive tests for the data migration service,
|
||||
including scanning for data files, migrating individual files,
|
||||
batch migration, and error handling.
|
||||
"""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.server.services.data_migration_service import (
|
||||
DataFileReadError,
|
||||
DataMigrationError,
|
||||
DataMigrationService,
|
||||
MigrationResult,
|
||||
get_data_migration_service,
|
||||
reset_data_migration_service,
|
||||
)
|
||||
|
||||
|
||||
class TestMigrationResult:
|
||||
"""Test MigrationResult dataclass."""
|
||||
|
||||
def test_migration_result_defaults(self):
|
||||
"""Test MigrationResult with default values."""
|
||||
result = MigrationResult()
|
||||
|
||||
assert result.total_found == 0
|
||||
assert result.migrated == 0
|
||||
assert result.skipped == 0
|
||||
assert result.failed == 0
|
||||
assert result.errors == []
|
||||
|
||||
def test_migration_result_with_values(self):
|
||||
"""Test MigrationResult with custom values."""
|
||||
result = MigrationResult(
|
||||
total_found=10,
|
||||
migrated=5,
|
||||
skipped=3,
|
||||
failed=2,
|
||||
errors=["Error 1", "Error 2"]
|
||||
)
|
||||
|
||||
assert result.total_found == 10
|
||||
assert result.migrated == 5
|
||||
assert result.skipped == 3
|
||||
assert result.failed == 2
|
||||
assert result.errors == ["Error 1", "Error 2"]
|
||||
|
||||
def test_migration_result_post_init_none_errors(self):
|
||||
"""Test that None errors list is converted to empty list."""
|
||||
# Create result then manually set errors to None
|
||||
result = MigrationResult()
|
||||
result.errors = None
|
||||
result.__post_init__()
|
||||
|
||||
assert result.errors == []
|
||||
|
||||
|
||||
class TestDataMigrationServiceScan:
|
||||
"""Test scanning for data files."""
|
||||
|
||||
def test_scan_empty_directory(self):
|
||||
"""Test scanning empty anime directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
result = service.scan_for_data_files(tmp_dir)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_scan_empty_string(self):
|
||||
"""Test scanning with empty string."""
|
||||
service = DataMigrationService()
|
||||
|
||||
result = service.scan_for_data_files("")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_scan_whitespace_string(self):
|
||||
"""Test scanning with whitespace string."""
|
||||
service = DataMigrationService()
|
||||
|
||||
result = service.scan_for_data_files(" ")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_scan_nonexistent_directory(self):
|
||||
"""Test scanning nonexistent directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
result = service.scan_for_data_files("/nonexistent/path")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_scan_file_instead_of_directory(self):
|
||||
"""Test scanning when path is a file, not directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.NamedTemporaryFile() as tmp_file:
|
||||
result = service.scan_for_data_files(tmp_file.name)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_scan_finds_data_files(self):
|
||||
"""Test scanning finds data files in series folders."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create series folders with data files
|
||||
series1 = Path(tmp_dir) / "Attack on Titan (2013)"
|
||||
series1.mkdir()
|
||||
(series1 / "data").write_text('{"key": "aot", "name": "AOT"}')
|
||||
|
||||
series2 = Path(tmp_dir) / "One Piece"
|
||||
series2.mkdir()
|
||||
(series2 / "data").write_text('{"key": "one-piece", "name": "OP"}')
|
||||
|
||||
# Create folder without data file
|
||||
series3 = Path(tmp_dir) / "No Data Here"
|
||||
series3.mkdir()
|
||||
|
||||
result = service.scan_for_data_files(tmp_dir)
|
||||
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(p, Path) for p in result)
|
||||
# Check filenames
|
||||
filenames = [p.name for p in result]
|
||||
assert all(name == "data" for name in filenames)
|
||||
|
||||
def test_scan_ignores_files_in_root(self):
|
||||
"""Test scanning ignores files directly in anime directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a 'data' file in root (should be ignored)
|
||||
(Path(tmp_dir) / "data").write_text('{"key": "root"}')
|
||||
|
||||
# Create series folder with data file
|
||||
series1 = Path(tmp_dir) / "Series One"
|
||||
series1.mkdir()
|
||||
(series1 / "data").write_text('{"key": "series-one"}')
|
||||
|
||||
result = service.scan_for_data_files(tmp_dir)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].parent.name == "Series One"
|
||||
|
||||
def test_scan_ignores_nested_data_files(self):
|
||||
"""Test scanning only finds data files one level deep."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create nested folder structure
|
||||
series1 = Path(tmp_dir) / "Series One"
|
||||
series1.mkdir()
|
||||
(series1 / "data").write_text('{"key": "series-one"}')
|
||||
|
||||
# Create nested subfolder with data (should be ignored)
|
||||
nested = series1 / "Season 1"
|
||||
nested.mkdir()
|
||||
(nested / "data").write_text('{"key": "nested"}')
|
||||
|
||||
result = service.scan_for_data_files(tmp_dir)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].parent.name == "Series One"
|
||||
|
||||
|
||||
class TestDataMigrationServiceReadFile:
|
||||
"""Test reading data files."""
|
||||
|
||||
def test_read_valid_data_file(self):
|
||||
"""Test reading a valid data file."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = Path(tmp_dir) / "data"
|
||||
serie_data = {
|
||||
"key": "attack-on-titan",
|
||||
"name": "Attack on Titan",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Attack on Titan (2013)",
|
||||
"episodeDict": {"1": [1, 2, 3]}
|
||||
}
|
||||
data_file.write_text(json.dumps(serie_data))
|
||||
|
||||
result = service._read_data_file(data_file)
|
||||
|
||||
assert result is not None
|
||||
assert result.key == "attack-on-titan"
|
||||
assert result.name == "Attack on Titan"
|
||||
assert result.site == "aniworld.to"
|
||||
assert result.folder == "Attack on Titan (2013)"
|
||||
|
||||
def test_read_file_not_found(self):
|
||||
"""Test reading nonexistent file raises error."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with pytest.raises(DataFileReadError) as exc_info:
|
||||
service._read_data_file(Path("/nonexistent/data"))
|
||||
|
||||
assert "not found" in str(exc_info.value).lower() or "Error reading" in str(exc_info.value)
|
||||
|
||||
def test_read_file_empty_key(self):
|
||||
"""Test reading file with empty key raises error."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = Path(tmp_dir) / "data"
|
||||
serie_data = {
|
||||
"key": "",
|
||||
"name": "No Key Series",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test",
|
||||
"episodeDict": {}
|
||||
}
|
||||
data_file.write_text(json.dumps(serie_data))
|
||||
|
||||
with pytest.raises(DataFileReadError) as exc_info:
|
||||
service._read_data_file(data_file)
|
||||
|
||||
# The Serie class will raise ValueError for empty key
|
||||
assert "empty" in str(exc_info.value).lower() or "key" in str(exc_info.value).lower()
|
||||
|
||||
def test_read_file_invalid_json(self):
|
||||
"""Test reading file with invalid JSON raises error."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = Path(tmp_dir) / "data"
|
||||
data_file.write_text("not valid json {{{")
|
||||
|
||||
with pytest.raises(DataFileReadError):
|
||||
service._read_data_file(data_file)
|
||||
|
||||
def test_read_file_missing_required_fields(self):
|
||||
"""Test reading file with missing required fields raises error."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = Path(tmp_dir) / "data"
|
||||
# Missing 'key' field
|
||||
data_file.write_text('{"name": "Test", "site": "test.com"}')
|
||||
|
||||
with pytest.raises(DataFileReadError):
|
||||
service._read_data_file(data_file)
|
||||
|
||||
|
||||
class TestDataMigrationServiceMigrateSingle:
|
||||
"""Test migrating single data files."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""Create a mock database session."""
|
||||
return AsyncMock()
|
||||
|
||||
@pytest.fixture
|
||||
def sample_serie(self):
|
||||
"""Create a sample Serie for testing."""
|
||||
return Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="Attack on Titan (2013)",
|
||||
episodeDict={1: [1, 2, 3], 2: [1, 2]}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_new_series(self, mock_db, sample_serie):
|
||||
"""Test migrating a new series to database."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = Path(tmp_dir) / "data"
|
||||
sample_serie.save_to_file(str(data_file))
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_read_data_file',
|
||||
return_value=sample_serie
|
||||
):
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock()
|
||||
|
||||
result = await service.migrate_data_file(data_file, mock_db)
|
||||
|
||||
assert result is True
|
||||
MockService.create.assert_called_once()
|
||||
# Verify the key was passed correctly
|
||||
call_kwargs = MockService.create.call_args.kwargs
|
||||
assert call_kwargs['key'] == "attack-on-titan"
|
||||
assert call_kwargs['name'] == "Attack on Titan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_existing_series_same_data(self, mock_db, sample_serie):
|
||||
"""Test migrating series that already exists with same data."""
|
||||
service = DataMigrationService()
|
||||
|
||||
# Create mock existing series with same episodes
|
||||
existing = MagicMock()
|
||||
existing.id = 1
|
||||
|
||||
# Mock episodes matching sample_serie.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
||||
mock_episodes = []
|
||||
for season, eps in {1: [1, 2, 3], 2: [1, 2]}.items():
|
||||
for ep_num in eps:
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.season = season
|
||||
mock_ep.episode_number = ep_num
|
||||
mock_episodes.append(mock_ep)
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_read_data_file',
|
||||
return_value=sample_serie
|
||||
):
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.EpisodeService'
|
||||
) as MockEpisodeService:
|
||||
MockService.get_by_key = AsyncMock(return_value=existing)
|
||||
MockEpisodeService.get_by_series = AsyncMock(
|
||||
return_value=mock_episodes
|
||||
)
|
||||
|
||||
result = await service.migrate_data_file(
|
||||
Path("/fake/data"),
|
||||
mock_db
|
||||
)
|
||||
|
||||
assert result is False
|
||||
MockService.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_existing_series_different_data(self, mock_db):
|
||||
"""Test migrating series that exists with different episodes."""
|
||||
service = DataMigrationService()
|
||||
|
||||
# Serie with new episodes
|
||||
serie = Serie(
|
||||
key="attack-on-titan",
|
||||
name="Attack on Titan",
|
||||
site="aniworld.to",
|
||||
folder="AOT",
|
||||
episodeDict={1: [1, 2, 3, 4, 5]} # More episodes than existing
|
||||
)
|
||||
|
||||
# Existing series has fewer episodes
|
||||
existing = MagicMock()
|
||||
existing.id = 1
|
||||
|
||||
# Mock episodes for existing (only 3 episodes)
|
||||
mock_episodes = []
|
||||
for ep_num in [1, 2, 3]:
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.season = 1
|
||||
mock_ep.episode_number = ep_num
|
||||
mock_episodes.append(mock_ep)
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_read_data_file',
|
||||
return_value=serie
|
||||
):
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.EpisodeService'
|
||||
) as MockEpisodeService:
|
||||
MockService.get_by_key = AsyncMock(return_value=existing)
|
||||
MockEpisodeService.get_by_series = AsyncMock(
|
||||
return_value=mock_episodes
|
||||
)
|
||||
MockEpisodeService.create = AsyncMock()
|
||||
|
||||
result = await service.migrate_data_file(
|
||||
Path("/fake/data"),
|
||||
mock_db
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# Should create 2 new episodes (4 and 5)
|
||||
assert MockEpisodeService.create.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_read_error(self, mock_db):
|
||||
"""Test migration handles read errors properly."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with patch.object(
|
||||
service,
|
||||
'_read_data_file',
|
||||
side_effect=DataFileReadError("Cannot read file")
|
||||
):
|
||||
with pytest.raises(DataFileReadError):
|
||||
await service.migrate_data_file(Path("/fake/data"), mock_db)
|
||||
|
||||
|
||||
class TestDataMigrationServiceMigrateAll:
|
||||
"""Test batch migration of data files."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""Create a mock database session."""
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
return db
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_all_empty_directory(self, mock_db):
|
||||
"""Test migration with no data files."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
assert result.total_found == 0
|
||||
assert result.migrated == 0
|
||||
assert result.skipped == 0
|
||||
assert result.failed == 0
|
||||
assert result.errors == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_all_success(self, mock_db):
|
||||
"""Test successful migration of multiple files."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create test data files
|
||||
for i in range(3):
|
||||
series_dir = Path(tmp_dir) / f"Series {i}"
|
||||
series_dir.mkdir()
|
||||
data = {
|
||||
"key": f"series-{i}",
|
||||
"name": f"Series {i}",
|
||||
"site": "aniworld.to",
|
||||
"folder": f"Series {i}",
|
||||
"episodeDict": {}
|
||||
}
|
||||
(series_dir / "data").write_text(json.dumps(data))
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
assert result.total_found == 3
|
||||
assert result.migrated == 3
|
||||
assert result.skipped == 0
|
||||
assert result.failed == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_all_with_errors(self, mock_db):
|
||||
"""Test migration continues after individual file errors."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create valid data file
|
||||
valid_dir = Path(tmp_dir) / "Valid Series"
|
||||
valid_dir.mkdir()
|
||||
valid_data = {
|
||||
"key": "valid-series",
|
||||
"name": "Valid Series",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Valid Series",
|
||||
"episodeDict": {}
|
||||
}
|
||||
(valid_dir / "data").write_text(json.dumps(valid_data))
|
||||
|
||||
# Create invalid data file
|
||||
invalid_dir = Path(tmp_dir) / "Invalid Series"
|
||||
invalid_dir.mkdir()
|
||||
(invalid_dir / "data").write_text("not valid json")
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
MockService.get_by_key = AsyncMock(return_value=None)
|
||||
MockService.create = AsyncMock()
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
assert result.total_found == 2
|
||||
assert result.migrated == 1
|
||||
assert result.failed == 1
|
||||
assert len(result.errors) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_all_with_skips(self, mock_db):
|
||||
"""Test migration correctly counts skipped files."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create data files
|
||||
for i in range(2):
|
||||
series_dir = Path(tmp_dir) / f"Series {i}"
|
||||
series_dir.mkdir()
|
||||
data = {
|
||||
"key": f"series-{i}",
|
||||
"name": f"Series {i}",
|
||||
"site": "aniworld.to",
|
||||
"folder": f"Series {i}",
|
||||
"episodeDict": {}
|
||||
}
|
||||
(series_dir / "data").write_text(json.dumps(data))
|
||||
|
||||
# Mock: first series doesn't exist, second already exists
|
||||
existing = MagicMock()
|
||||
existing.id = 2
|
||||
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.AnimeSeriesService'
|
||||
) as MockService:
|
||||
with patch(
|
||||
'src.server.services.data_migration_service.EpisodeService'
|
||||
) as MockEpisodeService:
|
||||
MockService.get_by_key = AsyncMock(
|
||||
side_effect=[None, existing]
|
||||
)
|
||||
MockService.create = AsyncMock(
|
||||
return_value=MagicMock(id=1)
|
||||
)
|
||||
MockEpisodeService.get_by_series = AsyncMock(return_value=[])
|
||||
|
||||
result = await service.migrate_all(tmp_dir, mock_db)
|
||||
|
||||
assert result.total_found == 2
|
||||
assert result.migrated == 1
|
||||
assert result.skipped == 1
|
||||
|
||||
|
||||
class TestDataMigrationServiceIsMigrationNeeded:
|
||||
"""Test is_migration_needed method."""
|
||||
|
||||
def test_migration_needed_with_data_files(self):
|
||||
"""Test migration is needed when data files exist."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
series_dir = Path(tmp_dir) / "Test Series"
|
||||
series_dir.mkdir()
|
||||
(series_dir / "data").write_text('{"key": "test"}')
|
||||
|
||||
assert service.is_migration_needed(tmp_dir) is True
|
||||
|
||||
def test_migration_not_needed_empty_directory(self):
|
||||
"""Test migration not needed for empty directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
assert service.is_migration_needed(tmp_dir) is False
|
||||
|
||||
def test_migration_not_needed_nonexistent_directory(self):
|
||||
"""Test migration not needed for nonexistent directory."""
|
||||
service = DataMigrationService()
|
||||
|
||||
assert service.is_migration_needed("/nonexistent/path") is False
|
||||
|
||||
|
||||
class TestDataMigrationServiceSingleton:
|
||||
"""Test singleton pattern for service."""
|
||||
|
||||
def test_get_service_returns_same_instance(self):
|
||||
"""Test getting service returns same instance."""
|
||||
reset_data_migration_service()
|
||||
|
||||
service1 = get_data_migration_service()
|
||||
service2 = get_data_migration_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
def test_reset_service_creates_new_instance(self):
|
||||
"""Test resetting service creates new instance."""
|
||||
service1 = get_data_migration_service()
|
||||
reset_data_migration_service()
|
||||
service2 = get_data_migration_service()
|
||||
|
||||
assert service1 is not service2
|
||||
|
||||
def test_service_is_correct_type(self):
|
||||
"""Test service is correct type."""
|
||||
reset_data_migration_service()
|
||||
service = get_data_migration_service()
|
||||
|
||||
assert isinstance(service, DataMigrationService)
|
||||
@@ -25,7 +25,6 @@ from src.server.database.init import (
|
||||
create_database_backup,
|
||||
create_database_schema,
|
||||
get_database_info,
|
||||
get_migration_guide,
|
||||
get_schema_version,
|
||||
initialize_database,
|
||||
seed_initial_data,
|
||||
@@ -372,16 +371,6 @@ def test_get_database_info():
|
||||
assert set(info["expected_tables"]) == EXPECTED_TABLES
|
||||
|
||||
|
||||
def test_get_migration_guide():
|
||||
"""Test getting migration guide."""
|
||||
guide = get_migration_guide()
|
||||
|
||||
assert isinstance(guide, str)
|
||||
assert "Alembic" in guide
|
||||
assert "alembic init" in guide
|
||||
assert "alembic upgrade head" in guide
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
Tests for database migration system.
|
||||
|
||||
This module tests the migration runner, validator, and base classes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.database.migrations.base import (
|
||||
Migration,
|
||||
MigrationError,
|
||||
MigrationHistory,
|
||||
)
|
||||
from src.server.database.migrations.runner import MigrationRunner
|
||||
from src.server.database.migrations.validator import MigrationValidator
|
||||
|
||||
|
||||
class TestMigration:
|
||||
"""Tests for base Migration class."""
|
||||
|
||||
def test_migration_initialization(self):
|
||||
"""Test migration can be initialized with basic attributes."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(
|
||||
version="20250124_001", description="Test migration"
|
||||
)
|
||||
|
||||
assert mig.version == "20250124_001"
|
||||
assert mig.description == "Test migration"
|
||||
assert isinstance(mig.created_at, datetime)
|
||||
|
||||
def test_migration_equality(self):
|
||||
"""Test migrations are equal based on version."""
|
||||
|
||||
class TestMig1(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
class TestMig2(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig1 = TestMig1(version="20250124_001", description="Test 1")
|
||||
mig2 = TestMig2(version="20250124_001", description="Test 2")
|
||||
mig3 = TestMig1(version="20250124_002", description="Test 3")
|
||||
|
||||
assert mig1 == mig2
|
||||
assert mig1 != mig3
|
||||
assert hash(mig1) == hash(mig2)
|
||||
assert hash(mig1) != hash(mig3)
|
||||
|
||||
def test_migration_repr(self):
|
||||
"""Test migration string representation."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(
|
||||
version="20250124_001", description="Test migration"
|
||||
)
|
||||
|
||||
assert "20250124_001" in repr(mig)
|
||||
assert "Test migration" in repr(mig)
|
||||
|
||||
|
||||
class TestMigrationHistory:
|
||||
"""Tests for MigrationHistory class."""
|
||||
|
||||
def test_history_initialization(self):
|
||||
"""Test migration history record can be created."""
|
||||
history = MigrationHistory(
|
||||
version="20250124_001",
|
||||
description="Test migration",
|
||||
applied_at=datetime.now(),
|
||||
execution_time_ms=1500,
|
||||
success=True,
|
||||
)
|
||||
|
||||
assert history.version == "20250124_001"
|
||||
assert history.description == "Test migration"
|
||||
assert history.execution_time_ms == 1500
|
||||
assert history.success is True
|
||||
assert history.error_message is None
|
||||
|
||||
def test_history_with_error(self):
|
||||
"""Test migration history with error message."""
|
||||
history = MigrationHistory(
|
||||
version="20250124_001",
|
||||
description="Failed migration",
|
||||
applied_at=datetime.now(),
|
||||
execution_time_ms=500,
|
||||
success=False,
|
||||
error_message="Test error",
|
||||
)
|
||||
|
||||
assert history.success is False
|
||||
assert history.error_message == "Test error"
|
||||
|
||||
|
||||
class TestMigrationValidator:
|
||||
"""Tests for MigrationValidator class."""
|
||||
|
||||
def test_validator_initialization(self):
|
||||
"""Test validator can be initialized."""
|
||||
validator = MigrationValidator()
|
||||
assert isinstance(validator.errors, list)
|
||||
assert isinstance(validator.warnings, list)
|
||||
assert len(validator.errors) == 0
|
||||
|
||||
def test_validate_version_format_valid(self):
|
||||
"""Test validation of valid version formats."""
|
||||
validator = MigrationValidator()
|
||||
|
||||
assert validator._validate_version_format("20250124_001")
|
||||
assert validator._validate_version_format("20231201_099")
|
||||
assert validator._validate_version_format("20250124_001_description")
|
||||
|
||||
def test_validate_version_format_invalid(self):
|
||||
"""Test validation of invalid version formats."""
|
||||
validator = MigrationValidator()
|
||||
|
||||
assert not validator._validate_version_format("")
|
||||
assert not validator._validate_version_format("20250124")
|
||||
assert not validator._validate_version_format("invalid_001")
|
||||
assert not validator._validate_version_format("202501_001")
|
||||
|
||||
def test_validate_migration_valid(self):
|
||||
"""Test validation of valid migration."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(
|
||||
version="20250124_001",
|
||||
description="Valid test migration",
|
||||
)
|
||||
|
||||
validator = MigrationValidator()
|
||||
assert validator.validate_migration(mig) is True
|
||||
assert len(validator.errors) == 0
|
||||
|
||||
def test_validate_migration_invalid_version(self):
|
||||
"""Test validation fails for invalid version."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(
|
||||
version="invalid",
|
||||
description="Valid description",
|
||||
)
|
||||
|
||||
validator = MigrationValidator()
|
||||
assert validator.validate_migration(mig) is False
|
||||
assert len(validator.errors) > 0
|
||||
|
||||
def test_validate_migration_missing_description(self):
|
||||
"""Test validation fails for missing description."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(version="20250124_001", description="")
|
||||
|
||||
validator = MigrationValidator()
|
||||
assert validator.validate_migration(mig) is False
|
||||
assert any("description" in e.lower() for e in validator.errors)
|
||||
|
||||
def test_validate_migrations_duplicate_version(self):
|
||||
"""Test validation detects duplicate versions."""
|
||||
|
||||
class TestMig1(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
class TestMig2(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig1 = TestMig1(version="20250124_001", description="First")
|
||||
mig2 = TestMig2(version="20250124_001", description="Duplicate")
|
||||
|
||||
validator = MigrationValidator()
|
||||
assert validator.validate_migrations([mig1, mig2]) is False
|
||||
assert any("duplicate" in e.lower() for e in validator.errors)
|
||||
|
||||
def test_check_migration_conflicts(self):
|
||||
"""Test detection of migration conflicts."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
old_mig = TestMig(version="20250101_001", description="Old")
|
||||
new_mig = TestMig(version="20250124_001", description="New")
|
||||
|
||||
validator = MigrationValidator()
|
||||
|
||||
# No conflict when pending is newer
|
||||
conflict = validator.check_migration_conflicts(
|
||||
[new_mig], ["20250101_001"]
|
||||
)
|
||||
assert conflict is None
|
||||
|
||||
# Conflict when pending is older
|
||||
conflict = validator.check_migration_conflicts(
|
||||
[old_mig], ["20250124_001"]
|
||||
)
|
||||
assert conflict is not None
|
||||
assert "older" in conflict.lower()
|
||||
|
||||
def test_get_validation_report(self):
|
||||
"""Test validation report generation."""
|
||||
validator = MigrationValidator()
|
||||
|
||||
validator.errors.append("Test error")
|
||||
validator.warnings.append("Test warning")
|
||||
|
||||
report = validator.get_validation_report()
|
||||
|
||||
assert "Test error" in report
|
||||
assert "Test warning" in report
|
||||
assert "Validation Errors:" in report
|
||||
assert "Validation Warnings:" in report
|
||||
|
||||
def test_raise_if_invalid(self):
|
||||
"""Test exception raising on validation failure."""
|
||||
validator = MigrationValidator()
|
||||
validator.errors.append("Test error")
|
||||
|
||||
with pytest.raises(MigrationError):
|
||||
validator.raise_if_invalid()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMigrationRunner:
|
||||
"""Tests for MigrationRunner class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
"""Create mock database session."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
return session
|
||||
|
||||
@pytest.fixture
|
||||
def migrations_dir(self, tmp_path):
|
||||
"""Create temporary migrations directory."""
|
||||
return tmp_path / "migrations"
|
||||
|
||||
async def test_runner_initialization(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test migration runner can be initialized."""
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
|
||||
assert runner.migrations_dir == migrations_dir
|
||||
assert runner.session == mock_session
|
||||
assert isinstance(runner._migrations, list)
|
||||
|
||||
async def test_initialize_creates_table(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test initialization creates migration_history table."""
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
|
||||
await runner.initialize()
|
||||
|
||||
mock_session.execute.assert_called()
|
||||
mock_session.commit.assert_called()
|
||||
|
||||
async def test_load_migrations_empty_dir(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test loading migrations from empty directory."""
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
|
||||
runner.load_migrations()
|
||||
|
||||
assert len(runner._migrations) == 0
|
||||
|
||||
async def test_get_applied_migrations(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test retrieving list of applied migrations."""
|
||||
# Mock database response
|
||||
mock_result = Mock()
|
||||
mock_result.fetchall.return_value = [
|
||||
("20250124_001",),
|
||||
("20250124_002",),
|
||||
]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
applied = await runner.get_applied_migrations()
|
||||
|
||||
assert len(applied) == 2
|
||||
assert "20250124_001" in applied
|
||||
assert "20250124_002" in applied
|
||||
|
||||
async def test_apply_migration_success(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test successful migration application."""
|
||||
|
||||
class TestMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = TestMig(version="20250124_001", description="Test")
|
||||
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
|
||||
await runner.apply_migration(mig)
|
||||
|
||||
mock_session.commit.assert_called()
|
||||
|
||||
async def test_apply_migration_failure(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test migration application handles failures."""
|
||||
|
||||
class FailingMig(Migration):
|
||||
async def upgrade(self, session):
|
||||
raise Exception("Test failure")
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig = FailingMig(version="20250124_001", description="Failing")
|
||||
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
|
||||
with pytest.raises(MigrationError):
|
||||
await runner.apply_migration(mig)
|
||||
|
||||
mock_session.rollback.assert_called()
|
||||
|
||||
async def test_get_pending_migrations(
|
||||
self, migrations_dir, mock_session
|
||||
):
|
||||
"""Test retrieving pending migrations."""
|
||||
|
||||
class TestMig1(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
class TestMig2(Migration):
|
||||
async def upgrade(self, session):
|
||||
return None
|
||||
|
||||
async def downgrade(self, session):
|
||||
return None
|
||||
|
||||
mig1 = TestMig1(version="20250124_001", description="Applied")
|
||||
mig2 = TestMig2(version="20250124_002", description="Pending")
|
||||
|
||||
runner = MigrationRunner(migrations_dir, mock_session)
|
||||
runner._migrations = [mig1, mig2]
|
||||
|
||||
# Mock only mig1 as applied
|
||||
mock_result = Mock()
|
||||
mock_result.fetchall.return_value = [("20250124_001",)]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
pending = await runner.get_pending_migrations()
|
||||
|
||||
assert len(pending) == 1
|
||||
assert pending[0].version == "20250124_002"
|
||||
@@ -1,361 +0,0 @@
|
||||
"""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