Files
musicseerr/backend/tests/test_discover_queue_manager.py
T
2026-04-03 15:53:00 +01:00

304 lines
8.7 KiB
Python

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