"""Tests for DiscoverQueueManager background queue building.""" import asyncio import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from api.v1.schemas.discover import DiscoverQueueEnrichment, DiscoverQueueItemLight, DiscoverQueueResponse from services.discover_queue_manager import DiscoverQueueManager, QueueBuildStatus def _make_queue(n: int = 3) -> DiscoverQueueResponse: return DiscoverQueueResponse( items=[ DiscoverQueueItemLight( release_group_mbid=f"mbid-{i}", album_name=f"Album {i}", artist_name=f"Artist {i}", artist_mbid=f"artist-{i}", cover_url=None, recommendation_reason="test", ) for i in range(n) ], queue_id="test-queue-id", ) def _make_manager( queue: DiscoverQueueResponse | None = None, build_error: Exception | None = None, ttl: int = 86400, ) -> DiscoverQueueManager: discover = AsyncMock() if build_error: discover.build_queue.side_effect = build_error else: discover.build_queue.return_value = queue or _make_queue() discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) prefs = MagicMock() adv = MagicMock() adv.discover_queue_ttl = ttl prefs.get_advanced_settings.return_value = adv return DiscoverQueueManager(discover, prefs) @pytest.mark.asyncio async def test_initial_status_is_idle(): expect_assertions = True mgr = _make_manager() status = mgr.get_status("listenbrainz") assert status.status == "idle" assert status.source == "listenbrainz" @pytest.mark.asyncio async def test_start_build_changes_status(): expect_assertions = True mgr = _make_manager() result = await mgr.start_build("listenbrainz") assert result.action == "started" assert result.status in ("building", "ready") @pytest.mark.asyncio async def test_build_produces_ready_queue(): expect_assertions = True queue = _make_queue(5) mgr = _make_manager(queue=queue) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) status = mgr.get_status("listenbrainz") assert status.status == "ready" assert status.item_count == 5 built_queue = mgr.get_queue("listenbrainz") assert built_queue is not None assert all(item.enrichment is not None for item in built_queue.items) @pytest.mark.asyncio async def test_enrichment_failures_fall_back_to_empty_enrichment(): expect_assertions = True queue = _make_queue(2) mgr = _make_manager(queue=queue) mgr._discover.enrich_queue_item.side_effect = RuntimeError("enrichment failed") await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) built_queue = mgr.get_queue("listenbrainz") assert built_queue is not None assert all(item.enrichment is not None for item in built_queue.items) @pytest.mark.asyncio async def test_get_queue_returns_cached(): expect_assertions = True queue = _make_queue(3) mgr = _make_manager(queue=queue) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) result = mgr.get_queue("listenbrainz") assert result is not None assert len(result.items) == 3 @pytest.mark.asyncio async def test_consume_queue_returns_and_clears(): expect_assertions = True mgr = _make_manager() await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) consumed = await mgr.consume_queue("listenbrainz") assert consumed is not None assert len(consumed.items) == 3 assert mgr.get_queue("listenbrainz") is None assert mgr.get_status("listenbrainz").status == "idle" @pytest.mark.asyncio async def test_build_error_sets_error_status(): expect_assertions = True mgr = _make_manager(build_error=RuntimeError("test fail")) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) status = mgr.get_status("listenbrainz") assert status.status == "error" assert "test fail" in (status.error or "") @pytest.mark.asyncio async def test_already_building_is_no_op(): expect_assertions = True slow_discover = AsyncMock() async def slow_build(**kwargs): await asyncio.sleep(5) return _make_queue() slow_discover.build_queue.side_effect = slow_build prefs = MagicMock() adv = MagicMock() adv.discover_queue_ttl = 86400 prefs.get_advanced_settings.return_value = adv mgr = DiscoverQueueManager(slow_discover, prefs) await mgr.start_build("listenbrainz") result = await mgr.start_build("listenbrainz") assert result.action == "already_building" mgr.invalidate("listenbrainz") @pytest.mark.asyncio async def test_force_rebuild_when_ready(): expect_assertions = True mgr = _make_manager() await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) result = await mgr.start_build("listenbrainz", force=True) assert result.action == "started" @pytest.mark.asyncio async def test_invalidate_resets_state(): expect_assertions = True mgr = _make_manager() await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) mgr.invalidate("listenbrainz") status = mgr.get_status("listenbrainz") assert status.status == "idle" @pytest.mark.asyncio async def test_separate_sources_are_independent(): expect_assertions = True mgr = _make_manager() await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) lb_status = mgr.get_status("listenbrainz") lfm_status = mgr.get_status("lastfm") assert lb_status.status == "ready" assert lfm_status.status == "idle" @pytest.mark.asyncio async def test_consume_queue_rejects_stale(): expect_assertions = True mgr = _make_manager(ttl=1) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) assert mgr.get_status("listenbrainz").status == "ready" mgr._get_state("listenbrainz").built_at = time.time() - 10 consumed = await mgr.consume_queue("listenbrainz") assert consumed is None assert mgr.get_status("listenbrainz").status == "idle" @pytest.mark.asyncio async def test_get_queue_rejects_stale(): expect_assertions = True mgr = _make_manager(ttl=1) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) assert mgr.get_queue("listenbrainz") is not None mgr._get_state("listenbrainz").built_at = time.time() - 10 assert mgr.get_queue("listenbrainz") is None @pytest.mark.asyncio async def test_stale_flag_in_status(): expect_assertions = True mgr = _make_manager(ttl=1) await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) status = mgr.get_status("listenbrainz") assert status.stale is False mgr._get_state("listenbrainz").built_at = time.time() - 10 status = mgr.get_status("listenbrainz") assert status.stale is True @pytest.mark.asyncio async def test_build_prewarms_covers(): expect_assertions = True queue = _make_queue(3) cover_repo = AsyncMock() cover_repo.get_release_group_cover = AsyncMock(return_value=(b"img", "image/jpeg", "caa")) discover = AsyncMock() discover.build_queue.return_value = queue discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) prefs = MagicMock() adv = MagicMock() adv.discover_queue_ttl = 86400 prefs.get_advanced_settings.return_value = adv mgr = DiscoverQueueManager(discover, prefs, cover_repo=cover_repo) await mgr.start_build("listenbrainz") await asyncio.sleep(0.3) assert cover_repo.get_release_group_cover.call_count == 3 called_mbids = sorted( call.args[0] for call in cover_repo.get_release_group_cover.call_args_list ) assert called_mbids == ["mbid-0", "mbid-1", "mbid-2"] @pytest.mark.asyncio async def test_build_prewarm_skipped_without_cover_repo(): expect_assertions = True mgr = _make_manager() await mgr.start_build("listenbrainz") await asyncio.sleep(0.1) assert mgr.get_status("listenbrainz").status == "ready" @pytest.mark.asyncio async def test_build_prewarm_failure_does_not_break_queue(): expect_assertions = True queue = _make_queue(2) cover_repo = AsyncMock() cover_repo.get_release_group_cover = AsyncMock(side_effect=RuntimeError("fetch failed")) discover = AsyncMock() discover.build_queue.return_value = queue discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) prefs = MagicMock() adv = MagicMock() adv.discover_queue_ttl = 86400 prefs.get_advanced_settings.return_value = adv mgr = DiscoverQueueManager(discover, prefs, cover_repo=cover_repo) await mgr.start_build("listenbrainz") await asyncio.sleep(0.3) assert mgr.get_status("listenbrainz").status == "ready" assert mgr.get_queue("listenbrainz") is not None