Aniworld/tests/unit/test_data_migration_service.py
2025-12-04 19:22:42 +01:00

600 lines
22 KiB
Python

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