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