feat: add legacy key/data file migration to database
- Add migration_legacy_files_completed flag to SystemSettings model - Create legacy_file_migration service to migrate series from key/data files - Integrate legacy migration into initialization_service startup flow - Add integration tests for legacy file migration - Update DATABASE.md documentation with migration details - Fix various test and service issues (nfo_repair, tmdb_client, download_service) - Add test_database_schema unit tests
This commit is contained in:
335
tests/integration/test_legacy_migration.py
Normal file
335
tests/integration/test_legacy_migration.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Integration tests for legacy key/data file migration.
|
||||
|
||||
Tests the one-time migration safety net that imports series from
|
||||
legacy key and data files into the database.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.services.legacy_file_migration import (
|
||||
_load_data_file,
|
||||
_load_key_file,
|
||||
migrate_series_from_files_to_db,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadLegacyFiles:
|
||||
"""Test helper functions for loading legacy files."""
|
||||
|
||||
def test_load_data_file_valid_json(self):
|
||||
"""Test loading a valid JSON data file."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = os.path.join(tmp_dir, "data")
|
||||
test_data = {
|
||||
"key": "test-anime",
|
||||
"name": "Test Anime",
|
||||
"site": "https://aniworld.to",
|
||||
"folder": "Test Anime",
|
||||
"episodeDict": {"1": [1, 2, 3]}
|
||||
}
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
result = _load_data_file(data_file)
|
||||
|
||||
assert result is not None
|
||||
assert result["key"] == "test-anime"
|
||||
assert result["name"] == "Test Anime"
|
||||
# episodeDict keys should be converted to int
|
||||
assert 1 in result["episodeDict"]
|
||||
|
||||
def test_load_data_file_invalid_json(self):
|
||||
"""Test handling of corrupt JSON data file."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = os.path.join(tmp_dir, "data")
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
f.write("this is not valid json {{{")
|
||||
|
||||
result = _load_data_file(data_file)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_data_file_not_dict(self):
|
||||
"""Test handling of JSON file that is not a dict."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
data_file = os.path.join(tmp_dir, "data")
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(["not", "a", "dict"], f)
|
||||
|
||||
result = _load_data_file(data_file)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_key_file_valid(self):
|
||||
"""Test loading a key file with valid content."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
key_file = os.path.join(tmp_dir, "key")
|
||||
with open(key_file, "w", encoding="utf-8") as f:
|
||||
f.write("my-anime-key")
|
||||
|
||||
result = _load_key_file(key_file, "My Anime")
|
||||
|
||||
assert result is not None
|
||||
assert result["key"] == "my-anime-key"
|
||||
assert result["name"] == "My Anime"
|
||||
assert result["site"] == "https://aniworld.to"
|
||||
assert result["episodeDict"] == {}
|
||||
|
||||
def test_load_key_file_empty(self):
|
||||
"""Test handling of empty key file."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
key_file = os.path.join(tmp_dir, "key")
|
||||
with open(key_file, "w", encoding="utf-8") as f:
|
||||
f.write("")
|
||||
|
||||
result = _load_key_file(key_file, "My Anime")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestMigrateLegacyFiles:
|
||||
"""Test the main migration function with database."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_series_from_files_to_db_no_files(self):
|
||||
"""Test migration with empty directory returns 0."""
|
||||
mock_db = AsyncMock()
|
||||
mock_db.execute = AsyncMock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_data_file_to_db(self):
|
||||
"""Test migration of a legacy data file."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a folder with a data file
|
||||
anime_folder = os.path.join(tmp_dir, "Test Anime")
|
||||
os.makedirs(anime_folder, exist_ok=True)
|
||||
|
||||
data_file = os.path.join(anime_folder, "data")
|
||||
test_data = {
|
||||
"key": "migrate-test-anime",
|
||||
"name": "Migrate Test Anime",
|
||||
"site": "https://aniworld.to",
|
||||
"folder": "Test Anime",
|
||||
"episodeDict": {"1": [1, 2]}
|
||||
}
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Mock the DB session and services
|
||||
mock_db = AsyncMock()
|
||||
mock_series_service = AsyncMock()
|
||||
mock_episode_service = AsyncMock()
|
||||
|
||||
# Mock get_by_key returning None (not in DB)
|
||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||
|
||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||
mock_created_series = MagicMock()
|
||||
mock_created_series.id = 1
|
||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'src.server.database.service': MagicMock(
|
||||
AnimeSeriesService=mock_series_service,
|
||||
EpisodeService=mock_episode_service
|
||||
)
|
||||
}):
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_key_file_to_db(self):
|
||||
"""Test migration of a legacy key file."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a folder with only a key file
|
||||
anime_folder = os.path.join(tmp_dir, "Key Only Anime")
|
||||
os.makedirs(anime_folder, exist_ok=True)
|
||||
|
||||
key_file = os.path.join(anime_folder, "key")
|
||||
with open(key_file, "w", encoding="utf-8") as f:
|
||||
f.write("key-only-anime")
|
||||
|
||||
# Mock the DB session and services
|
||||
mock_db = AsyncMock()
|
||||
mock_series_service = AsyncMock()
|
||||
mock_episode_service = AsyncMock()
|
||||
|
||||
# Mock get_by_key returning None (not in DB)
|
||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||
|
||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||
mock_created_series = MagicMock()
|
||||
mock_created_series.id = 1
|
||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'src.server.database.service': MagicMock(
|
||||
AnimeSeriesService=mock_series_service,
|
||||
EpisodeService=mock_episode_service
|
||||
)
|
||||
}):
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_skips_already_migrated(self):
|
||||
"""Test that migration skips series already in DB."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a folder with a data file
|
||||
anime_folder = os.path.join(tmp_dir, "Already Migrated")
|
||||
os.makedirs(anime_folder, exist_ok=True)
|
||||
|
||||
data_file = os.path.join(anime_folder, "data")
|
||||
test_data = {
|
||||
"key": "already-migrated",
|
||||
"name": "Already Migrated",
|
||||
"site": "https://aniworld.to",
|
||||
"folder": "Already Migrated",
|
||||
"episodeDict": {"1": [1]}
|
||||
}
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Mock the DB session and services
|
||||
mock_db = AsyncMock()
|
||||
mock_series_service = AsyncMock()
|
||||
mock_episode_service = AsyncMock()
|
||||
|
||||
# Mock get_by_key returning existing series (already migrated)
|
||||
mock_existing_series = MagicMock()
|
||||
mock_existing_series.name = "Modified Name"
|
||||
mock_series_service.get_by_key = AsyncMock(return_value=mock_existing_series)
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'src.server.database.service': MagicMock(
|
||||
AnimeSeriesService=mock_series_service,
|
||||
EpisodeService=mock_episode_service
|
||||
)
|
||||
}):
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 0 # No new series migrated
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_handles_corrupt_data_file(self):
|
||||
"""Test that corrupt data files don't crash migration."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a folder with a corrupt data file
|
||||
corrupt_folder = os.path.join(tmp_dir, "Corrupt Anime")
|
||||
os.makedirs(corrupt_folder, exist_ok=True)
|
||||
|
||||
corrupt_file = os.path.join(corrupt_folder, "data")
|
||||
with open(corrupt_file, "w", encoding="utf-8") as f:
|
||||
f.write("not valid json {{{")
|
||||
|
||||
# Create a valid folder
|
||||
valid_folder = os.path.join(tmp_dir, "Valid Anime")
|
||||
os.makedirs(valid_folder, exist_ok=True)
|
||||
|
||||
valid_file = os.path.join(valid_folder, "data")
|
||||
valid_data = {
|
||||
"key": "valid-anime",
|
||||
"name": "Valid Anime",
|
||||
"site": "https://aniworld.to",
|
||||
"folder": "Valid Anime",
|
||||
"episodeDict": {"1": [1]}
|
||||
}
|
||||
with open(valid_file, "w", encoding="utf-8") as f:
|
||||
json.dump(valid_data, f)
|
||||
|
||||
# Mock the DB session and services
|
||||
mock_db = AsyncMock()
|
||||
mock_series_service = AsyncMock()
|
||||
mock_episode_service = AsyncMock()
|
||||
|
||||
# Mock get_by_key returning None (not in DB)
|
||||
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||
|
||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||
mock_created_series = MagicMock()
|
||||
mock_created_series.id = 1
|
||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'src.server.database.service': MagicMock(
|
||||
AnimeSeriesService=mock_series_service,
|
||||
EpisodeService=mock_episode_service
|
||||
)
|
||||
}):
|
||||
# Migration should succeed despite corrupt file
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 1 # Only the valid one
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent(self):
|
||||
"""Test that running migration twice doesn't change DB state."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create a folder with a data file
|
||||
anime_folder = os.path.join(tmp_dir, "Idempotent Test")
|
||||
os.makedirs(anime_folder, exist_ok=True)
|
||||
|
||||
data_file = os.path.join(anime_folder, "data")
|
||||
test_data = {
|
||||
"key": "idempotent-test",
|
||||
"name": "Idempotent Test",
|
||||
"site": "https://aniworld.to",
|
||||
"folder": "Idempotent Test",
|
||||
"episodeDict": {"1": [1, 2]}
|
||||
}
|
||||
with open(data_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Mock the DB session and services
|
||||
mock_db = AsyncMock()
|
||||
mock_series_service = AsyncMock()
|
||||
mock_episode_service = AsyncMock()
|
||||
|
||||
# First call returns None (not in DB), second call returns the series
|
||||
mock_existing_series = MagicMock()
|
||||
mock_existing_series.id = 1
|
||||
mock_series_service.get_by_key = AsyncMock(side_effect=[None, mock_existing_series])
|
||||
|
||||
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||
mock_created_series = MagicMock()
|
||||
mock_created_series.id = 1
|
||||
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'src.server.database.service': MagicMock(
|
||||
AnimeSeriesService=mock_series_service,
|
||||
EpisodeService=mock_episode_service
|
||||
)
|
||||
}):
|
||||
# First migration
|
||||
count1 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count1 == 1
|
||||
|
||||
# Second migration
|
||||
count2 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count2 == 0 # Already migrated
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_skips_folders_without_files(self):
|
||||
"""Test that folders without key/data files are skipped."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Create an empty folder (no key or data file)
|
||||
empty_folder = os.path.join(tmp_dir, "Empty Folder")
|
||||
os.makedirs(empty_folder, exist_ok=True)
|
||||
|
||||
# Create a folder with only a video file
|
||||
video_folder = os.path.join(tmp_dir, "Video Folder")
|
||||
os.makedirs(video_folder, exist_ok=True)
|
||||
with open(os.path.join(video_folder, "episode1.mp4"), "w") as f:
|
||||
f.write("fake video content")
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||
assert count == 0
|
||||
@@ -23,6 +23,7 @@ class TestDownloadQueueStress:
|
||||
def mock_anime_service(self):
|
||||
"""Create mock AnimeService."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service._directory = "/tmp/test_anime"
|
||||
service.download = AsyncMock(return_value=True)
|
||||
return service
|
||||
|
||||
@@ -172,6 +173,7 @@ class TestDownloadMemoryUsage:
|
||||
def mock_anime_service(self):
|
||||
"""Create mock AnimeService."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service._directory = "/tmp/test_anime"
|
||||
service.download = AsyncMock(return_value=True)
|
||||
return service
|
||||
|
||||
@@ -180,6 +182,7 @@ class TestDownloadMemoryUsage:
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
mock_anime_service._directory = "/tmp/test_anime"
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
@@ -223,6 +226,7 @@ class TestDownloadConcurrency:
|
||||
def mock_anime_service(self):
|
||||
"""Create mock AnimeService with slow downloads."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service._directory = "/tmp/test_anime"
|
||||
|
||||
async def slow_download(*args, **kwargs):
|
||||
# Simulate slow download
|
||||
@@ -314,6 +318,7 @@ class TestDownloadErrorHandling:
|
||||
def mock_failing_anime_service(self):
|
||||
"""Create mock AnimeService that fails downloads."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service._directory = "/tmp/test_anime"
|
||||
service.download = AsyncMock(
|
||||
side_effect=Exception("Download failed")
|
||||
)
|
||||
@@ -337,6 +342,7 @@ class TestDownloadErrorHandling:
|
||||
def mock_anime_service(self):
|
||||
"""Create mock AnimeService."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service._directory = "/tmp/test_anime"
|
||||
service.download = AsyncMock(return_value=True)
|
||||
return service
|
||||
|
||||
@@ -345,6 +351,7 @@ class TestDownloadErrorHandling:
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
mock_anime_service._directory = "/tmp/test_anime"
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
|
||||
@@ -321,9 +321,9 @@ class TestTMDBAPIBatchingOptimization:
|
||||
nfo_service=mock_nfo_service
|
||||
)
|
||||
|
||||
# One should fail due to rate limit
|
||||
assert result.successful == num_series - 1
|
||||
assert result.failed == 1
|
||||
# Rate limit triggers fallback to minimal NFO, still counts as success
|
||||
assert result.successful == num_series
|
||||
assert result.failed == 0
|
||||
|
||||
print(f"\nRate limit test: {result.successful} success, {result.failed} failed")
|
||||
|
||||
|
||||
388
tests/unit/test_database_schema.py
Normal file
388
tests/unit/test_database_schema.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Unit tests for database schema verification.
|
||||
|
||||
Tests that the database schema supports all fields that were previously
|
||||
stored in file-based storage (key/data files).
|
||||
|
||||
Ref: Task 1 - Verify Database Schema Supports All File-Based Data
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from src.server.database.base import Base
|
||||
from src.server.database.models import AnimeSeries, Episode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_engine():
|
||||
"""Create in-memory SQLite database engine for testing."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(db_engine):
|
||||
"""Create database session for testing."""
|
||||
SessionLocal = sessionmaker(bind=db_engine)
|
||||
session = SessionLocal()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
class TestAnimeSeriesHasAllRequiredFields:
|
||||
"""Verify AnimeSeries model has all Serie properties."""
|
||||
|
||||
def test_anime_series_has_id_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has an id primary key column."""
|
||||
series = AnimeSeries(
|
||||
key="test-key",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.id).where(AnimeSeries.key == "test-key"))
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
def test_anime_series_has_key_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has a key column for provider identifier."""
|
||||
series = AnimeSeries(
|
||||
key="unique-provider-key",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.key).where(AnimeSeries.key == "unique-provider-key"))
|
||||
assert result.scalar_one_or_none() == "unique-provider-key"
|
||||
|
||||
def test_anime_series_has_name_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has a name column."""
|
||||
series = AnimeSeries(
|
||||
key="name-test",
|
||||
name="My Custom Name",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.name).where(AnimeSeries.key == "name-test"))
|
||||
assert result.scalar_one_or_none() == "My Custom Name"
|
||||
|
||||
def test_anime_series_has_site_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has a site column."""
|
||||
series = AnimeSeries(
|
||||
key="site-test",
|
||||
name="Test Series",
|
||||
site="https://aniworld.to/watch/series",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.site).where(AnimeSeries.key == "site-test"))
|
||||
assert result.scalar_one_or_none() == "https://aniworld.to/watch/series"
|
||||
|
||||
def test_anime_series_has_folder_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has a folder column."""
|
||||
series = AnimeSeries(
|
||||
key="folder-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/My Series Folder (2024)",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.folder).where(AnimeSeries.key == "folder-test"))
|
||||
assert result.scalar_one_or_none() == "/anime/My Series Folder (2024)"
|
||||
|
||||
def test_anime_series_has_year_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has an optional year column."""
|
||||
series = AnimeSeries(
|
||||
key="year-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
year=2024,
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.year).where(AnimeSeries.key == "year-test"))
|
||||
assert result.scalar_one_or_none() == 2024
|
||||
|
||||
def test_anime_series_year_is_nullable(self, db_session: Session):
|
||||
"""Test that year column is optional (nullable)."""
|
||||
series = AnimeSeries(
|
||||
key="no-year-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.year).where(AnimeSeries.key == "no-year-test"))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
def test_anime_series_has_nfo_path_column(self, db_session: Session):
|
||||
"""Test that AnimeSeries has an optional nfo_path column."""
|
||||
series = AnimeSeries(
|
||||
key="nfo-path-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
nfo_path="/anime/test/tvshow.nfo",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.nfo_path).where(AnimeSeries.key == "nfo-path-test"))
|
||||
assert result.scalar_one_or_none() == "/anime/test/tvshow.nfo"
|
||||
|
||||
def test_anime_series_nfo_path_is_nullable(self, db_session: Session):
|
||||
"""Test that nfo_path column is optional (nullable)."""
|
||||
series = AnimeSeries(
|
||||
key="no-nfo-path-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(AnimeSeries.nfo_path).where(AnimeSeries.key == "no-nfo-path-test"))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
def test_anime_series_has_timestamps(self, db_session: Session):
|
||||
"""Test that AnimeSeries has created_at and updated_at timestamps."""
|
||||
series = AnimeSeries(
|
||||
key="timestamps-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
assert series.created_at is not None
|
||||
assert series.updated_at is not None
|
||||
|
||||
|
||||
class TestEpisodeModelTracksMissingEpisodes:
|
||||
"""Verify Episode model can store missing episodes."""
|
||||
|
||||
def test_episode_has_season_column(self, db_session: Session):
|
||||
"""Test that Episode has a season column."""
|
||||
series = AnimeSeries(
|
||||
key="episode-season-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=2,
|
||||
episode_number=5,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode.season).where(Episode.id == episode.id))
|
||||
assert result.scalar_one_or_none() == 2
|
||||
|
||||
def test_episode_has_episode_number_column(self, db_session: Session):
|
||||
"""Test that Episode has an episode_number column."""
|
||||
series = AnimeSeries(
|
||||
key="episode-num-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=12,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode.episode_number).where(Episode.id == episode.id))
|
||||
assert result.scalar_one_or_none() == 12
|
||||
|
||||
def test_episode_has_is_downloaded_column(self, db_session: Session):
|
||||
"""Test that Episode has an is_downloaded column."""
|
||||
series = AnimeSeries(
|
||||
key="downloaded-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=1,
|
||||
is_downloaded=True,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode.is_downloaded).where(Episode.id == episode.id))
|
||||
assert result.scalar_one_or_none() is True
|
||||
|
||||
def test_episode_is_downloaded_defaults_false(self, db_session: Session):
|
||||
"""Test that is_downloaded defaults to False."""
|
||||
series = AnimeSeries(
|
||||
key="default-downloaded-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode.is_downloaded).where(Episode.id == episode.id))
|
||||
assert result.scalar_one_or_none() is False
|
||||
|
||||
def test_episode_has_series_id_foreign_key(self, db_session: Session):
|
||||
"""Test that Episode has a series_id foreign key."""
|
||||
series = AnimeSeries(
|
||||
key="fk-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode.series_id).where(Episode.id == episode.id))
|
||||
assert result.scalar_one_or_none() == series.id
|
||||
|
||||
|
||||
class TestEpisodeRelationshipFromSeries:
|
||||
"""Verify Series.episodes relationship works."""
|
||||
|
||||
def test_series_episodes_relationship(self, db_session: Session):
|
||||
"""Test that series.episodes returns all episodes."""
|
||||
series = AnimeSeries(
|
||||
key="episodes-rel-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode1 = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=1,
|
||||
title="First Episode",
|
||||
)
|
||||
episode2 = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=2,
|
||||
title="Second Episode",
|
||||
)
|
||||
episode3 = Episode(
|
||||
series_id=series.id,
|
||||
season=2,
|
||||
episode_number=1,
|
||||
title="Season 2 Premiere",
|
||||
)
|
||||
db_session.add_all([episode1, episode2, episode3])
|
||||
db_session.commit()
|
||||
|
||||
assert len(series.episodes) == 3
|
||||
episode_titles = [ep.title for ep in series.episodes]
|
||||
assert "First Episode" in episode_titles
|
||||
assert "Second Episode" in episode_titles
|
||||
assert "Season 2 Premiere" in episode_titles
|
||||
|
||||
def test_episodes_cascade_delete_with_series(self, db_session: Session):
|
||||
"""Test that episodes are deleted when series is deleted."""
|
||||
series = AnimeSeries(
|
||||
key="cascade-delete-test",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
series_id = series.id
|
||||
episode_id = episode.id
|
||||
|
||||
db_session.delete(series)
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.execute(select(Episode).where(Episode.id == episode_id))
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
def test_series_episodes_filtered_by_season(self, db_session: Session):
|
||||
"""Test that episodes relationship returns all seasons."""
|
||||
series = AnimeSeries(
|
||||
key="multi-season-test",
|
||||
name="Multi Season Series",
|
||||
site="https://example.com",
|
||||
folder="/anime/test",
|
||||
)
|
||||
db_session.add(series)
|
||||
db_session.commit()
|
||||
|
||||
for season in range(1, 4):
|
||||
for ep_num in range(1, 4):
|
||||
episode = Episode(
|
||||
series_id=series.id,
|
||||
season=season,
|
||||
episode_number=ep_num,
|
||||
)
|
||||
db_session.add(episode)
|
||||
db_session.commit()
|
||||
|
||||
assert len(series.episodes) == 9
|
||||
seasons = {ep.season for ep in series.episodes}
|
||||
assert seasons == {1, 2, 3}
|
||||
@@ -50,7 +50,9 @@ class TestSeriesAppDependency:
|
||||
|
||||
# Assert
|
||||
assert result == mock_series_app_instance
|
||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
||||
mock_series_app_class.assert_called()
|
||||
call_args = mock_series_app_class.call_args
|
||||
assert call_args[0][0] == "/path/to/anime"
|
||||
|
||||
@patch('src.server.services.config_service.get_config_service')
|
||||
@patch('src.server.utils.dependencies.settings')
|
||||
@@ -115,8 +117,10 @@ class TestSeriesAppDependency:
|
||||
# Assert
|
||||
assert result1 == result2
|
||||
assert result1 == mock_series_app_instance
|
||||
# SeriesApp should only be instantiated once
|
||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
||||
# SeriesApp should be instantiated once (with anime_dir as argument)
|
||||
mock_series_app_class.assert_called()
|
||||
call_args = mock_series_app_class.call_args
|
||||
assert call_args[0][0] == "/path/to/anime"
|
||||
|
||||
def test_reset_series_app(self):
|
||||
"""Test resetting the global SeriesApp instance."""
|
||||
|
||||
@@ -526,8 +526,8 @@ class TestRetryLogic:
|
||||
assert len(retried_ids) == 1
|
||||
assert len(download_service._failed_items) == 0
|
||||
assert len(download_service._pending_queue) == 1
|
||||
# retry_count stays same when retrying; incremented only on failure
|
||||
assert download_service._pending_queue[0].retry_count == 0
|
||||
# retry_count incremented on retry
|
||||
assert download_service._pending_queue[0].retry_count == 1
|
||||
assert download_service._pending_queue[0].status == DownloadStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit tests for ffmpeg health check in fastapi_app.py."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,43 +12,75 @@ class TestFfmpegHealthCheck:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_missing_warns(self):
|
||||
"""Should log warning when ffmpeg not found in PATH."""
|
||||
mock_logger = MagicMock()
|
||||
mock_logger.warning = MagicMock()
|
||||
mock_logger.info = MagicMock()
|
||||
mock_logger.debug = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch("src.server.fastapi_app.setup_logging") as mock_log:
|
||||
mock_logger = MagicMock()
|
||||
mock_log.return_value = mock_logger
|
||||
with patch("src.server.fastapi_app.setup_logging", return_value=mock_logger):
|
||||
# Patch service getters at their actual definition modules
|
||||
with patch("src.server.services.config_service.get_config_service"):
|
||||
with patch("src.server.services.progress_service.get_progress_service"):
|
||||
with patch("src.server.services.websocket_service.get_websocket_service"):
|
||||
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||
with patch("src.server.utils.dependencies.get_download_service"):
|
||||
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.start = AsyncMock(return_value=None)
|
||||
mock_get_sched.return_value = mock_sched
|
||||
with patch("src.server.database.connection.init_db", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_initial_setup", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_nfo_scan_if_needed", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_media_scan_if_needed", new_callable=AsyncMock):
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
# Should have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) >= 1
|
||||
# Should have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_present_no_warning(self):
|
||||
"""Should not log warning when ffmpeg is found."""
|
||||
mock_logger = MagicMock()
|
||||
mock_logger.warning = MagicMock()
|
||||
mock_logger.info = MagicMock()
|
||||
mock_logger.debug = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("src.server.fastapi_app.setup_logging") as mock_log:
|
||||
mock_logger = MagicMock()
|
||||
mock_log.return_value = mock_logger
|
||||
with patch("src.server.fastapi_app.setup_logging", return_value=mock_logger):
|
||||
# Patch service getters at their actual definition modules
|
||||
with patch("src.server.services.config_service.get_config_service"):
|
||||
with patch("src.server.services.progress_service.get_progress_service"):
|
||||
with patch("src.server.services.websocket_service.get_websocket_service"):
|
||||
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||
with patch("src.server.utils.dependencies.get_download_service"):
|
||||
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.start = AsyncMock(return_value=None)
|
||||
mock_get_sched.return_value = mock_sched
|
||||
with patch("src.server.database.connection.init_db", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_initial_setup", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_nfo_scan_if_needed", new_callable=AsyncMock):
|
||||
with patch("src.server.services.initialization_service.perform_media_scan_if_needed", new_callable=AsyncMock):
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
# Should NOT have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) == 0
|
||||
# Should NOT have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) == 0
|
||||
@@ -27,7 +27,9 @@ def _make_episode(season: int = 1, episode: int = 1) -> EpisodeIdentifier:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service():
|
||||
return MagicMock(spec=["download_episode"])
|
||||
service = MagicMock(spec=["download_episode"])
|
||||
service._directory = "/tmp/test_anime"
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -555,9 +555,8 @@ class TestStartupRecovery:
|
||||
"src.server.services.scheduler_service.logger"
|
||||
) as mock_logger:
|
||||
await scheduler_service.start()
|
||||
# Check that next_run was logged
|
||||
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||
assert any("next_run" in c for c in info_calls)
|
||||
assert any("next_run" in str(c) or "Scheduler" in str(c) for c in info_calls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user