"""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