Aniworld/tests/unit/test_data_migration_service.py
Lukas 17754a86f0 Add database migration from legacy data files
- Create DataMigrationService for migrating data files to SQLite
- Add sync database methods to AnimeSeriesService
- Update SerieScanner to save to database with file fallback
- Update anime API endpoints to use database with fallback
- Add delete endpoint for anime series
- Add automatic migration on startup in fastapi_app.py lifespan
- Add 28 unit tests for migration service
- Add 14 integration tests for migration flow
- Update infrastructure.md and database README docs

Migration runs automatically on startup, legacy data files preserved.
2025-12-01 17:42:09 +01:00

720 lines
22 KiB
Python

"""Unit tests for data migration service.
Tests cover:
- Detection of legacy data files
- Migration of data files to database
- Error handling for corrupted files
- Backup functionality
- Migration status reporting
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from src.server.database.base import Base
from src.server.database.service import AnimeSeriesService
from src.server.services.data_migration_service import (
DataMigrationService,
MigrationResult,
)
@pytest.fixture
async def test_engine():
"""Create in-memory SQLite engine for testing."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def test_session(test_engine):
"""Create async session for testing."""
from sqlalchemy.ext.asyncio import async_sessionmaker
async_session = async_sessionmaker(
test_engine,
expire_on_commit=False,
)
async with async_session() as session:
yield session
@pytest.fixture
def temp_anime_dir():
"""Create temporary directory for testing anime folders."""
with tempfile.TemporaryDirectory() as tmp_dir:
yield tmp_dir
@pytest.fixture
def migration_service():
"""Create DataMigrationService instance."""
return DataMigrationService()
def create_test_data_file(
base_dir: str,
folder_name: str,
key: str,
name: str,
) -> str:
"""Create a test data file in the specified folder.
Args:
base_dir: Base anime directory
folder_name: Folder name to create
key: Series key
name: Series name
Returns:
Path to the created data file
"""
folder_path = os.path.join(base_dir, folder_name)
os.makedirs(folder_path, exist_ok=True)
data_path = os.path.join(folder_path, "data")
data = {
"key": key,
"name": name,
"site": "aniworld.to",
"folder": folder_name,
"episodeDict": {"1": [1, 2, 3], "2": [1, 2]},
}
with open(data_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
return data_path
# =============================================================================
# MigrationResult Tests
# =============================================================================
class TestMigrationResult:
"""Test cases for MigrationResult dataclass."""
def test_migration_result_defaults(self):
"""Test MigrationResult default values."""
result = MigrationResult()
assert result.total_found == 0
assert result.migrated == 0
assert result.failed == 0
assert result.skipped == 0
assert result.errors == []
def test_migration_result_with_values(self):
"""Test MigrationResult with custom values."""
result = MigrationResult(
total_found=10,
migrated=7,
failed=1,
skipped=2,
errors=["Error 1"],
)
assert result.total_found == 10
assert result.migrated == 7
assert result.failed == 1
assert result.skipped == 2
assert result.errors == ["Error 1"]
def test_migration_result_str(self):
"""Test MigrationResult string representation."""
result = MigrationResult(
total_found=10,
migrated=7,
failed=1,
skipped=2,
)
result_str = str(result)
assert "7 migrated" in result_str
assert "2 skipped" in result_str
assert "1 failed" in result_str
assert "10" in result_str
# =============================================================================
# Check for Legacy Data Files Tests
# =============================================================================
class TestCheckForLegacyDataFiles:
"""Test cases for check_for_legacy_data_files method."""
@pytest.mark.asyncio
async def test_check_empty_directory(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test scanning empty directory returns empty list."""
files = await migration_service.check_for_legacy_data_files(
temp_anime_dir
)
assert files == []
@pytest.mark.asyncio
async def test_check_nonexistent_directory(
self,
migration_service: DataMigrationService,
):
"""Test scanning nonexistent directory returns empty list."""
files = await migration_service.check_for_legacy_data_files(
"/nonexistent/path"
)
assert files == []
@pytest.mark.asyncio
async def test_check_none_directory(
self,
migration_service: DataMigrationService,
):
"""Test scanning None directory returns empty list."""
files = await migration_service.check_for_legacy_data_files(None)
assert files == []
@pytest.mark.asyncio
async def test_check_empty_string_directory(
self,
migration_service: DataMigrationService,
):
"""Test scanning empty string directory returns empty list."""
files = await migration_service.check_for_legacy_data_files("")
assert files == []
@pytest.mark.asyncio
async def test_find_single_data_file(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test finding a single data file."""
create_test_data_file(
temp_anime_dir,
"Test Anime",
"test-anime",
"Test Anime",
)
files = await migration_service.check_for_legacy_data_files(
temp_anime_dir
)
assert len(files) == 1
assert files[0].endswith("data")
assert "Test Anime" in files[0]
@pytest.mark.asyncio
async def test_find_multiple_data_files(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test finding multiple data files."""
create_test_data_file(
temp_anime_dir, "Anime 1", "anime-1", "Anime 1"
)
create_test_data_file(
temp_anime_dir, "Anime 2", "anime-2", "Anime 2"
)
create_test_data_file(
temp_anime_dir, "Anime 3", "anime-3", "Anime 3"
)
files = await migration_service.check_for_legacy_data_files(
temp_anime_dir
)
assert len(files) == 3
@pytest.mark.asyncio
async def test_skip_folders_without_data_file(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test that folders without data files are skipped."""
# Create folder with data file
create_test_data_file(
temp_anime_dir, "With Data", "with-data", "With Data"
)
# Create folder without data file
empty_folder = os.path.join(temp_anime_dir, "Without Data")
os.makedirs(empty_folder, exist_ok=True)
files = await migration_service.check_for_legacy_data_files(
temp_anime_dir
)
assert len(files) == 1
assert "With Data" in files[0]
@pytest.mark.asyncio
async def test_skip_non_directories(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test that non-directory entries are skipped."""
create_test_data_file(
temp_anime_dir, "Anime", "anime", "Anime"
)
# Create a file (not directory) in anime dir
file_path = os.path.join(temp_anime_dir, "some_file.txt")
with open(file_path, "w") as f:
f.write("test")
files = await migration_service.check_for_legacy_data_files(
temp_anime_dir
)
assert len(files) == 1
# =============================================================================
# Migrate Data File to DB Tests
# =============================================================================
class TestMigrateDataFileToDb:
"""Test cases for migrate_data_file_to_db method."""
@pytest.mark.asyncio
async def test_migrate_valid_data_file(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migrating a valid data file creates database entry."""
data_path = create_test_data_file(
temp_anime_dir,
"Test Anime",
"test-anime",
"Test Anime",
)
result = await migration_service.migrate_data_file_to_db(
data_path, test_session
)
assert result is True
# Verify database entry
series = await AnimeSeriesService.get_by_key(
test_session, "test-anime"
)
assert series is not None
assert series.name == "Test Anime"
assert series.site == "aniworld.to"
assert series.folder == "Test Anime"
@pytest.mark.asyncio
async def test_migrate_nonexistent_file_raises_error(
self,
migration_service: DataMigrationService,
test_session,
):
"""Test migrating nonexistent file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
await migration_service.migrate_data_file_to_db(
"/nonexistent/path/data",
test_session,
)
@pytest.mark.asyncio
async def test_migrate_invalid_json_raises_error(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migrating invalid JSON file raises ValueError."""
# Create folder with invalid data file
folder_path = os.path.join(temp_anime_dir, "Invalid")
os.makedirs(folder_path, exist_ok=True)
data_path = os.path.join(folder_path, "data")
with open(data_path, "w") as f:
f.write("not valid json")
with pytest.raises(ValueError):
await migration_service.migrate_data_file_to_db(
data_path, test_session
)
@pytest.mark.asyncio
async def test_migrate_skips_existing_series(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migration returns False for existing series in DB."""
# Create series in database first
await AnimeSeriesService.create(
test_session,
key="existing-anime",
name="Existing Anime",
site="aniworld.to",
folder="Existing Anime",
)
await test_session.commit()
# Create data file with same key
data_path = create_test_data_file(
temp_anime_dir,
"Existing Anime",
"existing-anime",
"Existing Anime",
)
result = await migration_service.migrate_data_file_to_db(
data_path, test_session
)
assert result is False
@pytest.mark.asyncio
async def test_migrate_preserves_episode_dict(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migration preserves episode dictionary correctly."""
data_path = create_test_data_file(
temp_anime_dir,
"With Episodes",
"with-episodes",
"With Episodes",
)
await migration_service.migrate_data_file_to_db(
data_path, test_session
)
series = await AnimeSeriesService.get_by_key(
test_session, "with-episodes"
)
assert series is not None
assert series.episode_dict is not None
# Note: JSON keys become strings, so check string keys
assert "1" in series.episode_dict or 1 in series.episode_dict
# =============================================================================
# Migrate All Legacy Data Tests
# =============================================================================
class TestMigrateAllLegacyData:
"""Test cases for migrate_all_legacy_data method."""
@pytest.mark.asyncio
async def test_migrate_empty_directory(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migrating empty directory returns zero counts."""
result = await migration_service.migrate_all_legacy_data(
temp_anime_dir, test_session
)
assert result.total_found == 0
assert result.migrated == 0
assert result.failed == 0
assert result.skipped == 0
@pytest.mark.asyncio
async def test_migrate_multiple_files(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migrating multiple files successfully."""
create_test_data_file(
temp_anime_dir, "Anime 1", "anime-1", "Anime 1"
)
create_test_data_file(
temp_anime_dir, "Anime 2", "anime-2", "Anime 2"
)
result = await migration_service.migrate_all_legacy_data(
temp_anime_dir, test_session
)
assert result.total_found == 2
assert result.migrated == 2
assert result.failed == 0
assert result.skipped == 0
@pytest.mark.asyncio
async def test_migrate_with_existing_entries(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migration skips existing database entries."""
# Create one in DB already
await AnimeSeriesService.create(
test_session,
key="anime-1",
name="Anime 1",
site="aniworld.to",
folder="Anime 1",
)
await test_session.commit()
# Create data files
create_test_data_file(
temp_anime_dir, "Anime 1", "anime-1", "Anime 1"
)
create_test_data_file(
temp_anime_dir, "Anime 2", "anime-2", "Anime 2"
)
result = await migration_service.migrate_all_legacy_data(
temp_anime_dir, test_session
)
assert result.total_found == 2
assert result.migrated == 1
assert result.skipped == 1
assert result.failed == 0
@pytest.mark.asyncio
async def test_migrate_with_invalid_file(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test migration continues after encountering invalid file."""
# Create valid data file
create_test_data_file(
temp_anime_dir, "Valid Anime", "valid-anime", "Valid Anime"
)
# Create invalid data file
invalid_folder = os.path.join(temp_anime_dir, "Invalid")
os.makedirs(invalid_folder, exist_ok=True)
invalid_path = os.path.join(invalid_folder, "data")
with open(invalid_path, "w") as f:
f.write("invalid json")
result = await migration_service.migrate_all_legacy_data(
temp_anime_dir, test_session
)
assert result.total_found == 2
assert result.migrated == 1
assert result.failed == 1
assert len(result.errors) == 1
# =============================================================================
# Cleanup Migrated Files Tests
# =============================================================================
class TestCleanupMigratedFiles:
"""Test cases for cleanup_migrated_files method."""
@pytest.mark.asyncio
async def test_cleanup_empty_list(
self,
migration_service: DataMigrationService,
):
"""Test cleanup with empty list does nothing."""
# Should not raise
await migration_service.cleanup_migrated_files([])
@pytest.mark.asyncio
async def test_cleanup_creates_backup(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test cleanup creates backup before removal."""
data_path = create_test_data_file(
temp_anime_dir, "Test", "test", "Test"
)
await migration_service.cleanup_migrated_files(
[data_path], backup=True
)
# Original file should be gone
assert not os.path.exists(data_path)
# Backup should exist
folder_path = os.path.dirname(data_path)
backup_files = [
f for f in os.listdir(folder_path) if f.startswith("data.backup")
]
assert len(backup_files) == 1
@pytest.mark.asyncio
async def test_cleanup_without_backup(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
):
"""Test cleanup without backup just removes file."""
data_path = create_test_data_file(
temp_anime_dir, "Test", "test", "Test"
)
await migration_service.cleanup_migrated_files(
[data_path], backup=False
)
# Original file should be gone
assert not os.path.exists(data_path)
# No backup should exist
folder_path = os.path.dirname(data_path)
backup_files = [
f for f in os.listdir(folder_path) if f.startswith("data.backup")
]
assert len(backup_files) == 0
@pytest.mark.asyncio
async def test_cleanup_handles_missing_file(
self,
migration_service: DataMigrationService,
):
"""Test cleanup handles already-deleted files gracefully."""
# Should not raise
await migration_service.cleanup_migrated_files(
["/nonexistent/path/data"]
)
# =============================================================================
# Migration Status Tests
# =============================================================================
class TestGetMigrationStatus:
"""Test cases for get_migration_status method."""
@pytest.mark.asyncio
async def test_status_empty_directory_empty_db(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test status with empty directory and database."""
status = await migration_service.get_migration_status(
temp_anime_dir, test_session
)
assert status["legacy_files_count"] == 0
assert status["database_entries_count"] == 0
assert status["only_in_files"] == []
assert status["only_in_database"] == []
assert status["in_both"] == []
assert status["migration_complete"] is True
@pytest.mark.asyncio
async def test_status_with_pending_migrations(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test status when files need migration."""
create_test_data_file(
temp_anime_dir, "Anime 1", "anime-1", "Anime 1"
)
create_test_data_file(
temp_anime_dir, "Anime 2", "anime-2", "Anime 2"
)
status = await migration_service.get_migration_status(
temp_anime_dir, test_session
)
assert status["legacy_files_count"] == 2
assert status["database_entries_count"] == 0
assert len(status["only_in_files"]) == 2
assert status["migration_complete"] is False
@pytest.mark.asyncio
async def test_status_after_complete_migration(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test status after all files migrated."""
create_test_data_file(
temp_anime_dir, "Anime 1", "anime-1", "Anime 1"
)
# Migrate
await migration_service.migrate_all_legacy_data(
temp_anime_dir, test_session
)
status = await migration_service.get_migration_status(
temp_anime_dir, test_session
)
assert status["legacy_files_count"] == 1
assert status["database_entries_count"] == 1
assert status["in_both"] == ["anime-1"]
assert status["migration_complete"] is True
@pytest.mark.asyncio
async def test_status_with_only_db_entries(
self,
migration_service: DataMigrationService,
temp_anime_dir: str,
test_session,
):
"""Test status when database has entries but no files."""
await AnimeSeriesService.create(
test_session,
key="db-only-anime",
name="DB Only Anime",
site="aniworld.to",
folder="DB Only",
)
await test_session.commit()
status = await migration_service.get_migration_status(
temp_anime_dir, test_session
)
assert status["legacy_files_count"] == 0
assert status["database_entries_count"] == 1
assert status["only_in_database"] == ["db-only-anime"]
assert status["migration_complete"] is True