304 lines
8.7 KiB
Python
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
|