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.
This commit is contained in:
471
tests/integration/test_data_migration.py
Normal file
471
tests/integration/test_data_migration.py
Normal file
@@ -0,0 +1,471 @@
|
||||
"""Integration tests for data migration from file-based to database storage.
|
||||
|
||||
This module tests the complete migration flow including:
|
||||
- Migration of legacy data files to database
|
||||
- API endpoints working with database backend
|
||||
- Data integrity during migration
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.fastapi_app import app
|
||||
from src.server.services.auth_service import auth_service
|
||||
from src.server.services.data_migration_service import (
|
||||
DataMigrationService,
|
||||
MigrationResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_anime_dir(tmp_path):
|
||||
"""Create a temporary anime directory with test data files."""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
|
||||
# Create multiple anime series directories with data files
|
||||
series_data = [
|
||||
{
|
||||
"key": "test-anime-1",
|
||||
"name": "Test Anime 1",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Anime 1 (2020)",
|
||||
"episodeDict": {
|
||||
"1": [1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "test-anime-2",
|
||||
"name": "Test Anime 2",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Anime 2 (2021)",
|
||||
"episodeDict": {
|
||||
"1": [1],
|
||||
"2": [1, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "test-anime-3",
|
||||
"name": "Test Anime 3",
|
||||
"site": "aniworld.to",
|
||||
"folder": "Test Anime 3 (2022)",
|
||||
"episodeDict": {}
|
||||
}
|
||||
]
|
||||
|
||||
for data in series_data:
|
||||
series_dir = anime_dir / data["folder"]
|
||||
series_dir.mkdir()
|
||||
data_file = series_dir / "data"
|
||||
data_file.write_text(json.dumps(data))
|
||||
|
||||
return anime_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path):
|
||||
"""Create a temporary database path."""
|
||||
return tmp_path / "test_aniworld.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db_session(temp_db_path):
|
||||
"""Create an async database session with a temporary database."""
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from src.server.database.base import Base
|
||||
|
||||
# Create test database
|
||||
test_db_url = f"sqlite+aiosqlite:///{temp_db_path}"
|
||||
engine = create_async_engine(test_db_url, echo=False)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestDataMigrationIntegration:
|
||||
"""Integration tests for the complete data migration flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_migration_flow(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test complete migration from data files to database."""
|
||||
# Setup: Verify data files exist
|
||||
data_files = list(temp_anime_dir.glob("*/data"))
|
||||
assert len(data_files) == 3, "Should have 3 data files"
|
||||
|
||||
# Create migration service
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Check for legacy data files
|
||||
files = await migration_service.check_for_legacy_data_files(
|
||||
str(temp_anime_dir)
|
||||
)
|
||||
assert len(files) == 3, "Should find 3 legacy data files"
|
||||
|
||||
# Run full migration
|
||||
result = await migration_service.migrate_all_legacy_data(
|
||||
str(temp_anime_dir), test_db_session
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert result.total_found == 3
|
||||
assert result.migrated == 3
|
||||
assert result.failed == 0
|
||||
assert result.skipped == 0
|
||||
assert len(result.errors) == 0
|
||||
|
||||
# Verify all entries in database
|
||||
all_series = await AnimeSeriesService.get_all(test_db_session)
|
||||
assert len(all_series) == 3, "Should have 3 series in database"
|
||||
|
||||
# Verify series keys
|
||||
keys_in_db = {s.key for s in all_series}
|
||||
expected_keys = {"test-anime-1", "test-anime-2", "test-anime-3"}
|
||||
assert keys_in_db == expected_keys, \
|
||||
"All series keys should be migrated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_preserves_data(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test that migration preserves all series data."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Run migration
|
||||
await migration_service.migrate_all_legacy_data(
|
||||
str(temp_anime_dir), test_db_session
|
||||
)
|
||||
|
||||
# Verify specific series data
|
||||
series = await AnimeSeriesService.get_by_key(
|
||||
test_db_session, "test-anime-1"
|
||||
)
|
||||
assert series is not None
|
||||
assert series.name == "Test Anime 1"
|
||||
assert series.site == "aniworld.to"
|
||||
assert series.folder == "Test Anime 1 (2020)"
|
||||
assert series.episode_dict == {"1": [1, 2, 3]}
|
||||
|
||||
# Verify series with multiple seasons
|
||||
series2 = await AnimeSeriesService.get_by_key(
|
||||
test_db_session, "test-anime-2"
|
||||
)
|
||||
assert series2 is not None
|
||||
assert series2.episode_dict == {"1": [1], "2": [1, 2]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test that re-running migration doesn't create duplicates."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Run migration twice
|
||||
result1 = await migration_service.migrate_all_legacy_data(
|
||||
str(temp_anime_dir), test_db_session
|
||||
)
|
||||
result2 = await migration_service.migrate_all_legacy_data(
|
||||
str(temp_anime_dir), test_db_session
|
||||
)
|
||||
|
||||
# First run should migrate all
|
||||
assert result1.migrated == 3
|
||||
assert result1.skipped == 0
|
||||
|
||||
# Second run should skip all (already in DB)
|
||||
assert result2.migrated == 0
|
||||
assert result2.skipped == 3
|
||||
|
||||
# Database should only have 3 entries (not 6)
|
||||
all_series = await AnimeSeriesService.get_all(test_db_session)
|
||||
assert len(all_series) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_file_migration(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test migration of a single data file."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Get one data file path
|
||||
data_file = str(temp_anime_dir / "Test Anime 1 (2020)" / "data")
|
||||
|
||||
# Migrate single file
|
||||
result = await migration_service.migrate_data_file_to_db(
|
||||
data_file, test_db_session
|
||||
)
|
||||
assert result is True
|
||||
|
||||
# Verify in database
|
||||
series = await AnimeSeriesService.get_by_key(
|
||||
test_db_session, "test-anime-1"
|
||||
)
|
||||
assert series is not None
|
||||
assert series.name == "Test Anime 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_with_corrupted_file(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test migration handles corrupted files gracefully."""
|
||||
# Create a corrupted data file
|
||||
corrupted_dir = temp_anime_dir / "Corrupted Anime"
|
||||
corrupted_dir.mkdir()
|
||||
corrupted_file = corrupted_dir / "data"
|
||||
corrupted_file.write_text("not valid json {{{")
|
||||
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Run migration
|
||||
result = await migration_service.migrate_all_legacy_data(
|
||||
str(temp_anime_dir), test_db_session
|
||||
)
|
||||
|
||||
# Should have 3 migrated, 1 failed
|
||||
assert result.total_found == 4
|
||||
assert result.migrated == 3
|
||||
assert result.failed == 1
|
||||
assert len(result.errors) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_with_empty_directory(
|
||||
self, tmp_path, test_db_session
|
||||
):
|
||||
"""Test migration with directory containing no data files."""
|
||||
empty_dir = tmp_path / "empty_anime"
|
||||
empty_dir.mkdir()
|
||||
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Check for files
|
||||
files = await migration_service.check_for_legacy_data_files(
|
||||
str(empty_dir)
|
||||
)
|
||||
assert len(files) == 0
|
||||
|
||||
# Run migration on empty directory
|
||||
result = await migration_service.migrate_all_legacy_data(
|
||||
str(empty_dir), test_db_session
|
||||
)
|
||||
|
||||
assert result.total_found == 0
|
||||
assert result.migrated == 0
|
||||
assert result.failed == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_with_invalid_directory(
|
||||
self, tmp_path, test_db_session
|
||||
):
|
||||
"""Test migration with non-existent directory."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Try non-existent directory
|
||||
files = await migration_service.check_for_legacy_data_files(
|
||||
"/non/existent/path"
|
||||
)
|
||||
assert len(files) == 0
|
||||
|
||||
result = await migration_service.migrate_all_legacy_data(
|
||||
"/non/existent/path", test_db_session
|
||||
)
|
||||
assert result.total_found == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_migrated_files(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test cleanup of migrated data files with backup."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Get data file paths before migration
|
||||
files = await migration_service.check_for_legacy_data_files(
|
||||
str(temp_anime_dir)
|
||||
)
|
||||
assert len(files) == 3
|
||||
|
||||
# Run cleanup (with backup=True)
|
||||
await migration_service.cleanup_migrated_files(files, backup=True)
|
||||
|
||||
# Original data files should be removed
|
||||
for original_path in files:
|
||||
assert not os.path.exists(original_path), \
|
||||
f"Original file should not exist: {original_path}"
|
||||
# Backup files have timestamp suffix: data.backup.YYYYMMDD_HHMMSS
|
||||
parent_dir = os.path.dirname(original_path)
|
||||
backup_files = [
|
||||
f for f in os.listdir(parent_dir)
|
||||
if f.startswith("data.backup.")
|
||||
]
|
||||
assert len(backup_files) == 1, \
|
||||
f"Backup file should exist in {parent_dir}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_without_backup(
|
||||
self, temp_anime_dir, test_db_session
|
||||
):
|
||||
"""Test cleanup of migrated data files without backup."""
|
||||
migration_service = DataMigrationService()
|
||||
|
||||
# Get data file paths
|
||||
files = await migration_service.check_for_legacy_data_files(
|
||||
str(temp_anime_dir)
|
||||
)
|
||||
|
||||
# Run cleanup without backup
|
||||
await migration_service.cleanup_migrated_files(files, backup=False)
|
||||
|
||||
# Files should be deleted, no backups
|
||||
for original_path in files:
|
||||
assert not os.path.exists(original_path)
|
||||
assert not os.path.exists(original_path + ".migrated")
|
||||
|
||||
|
||||
class TestAPIWithDatabaseIntegration:
|
||||
"""Test API endpoints with database backend.
|
||||
|
||||
Note: These tests focus on the database integration layer.
|
||||
Full API tests are in tests/api/test_anime_endpoints.py.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth(self):
|
||||
"""Mock authentication for API tests."""
|
||||
return {"user_id": "test_user", "role": "admin"}
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(self, mock_auth):
|
||||
"""Create an authenticated test client."""
|
||||
# Create token
|
||||
token = auth_service.create_access_token(mock_auth)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test"
|
||||
) as client:
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
yield client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_service_uses_database(
|
||||
self, test_db_session
|
||||
):
|
||||
"""Test that AnimeSeriesService correctly stores data."""
|
||||
# Create a series through the service
|
||||
_series = await AnimeSeriesService.create(
|
||||
test_db_session,
|
||||
key="api-test-anime",
|
||||
name="API Test Anime",
|
||||
site="aniworld.to",
|
||||
folder="API Test Anime (2024)",
|
||||
episode_dict={"1": [1, 2, 3]}
|
||||
)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Verify it's stored
|
||||
retrieved = await AnimeSeriesService.get_by_key(
|
||||
test_db_session, "api-test-anime"
|
||||
)
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "API Test Anime"
|
||||
assert retrieved.folder == "API Test Anime (2024)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_update_series(
|
||||
self, test_db_session
|
||||
):
|
||||
"""Test that series can be updated in database."""
|
||||
# Create a series
|
||||
series = await AnimeSeriesService.create(
|
||||
test_db_session,
|
||||
key="update-test-anime",
|
||||
name="Original Name",
|
||||
site="aniworld.to",
|
||||
folder="Original Folder",
|
||||
episode_dict={}
|
||||
)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Update it
|
||||
updated = await AnimeSeriesService.update(
|
||||
test_db_session,
|
||||
series.id,
|
||||
name="Updated Name",
|
||||
episode_dict={"1": [1, 2]}
|
||||
)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Verify update
|
||||
assert updated.name == "Updated Name"
|
||||
assert updated.episode_dict == {"1": [1, 2]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_delete_series(
|
||||
self, test_db_session
|
||||
):
|
||||
"""Test that series can be deleted from database."""
|
||||
# Create a series
|
||||
series = await AnimeSeriesService.create(
|
||||
test_db_session,
|
||||
key="delete-test-anime",
|
||||
name="To Delete",
|
||||
site="aniworld.to",
|
||||
folder="Delete Folder",
|
||||
episode_dict={}
|
||||
)
|
||||
await test_db_session.commit()
|
||||
series_id = series.id
|
||||
|
||||
# Delete it
|
||||
result = await AnimeSeriesService.delete(test_db_session, series_id)
|
||||
await test_db_session.commit()
|
||||
assert result is True
|
||||
|
||||
# Verify deletion
|
||||
retrieved = await AnimeSeriesService.get_by_key(
|
||||
test_db_session, "delete-test-anime"
|
||||
)
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
class TestMigrationResult:
|
||||
"""Tests for MigrationResult dataclass."""
|
||||
|
||||
def test_migration_result_defaults(self):
|
||||
"""Test default values for MigrationResult."""
|
||||
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_str(self):
|
||||
"""Test string representation of MigrationResult."""
|
||||
result = MigrationResult(
|
||||
total_found=10,
|
||||
migrated=7,
|
||||
failed=1,
|
||||
skipped=2,
|
||||
errors=["Error 1"]
|
||||
)
|
||||
expected = (
|
||||
"Migration Result: 7 migrated, 2 skipped, "
|
||||
"1 failed (total: 10)"
|
||||
)
|
||||
assert str(result) == expected
|
||||
719
tests/unit/test_data_migration_service.py
Normal file
719
tests/unit/test_data_migration_service.py
Normal file
@@ -0,0 +1,719 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user