Files
musicseerr/backend/tests/services/test_artist_release_pagination.py
T
Harvey d24e26fb32 In library rework + Monitored/Unmonitored statuses (#50)
* In library rework + Monitored/Unmonitored statuses

* address comments + format
2026-04-16 00:51:13 +01:00

315 lines
11 KiB
Python

"""Service-level tests for filter-aware artist release pagination."""
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
import pytest
from unittest.mock import AsyncMock, MagicMock
from services.artist_service import ArtistService
ARTIST_MBID = "f4a31f0a-51dd-4fa7-986d-3095c40c5ed9"
def _make_release_group(rg_id: str, title: str, primary_type: str, date: str = "2020-01-01") -> dict:
return {
"id": rg_id,
"title": title,
"primary-type": primary_type,
"secondary-types": [],
"first-release-date": date,
}
def _make_prefs(primary_types: list[str] | None = None, secondary_types: list[str] | None = None) -> MagicMock:
p = MagicMock()
p.get_preferences.return_value = MagicMock(
primary_types=primary_types if primary_types is not None else ["Album", "Single", "EP"],
secondary_types=secondary_types if secondary_types is not None else ["Studio", "Live", "Compilation"],
)
p.get_advanced_settings.return_value = MagicMock(
cache_ttl_artist_library=21600,
cache_ttl_artist_non_library=3600,
)
return p
def _make_service(
*,
mb_release_pages: list[tuple[list[dict], int]] | None = None,
lidarr_artist: dict | None = None,
prefs: MagicMock | None = None,
) -> ArtistService:
mb_repo = AsyncMock()
if mb_release_pages is not None:
mb_repo.get_artist_release_groups = AsyncMock(side_effect=mb_release_pages)
else:
mb_repo.get_artist_release_groups = AsyncMock(return_value=([], 0))
lidarr_repo = MagicMock()
lidarr_repo.is_configured.return_value = False
lidarr_repo.get_artist_details = AsyncMock(return_value=lidarr_artist)
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
lidarr_repo.get_monitored_no_files_mbids = AsyncMock(return_value=set())
lidarr_repo.get_artist_mbids = AsyncMock(return_value=set())
wikidata_repo = AsyncMock()
memory_cache = AsyncMock()
memory_cache.get = AsyncMock(return_value=None)
memory_cache.set = AsyncMock()
disk_cache = AsyncMock()
disk_cache.get_artist = AsyncMock(return_value=None)
disk_cache.set_artist = AsyncMock()
return ArtistService(
mb_repo=mb_repo,
lidarr_repo=lidarr_repo,
wikidata_repo=wikidata_repo,
preferences_service=prefs or _make_prefs(),
memory_cache=memory_cache,
disk_cache=disk_cache,
)
class TestFilterAwarePagination:
@pytest.mark.asyncio
async def test_single_page_fits_filter(self):
rg1 = _make_release_group("rg-1", "Album A", "Album")
rg2 = _make_release_group("rg-2", "Single B", "Single")
svc = _make_service(mb_release_pages=[([rg1, rg2], 2)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert len(result.albums) == 1
assert result.albums[0].title == "Album A"
assert len(result.singles) == 1
assert result.singles[0].title == "Single B"
assert result.has_more is False
assert result.next_offset is None
assert result.returned_count == 2
assert result.source_total_count == 2
@pytest.mark.asyncio
async def test_sparse_filter_scans_multiple_batches(self):
batch1 = [_make_release_group(f"rg-{i}", f"Broadcast {i}", "Broadcast") for i in range(5)]
batch2 = [_make_release_group("rg-album", "Real Album", "Album")]
svc = _make_service(
mb_release_pages=[
(batch1, 6),
(batch2, 6),
]
)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.albums[0].title == "Real Album"
assert result.returned_count == 1
assert result.source_total_count == 6
@pytest.mark.asyncio
async def test_has_more_true_when_unscanned_raw_data_remains(self):
rgs = [_make_release_group("rg-1", "Album 1", "Album")]
svc = _make_service(mb_release_pages=[(rgs, 200), ([], 200), ([], 200)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.has_more is True
assert result.next_offset is not None
assert result.returned_count == 1
@pytest.mark.asyncio
async def test_next_offset_is_scan_position(self):
batch1 = [_make_release_group(f"rg-{i}", f"Album {i}", "Album") for i in range(100)]
svc = _make_service(mb_release_pages=[(batch1, 200)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=10)
assert result.has_more is True
assert result.next_offset == 100
@pytest.mark.asyncio
async def test_no_duplicates_within_scan(self):
batch1 = [_make_release_group(f"rg-{i}", f"Album {i}", "Album") for i in range(5)]
batch2 = [_make_release_group(f"rg-{i}", f"Album {i}", "Album") for i in range(3, 8)]
svc = _make_service(
mb_release_pages=[
(batch1, 8),
(batch2, 8),
]
)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
all_ids = [r.id for r in result.albums]
assert len(all_ids) == len(set(all_ids))
@pytest.mark.asyncio
async def test_no_drops_across_sequential_pages(self):
rgs = [_make_release_group(f"rg-{i}", f"Album {i}", "Album") for i in range(10)]
svc = _make_service(mb_release_pages=[(rgs, 10)])
page1 = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=3)
assert page1.returned_count == 10
assert page1.has_more is False
assert page1.next_offset is None
@pytest.mark.asyncio
async def test_empty_result_set(self):
svc = _make_service(mb_release_pages=[([], 0)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 0
assert result.has_more is False
assert result.next_offset is None
assert result.source_total_count == 0
@pytest.mark.asyncio
async def test_lidarr_library_artist_single_page(self):
lidarr_albums = [
{"mbid": "rg-1", "title": "Lib Album", "album_type": "Album", "release_date": "2020-01-01", "year": 2020, "track_file_count": 5, "secondary_types": []},
]
svc = _make_service(lidarr_artist={"monitored": True})
lidarr_repo = svc._lidarr_repo
lidarr_repo.get_artist_albums = AsyncMock(return_value=lidarr_albums)
lidarr_repo.get_library_mbids = AsyncMock(return_value={"rg-1"})
lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 1
assert result.has_more is False
assert result.next_offset is None
@pytest.mark.asyncio
async def test_lidarr_library_artist_offset_gt_zero_returns_empty(self):
svc = _make_service(lidarr_artist={"monitored": True})
result = await svc.get_artist_releases(ARTIST_MBID, offset=50, limit=50)
assert result.returned_count == 0
assert result.has_more is False
assert result.next_offset is None
@pytest.mark.asyncio
async def test_offset_reflects_client_param(self):
rg = _make_release_group("rg-1", "Album 1", "Album")
svc = _make_service(mb_release_pages=[([rg], 1)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=200, limit=50)
assert result.offset == 200
@pytest.mark.asyncio
async def test_returned_count_across_categories(self):
rgs = [
_make_release_group("rg-a1", "Album 1", "Album"),
_make_release_group("rg-a2", "Album 2", "Album"),
_make_release_group("rg-s1", "Single 1", "Single"),
_make_release_group("rg-e1", "EP 1", "EP"),
]
svc = _make_service(mb_release_pages=[(rgs, 4)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 4
assert len(result.albums) == 2
assert len(result.singles) == 1
assert len(result.eps) == 1
@pytest.mark.asyncio
async def test_limit_controls_scan_termination_all_items_returned(self):
rgs = [
_make_release_group("rg-a1", "Album 1", "Album"),
_make_release_group("rg-a2", "Album 2", "Album"),
_make_release_group("rg-a3", "Album 3", "Album"),
_make_release_group("rg-s1", "Single 1", "Single"),
_make_release_group("rg-e1", "EP 1", "EP"),
]
svc = _make_service(mb_release_pages=[(rgs, 5)])
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=3)
assert result.returned_count == 5
assert len(result.albums) == 3
assert len(result.singles) == 1
assert len(result.eps) == 1
assert result.has_more is False
@pytest.mark.asyncio
async def test_exception_returns_empty_page(self):
svc = _make_service()
svc._lidarr_repo.get_artist_details = AsyncMock(side_effect=RuntimeError("boom"))
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 0
assert result.has_more is False
assert result.next_offset is None
@pytest.mark.asyncio
async def test_empty_filter_types_returns_immediately(self):
svc = _make_service(
prefs=_make_prefs(primary_types=[], secondary_types=[]),
)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 0
assert result.has_more is False
assert result.next_offset is None
svc._mb_repo.get_artist_release_groups.assert_not_awaited()
@pytest.mark.asyncio
async def test_all_types_filtered_out_except_one(self):
batch = (
[_make_release_group(f"rg-b{i}", f"Broadcast {i}", "Broadcast") for i in range(5)]
+ [_make_release_group("rg-album", "Found Album", "Album")]
)
svc = _make_service(
mb_release_pages=[
(batch, 6),
]
)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.returned_count == 1
assert result.albums[0].title == "Found Album"
assert result.source_total_count == 6
assert result.has_more is False
@pytest.mark.asyncio
async def test_global_sort_across_batches(self):
batch1 = [_make_release_group("rg-a", "Old Album", "Album", "2010-01-01")]
batch2 = [_make_release_group("rg-b", "New Album", "Album", "2020-01-01")]
svc = _make_service(
mb_release_pages=[
(batch1, 2),
(batch2, 2),
]
)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert len(result.albums) == 2
assert result.albums[0].title == "New Album"
assert result.albums[1].title == "Old Album"
@pytest.mark.asyncio
async def test_scan_batch_cap_stops_early(self):
batches = [([_make_release_group(f"rg-{i}", f"Album {i}", "Album")], 5000) for i in range(25)]
svc = _make_service(mb_release_pages=batches)
result = await svc.get_artist_releases(ARTIST_MBID, offset=0, limit=50)
assert result.has_more is True
assert result.next_offset is not None
assert result.next_offset == 2