"""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 episode_dict existing = MagicMock() existing.id = 1 existing.episode_dict = {"1": [1, 2, 3], "2": [1, 2]} 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=existing) 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 episode_dict.""" 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 existing.episode_dict = {"1": [1, 2, 3]} with patch.object( service, '_read_data_file', return_value=serie ): with patch( 'src.server.services.data_migration_service.AnimeSeriesService' ) as MockService: MockService.get_by_key = AsyncMock(return_value=existing) MockService.update = AsyncMock() result = await service.migrate_data_file( Path("/fake/data"), mock_db ) assert result is True MockService.update.assert_called_once() @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 existing.episode_dict = {} with patch( 'src.server.services.data_migration_service.AnimeSeriesService' ) as MockService: MockService.get_by_key = AsyncMock( side_effect=[None, existing] ) MockService.create = AsyncMock() 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)