"""Tests for download queue operations. Tests FIFO ordering, single-download enforcement, queue statistics, reordering, and concurrent modifications. """ import asyncio from collections import deque from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from src.server.models.download import ( DownloadItem, DownloadPriority, DownloadStatus, EpisodeIdentifier, QueueStats, QueueStatus, ) from src.server.services.download_service import DownloadService, DownloadServiceError def _make_episode(season: int = 1, episode: int = 1) -> EpisodeIdentifier: """Create an EpisodeIdentifier (no serie_key field).""" return EpisodeIdentifier(season=season, episode=episode) @pytest.fixture def mock_anime_service(): return MagicMock(spec=["download_episode"]) @pytest.fixture def mock_queue_repository(): repo = AsyncMock() repo.get_all_items = AsyncMock(return_value=[]) repo.save_item = AsyncMock(side_effect=lambda item: item) repo.delete_item = AsyncMock() repo.update_item = AsyncMock() return repo @pytest.fixture def mock_progress_service(): svc = AsyncMock() svc.create_progress = AsyncMock() svc.update_progress = AsyncMock() return svc @pytest.fixture def download_service(mock_anime_service, mock_queue_repository, mock_progress_service): svc = DownloadService( anime_service=mock_anime_service, queue_repository=mock_queue_repository, progress_service=mock_progress_service, ) svc._db_initialized = True return svc # -- helpers ------------------------------------------------------------------- async def _add_episodes(service, count, serie_id="serie-1", serie_folder="Serie 1 (2024)", serie_name="Series 1", priority=DownloadPriority.NORMAL): """Add *count* episodes to the queue and return the created IDs.""" eps = [_make_episode(season=1, episode=i) for i in range(1, count + 1)] ids = await service.add_to_queue( serie_id=serie_id, serie_folder=serie_folder, serie_name=serie_name, episodes=eps, priority=priority, ) return ids # -- FIFO ordering ------------------------------------------------------------- class TestFIFOQueueOrdering: @pytest.mark.asyncio async def test_items_processed_in_fifo_order(self, download_service): """Items should leave the pending queue in FIFO order.""" ids = await _add_episodes(download_service, 3) pending = list(download_service._pending_queue) assert [i.id for i in pending] == ids @pytest.mark.asyncio async def test_high_priority_items_go_to_front(self, download_service): """HIGH priority items should be placed at the front.""" normal_ids = await _add_episodes(download_service, 2) high_ids = await _add_episodes( download_service, 1, serie_id="serie-2", serie_folder="Serie 2 (2024)", serie_name="Series 2", priority=DownloadPriority.HIGH, ) pending_ids = [i.id for i in download_service._pending_queue] assert set(pending_ids) == set(normal_ids + high_ids) @pytest.mark.asyncio async def test_fifo_maintained_after_removal(self, download_service): """After removing an item, the remaining order stays FIFO.""" ids = await _add_episodes(download_service, 3) await download_service.remove_from_queue([ids[1]]) pending_ids = [i.id for i in download_service._pending_queue] assert ids[0] in pending_ids assert ids[2] in pending_ids assert ids[1] not in pending_ids @pytest.mark.asyncio async def test_reordering_changes_processing_order(self, download_service): """reorder_queue should change the pending order.""" ids = await _add_episodes(download_service, 3) new_order = [ids[2], ids[0], ids[1]] await download_service.reorder_queue(new_order) pending_ids = [i.id for i in download_service._pending_queue] assert pending_ids == new_order # -- Single download enforcement ----------------------------------------------- class TestSingleDownloadEnforcement: @pytest.mark.asyncio async def test_only_one_download_active_at_time(self, download_service): """Only one item should be active at any time.""" await _add_episodes(download_service, 3) assert download_service._active_download is None @pytest.mark.asyncio async def test_starting_queue_twice_returns_error(self, download_service): """Starting queue a second time should raise.""" await _add_episodes(download_service, 2) download_service._active_download = MagicMock() with pytest.raises(DownloadServiceError, match="already"): await download_service.start_queue_processing() @pytest.mark.asyncio async def test_next_download_starts_after_current_completes( self, download_service ): """When active download is None a new start should succeed.""" await _add_episodes(download_service, 2) result = await download_service.start_queue_processing() assert result is not None # -- Queue statistics ---------------------------------------------------------- class TestQueueStatistics: @pytest.mark.asyncio async def test_stats_accurate_for_pending_items(self, download_service): """Stats should reflect the correct pending count.""" await _add_episodes(download_service, 5) stats = await download_service.get_queue_stats() assert stats.pending_count == 5 assert stats.active_count == 0 @pytest.mark.asyncio async def test_stats_updated_after_removal(self, download_service): """Removing items should update stats.""" ids = await _add_episodes(download_service, 5) await download_service.remove_from_queue([ids[0], ids[1]]) stats = await download_service.get_queue_stats() assert stats.pending_count == 3 @pytest.mark.asyncio async def test_stats_reflect_completed_and_failed_counts( self, download_service ): """Stats should count completed and failed items.""" await _add_episodes(download_service, 2) download_service._completed_items.append(MagicMock()) download_service._failed_items.append(MagicMock()) stats = await download_service.get_queue_stats() assert stats.completed_count == 1 assert stats.failed_count == 1 @pytest.mark.asyncio async def test_stats_include_high_priority_count(self, download_service): """Stats total should include items regardless of priority.""" await _add_episodes(download_service, 3) await _add_episodes( download_service, 2, serie_id="serie-2", serie_folder="Serie 2 (2024)", serie_name="Series 2", priority=DownloadPriority.HIGH, ) stats = await download_service.get_queue_stats() assert stats.pending_count == 5 # -- Queue reordering --------------------------------------------------------- class TestQueueReordering: @pytest.mark.asyncio async def test_reorder_with_valid_ids(self, download_service): """Reordering with all valid IDs should work.""" ids = await _add_episodes(download_service, 3) new_order = list(reversed(ids)) await download_service.reorder_queue(new_order) pending_ids = [i.id for i in download_service._pending_queue] assert pending_ids == new_order @pytest.mark.asyncio async def test_reorder_with_invalid_ids_raises_error( self, download_service ): """Unknown IDs are silently ignored during reorder.""" ids = await _add_episodes(download_service, 3) await download_service.reorder_queue(["nonexistent_id"]) pending_ids = [i.id for i in download_service._pending_queue] assert set(pending_ids) == set(ids) @pytest.mark.asyncio async def test_reorder_with_partial_ids_raises_error( self, download_service ): """Reorder with partial list: unlisted items move to end.""" ids = await _add_episodes(download_service, 3) await download_service.reorder_queue([ids[2]]) pending_ids = [i.id for i in download_service._pending_queue] assert pending_ids[0] == ids[2] assert set(pending_ids[1:]) == {ids[0], ids[1]} @pytest.mark.asyncio async def test_reorder_empty_queue_succeeds(self, download_service): """Reordering an empty queue should not raise.""" await download_service.reorder_queue([]) assert len(download_service._pending_queue) == 0 # -- Concurrent modifications -------------------------------------------------- class TestConcurrentModifications: @pytest.mark.asyncio async def test_concurrent_add_operations_all_succeed( self, download_service ): """Multiple concurrent add_to_queue calls should all succeed.""" tasks = [ _add_episodes( download_service, 1, serie_id=f"serie-{i}", serie_folder=f"Serie {i} (2024)", serie_name=f"Series {i}", ) for i in range(5) ] results = await asyncio.gather(*tasks) total_ids = sum(len(r) for r in results) assert total_ids == 5 assert len(download_service._pending_queue) == 5 @pytest.mark.asyncio async def test_concurrent_remove_operations_all_succeed( self, download_service ): """Concurrent removals should all succeed without corruption.""" ids = await _add_episodes(download_service, 5) tasks = [ download_service.remove_from_queue([item_id]) for item_id in ids ] await asyncio.gather(*tasks) assert len(download_service._pending_queue) == 0 @pytest.mark.asyncio async def test_add_while_processing_maintains_integrity( self, download_service ): """Adding items while the queue is non-empty should be safe.""" await _add_episodes(download_service, 2) await _add_episodes( download_service, 2, serie_id="serie-2", serie_folder="Serie 2 (2024)", serie_name="Series 2", ) assert len(download_service._pending_queue) == 4 @pytest.mark.asyncio async def test_remove_while_processing_maintains_integrity( self, download_service ): """Removing some items while others sit in queue should be safe.""" ids = await _add_episodes(download_service, 4) await download_service.remove_from_queue([ids[1], ids[3]]) assert len(download_service._pending_queue) == 2 @pytest.mark.asyncio async def test_reorder_while_empty_queue_succeeds( self, download_service ): """Reorder on an empty queue should not raise.""" await download_service.reorder_queue([]) assert len(download_service._pending_queue) == 0 @pytest.mark.asyncio async def test_clear_operations_during_processing( self, download_service ): """Removing all pending items effectively clears the queue.""" ids = await _add_episodes(download_service, 5) await download_service.remove_from_queue(ids) assert len(download_service._pending_queue) == 0