- Fix failing test_authenticated_request_succeeds (dependency override) - Expand test_anime_service.py (+35 tests: status events, DB, broadcasts) - Create test_queue_repository.py (27 tests: CRUD, model conversion) - Expand test_enhanced_provider.py (+24 tests: fetch, download, redirect) - Expand test_serie_scanner.py (+25 tests: events, year extract, mp4 scan) - Create test_database_connection.py (38 tests: sessions, transactions) - Expand test_anime_endpoints.py (+39 tests: status, search, loading) - Clean up docs/instructions.md TODO list
455 lines
18 KiB
Python
455 lines
18 KiB
Python
"""Unit tests for QueueRepository.
|
|
|
|
Tests cover model conversion, CRUD operations (save, get, get_all,
|
|
set_error, delete, clear), error handling, and the singleton factory.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.models.download import (
|
|
DownloadItem,
|
|
DownloadPriority,
|
|
DownloadStatus,
|
|
EpisodeIdentifier,
|
|
)
|
|
from src.server.services.queue_repository import (
|
|
QueueRepository,
|
|
QueueRepositoryError,
|
|
get_queue_repository,
|
|
reset_queue_repository,
|
|
)
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_singleton():
|
|
"""Ensure singleton is reset before and after every test."""
|
|
reset_queue_repository()
|
|
yield
|
|
reset_queue_repository()
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session():
|
|
"""Async session mock."""
|
|
session = AsyncMock()
|
|
session.commit = AsyncMock()
|
|
session.rollback = AsyncMock()
|
|
session.close = AsyncMock()
|
|
return session
|
|
|
|
|
|
@pytest.fixture
|
|
def session_factory(mock_session):
|
|
"""Factory that returns the mock session."""
|
|
return MagicMock(return_value=mock_session)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(session_factory):
|
|
"""QueueRepository instance backed by mock session."""
|
|
return QueueRepository(db_session_factory=session_factory)
|
|
|
|
|
|
def _make_db_item(
|
|
*,
|
|
db_id: int = 1,
|
|
series_key: str = "aot",
|
|
series_name: str = "Attack on Titan",
|
|
series_folder: str = "Attack on Titan (2013)",
|
|
season: int = 1,
|
|
episode_number: int = 3,
|
|
episode_title: str | None = None,
|
|
created_at: datetime | None = None,
|
|
started_at: datetime | None = None,
|
|
completed_at: datetime | None = None,
|
|
error_message: str | None = None,
|
|
download_url: str | None = None,
|
|
):
|
|
"""Build a fake DB DownloadQueueItem."""
|
|
episode = MagicMock()
|
|
episode.season = season
|
|
episode.episode_number = episode_number
|
|
episode.title = episode_title
|
|
|
|
series = MagicMock()
|
|
series.key = series_key
|
|
series.folder = series_folder
|
|
series.name = series_name
|
|
|
|
db_item = MagicMock()
|
|
db_item.id = db_id
|
|
db_item.episode = episode
|
|
db_item.series = series
|
|
db_item.created_at = created_at or datetime(2025, 1, 1, tzinfo=timezone.utc)
|
|
db_item.started_at = started_at
|
|
db_item.completed_at = completed_at
|
|
db_item.error_message = error_message
|
|
db_item.download_url = download_url
|
|
return db_item
|
|
|
|
|
|
def _make_download_item(**kwargs) -> DownloadItem:
|
|
"""Build a DownloadItem for save tests."""
|
|
defaults = dict(
|
|
id="tmp-1",
|
|
serie_id="naruto",
|
|
serie_folder="Naruto",
|
|
serie_name="Naruto",
|
|
episode=EpisodeIdentifier(season=1, episode=5),
|
|
status=DownloadStatus.PENDING,
|
|
priority=DownloadPriority.NORMAL,
|
|
added_at=datetime.now(timezone.utc),
|
|
)
|
|
defaults.update(kwargs)
|
|
return DownloadItem(**defaults)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Model Conversion
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestFromDBModel:
|
|
"""Test _from_db_model conversion."""
|
|
|
|
def test_basic_conversion(self, repo):
|
|
"""Should produce a DownloadItem from a DB model."""
|
|
db_item = _make_db_item()
|
|
result = repo._from_db_model(db_item)
|
|
|
|
assert isinstance(result, DownloadItem)
|
|
assert result.id == "1"
|
|
assert result.serie_id == "aot"
|
|
assert result.serie_folder == "Attack on Titan (2013)"
|
|
assert result.serie_name == "Attack on Titan"
|
|
assert result.episode.season == 1
|
|
assert result.episode.episode == 3
|
|
assert result.status == DownloadStatus.PENDING
|
|
assert result.priority == DownloadPriority.NORMAL
|
|
|
|
def test_custom_item_id(self, repo):
|
|
"""item_id kwarg should override the DB ID."""
|
|
db_item = _make_db_item(db_id=99)
|
|
result = repo._from_db_model(db_item, item_id="custom-42")
|
|
assert result.id == "custom-42"
|
|
|
|
def test_missing_episode(self, repo):
|
|
"""If episode is None, defaults should be used."""
|
|
db_item = _make_db_item()
|
|
db_item.episode = None
|
|
result = repo._from_db_model(db_item)
|
|
assert result.episode.season == 1
|
|
assert result.episode.episode == 1
|
|
|
|
def test_missing_series(self, repo):
|
|
"""If series is None, defaults should be used."""
|
|
db_item = _make_db_item()
|
|
db_item.series = None
|
|
# serie_name has min_length=1 in Pydantic, so empty string
|
|
# causes validation error. This test verifies the fallback behavior.
|
|
# The _from_db_model method falls back to empty strings for key/folder
|
|
# and empty string for name, which will trigger a Pydantic validation error.
|
|
with pytest.raises(Exception):
|
|
repo._from_db_model(db_item)
|
|
|
|
def test_error_message_preserved(self, repo):
|
|
"""Error message from DB should be carried over."""
|
|
db_item = _make_db_item(error_message="timeout")
|
|
result = repo._from_db_model(db_item)
|
|
assert result.error == "timeout"
|
|
|
|
def test_download_url_preserved(self, repo):
|
|
"""Source URL from DB should be carried over."""
|
|
db_item = _make_db_item(download_url="https://example.com/video.mp4")
|
|
result = repo._from_db_model(db_item)
|
|
assert str(result.source_url) == "https://example.com/video.mp4"
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# get_item
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGetItem:
|
|
"""Test get_item method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_item(self, repo, mock_session):
|
|
"""Should return a DownloadItem when found."""
|
|
db_item = _make_db_item(db_id=5)
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_by_id = AsyncMock(return_value=db_item)
|
|
result = await repo.get_item("5")
|
|
|
|
assert result is not None
|
|
assert result.id == "5"
|
|
mock_session.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_when_missing(self, repo, mock_session):
|
|
"""Should return None when item not found."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_by_id = AsyncMock(return_value=None)
|
|
result = await repo.get_item("999")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_id_returns_none(self, repo, mock_session):
|
|
"""Non-numeric ID should return None."""
|
|
result = await repo.get_item("abc")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_error_raises(self, repo, mock_session):
|
|
"""DB error should raise QueueRepositoryError."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_by_id = AsyncMock(
|
|
side_effect=RuntimeError("DB down")
|
|
)
|
|
with pytest.raises(QueueRepositoryError, match="Failed to get item"):
|
|
await repo.get_item("1")
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# get_all_items
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGetAllItems:
|
|
"""Test get_all_items method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_list(self, repo, mock_session):
|
|
"""Should return list of DownloadItems."""
|
|
db_items = [_make_db_item(db_id=i) for i in range(3)]
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_all = AsyncMock(return_value=db_items)
|
|
result = await repo.get_all_items()
|
|
|
|
assert len(result) == 3
|
|
mock_session.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_returns_empty_list(self, repo, mock_session):
|
|
"""Should return [] when no items exist."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_all = AsyncMock(return_value=[])
|
|
result = await repo.get_all_items()
|
|
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_error_raises(self, repo, mock_session):
|
|
"""DB error should raise QueueRepositoryError."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.get_all = AsyncMock(
|
|
side_effect=RuntimeError("DB down")
|
|
)
|
|
with pytest.raises(QueueRepositoryError, match="Failed to get all"):
|
|
await repo.get_all_items()
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# set_error
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestSetError:
|
|
"""Test set_error method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success(self, repo, mock_session):
|
|
"""Should return True on success."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.set_error = AsyncMock(return_value=MagicMock())
|
|
result = await repo.set_error("1", "some error")
|
|
|
|
assert result is True
|
|
mock_session.commit.assert_called_once()
|
|
mock_session.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_found(self, repo, mock_session):
|
|
"""Should return False when item not found."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.set_error = AsyncMock(return_value=None)
|
|
result = await repo.set_error("999", "err")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_id_returns_false(self, repo, mock_session):
|
|
"""Non-numeric ID should return False."""
|
|
result = await repo.set_error("abc", "err")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_error_raises(self, repo, mock_session):
|
|
"""DB error should raise QueueRepositoryError."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.set_error = AsyncMock(
|
|
side_effect=RuntimeError("boom")
|
|
)
|
|
with pytest.raises(QueueRepositoryError, match="Failed to set error"):
|
|
await repo.set_error("1", "err")
|
|
mock_session.rollback.assert_called_once()
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# delete_item
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestDeleteItem:
|
|
"""Test delete_item method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success(self, repo, mock_session):
|
|
"""Should return True when deleted."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.delete = AsyncMock(return_value=True)
|
|
result = await repo.delete_item("1")
|
|
|
|
assert result is True
|
|
mock_session.commit.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_found(self, repo, mock_session):
|
|
"""Should return False when item does not exist."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.delete = AsyncMock(return_value=False)
|
|
result = await repo.delete_item("999")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_id_returns_false(self, repo, mock_session):
|
|
"""Non-numeric ID should return False."""
|
|
result = await repo.delete_item("abc")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_error_raises(self, repo, mock_session):
|
|
"""DB error should raise QueueRepositoryError."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS:
|
|
MockDQS.delete = AsyncMock(side_effect=RuntimeError("boom"))
|
|
with pytest.raises(QueueRepositoryError, match="Failed to delete"):
|
|
await repo.delete_item("1")
|
|
mock_session.rollback.assert_called_once()
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# clear_all
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestClearAll:
|
|
"""Test clear_all method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_count(self, repo, mock_session):
|
|
"""Should return number of deleted items."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS, patch(
|
|
"src.server.services.queue_repository.atomic"
|
|
) as mock_atomic:
|
|
MockDQS.clear_all = AsyncMock(return_value=5)
|
|
# atomic context manager
|
|
mock_atomic.return_value.__aenter__ = AsyncMock()
|
|
mock_atomic.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
result = await repo.clear_all()
|
|
|
|
assert result == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_queue_returns_zero(self, repo, mock_session):
|
|
"""Should return 0 when queue is empty."""
|
|
with patch(
|
|
"src.server.services.queue_repository.DownloadQueueService"
|
|
) as MockDQS, patch(
|
|
"src.server.services.queue_repository.atomic"
|
|
) as mock_atomic:
|
|
MockDQS.clear_all = AsyncMock(return_value=0)
|
|
mock_atomic.return_value.__aenter__ = AsyncMock()
|
|
mock_atomic.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
result = await repo.clear_all()
|
|
|
|
assert result == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_error_raises(self, repo, mock_session):
|
|
"""DB error should raise QueueRepositoryError."""
|
|
with patch(
|
|
"src.server.services.queue_repository.atomic"
|
|
) as mock_atomic:
|
|
mock_atomic.return_value.__aenter__ = AsyncMock(
|
|
side_effect=RuntimeError("boom")
|
|
)
|
|
mock_atomic.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
with pytest.raises(QueueRepositoryError, match="Failed to clear"):
|
|
await repo.clear_all()
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Singleton Factory
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestSingletonFactory:
|
|
"""Test get_queue_repository and reset."""
|
|
|
|
def test_creates_singleton(self):
|
|
"""Should return same instance on repeated calls."""
|
|
factory = MagicMock()
|
|
instance1 = get_queue_repository(factory)
|
|
instance2 = get_queue_repository(factory)
|
|
assert instance1 is instance2
|
|
|
|
def test_reset_clears_instance(self):
|
|
"""After reset, a new instance should be created."""
|
|
factory = MagicMock()
|
|
instance1 = get_queue_repository(factory)
|
|
reset_queue_repository()
|
|
instance2 = get_queue_repository(factory)
|
|
assert instance1 is not instance2
|
|
|
|
def test_default_factory_used_when_none(self):
|
|
"""When no factory passed, should use default from connection."""
|
|
with patch(
|
|
"src.server.database.connection.get_async_session_factory"
|
|
) as mock_factory:
|
|
instance = get_queue_repository()
|
|
assert instance is not None
|