Files
Aniworld/tests/unit/test_queue_repository.py
Lukas e84a220f55 Expand test coverage: ~188 new tests across 6 critical files
- 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
2026-02-15 17:49:12 +01:00

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