325 lines
10 KiB
Python
325 lines
10 KiB
Python
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
import asyncio
|
|
|
|
from api.v1.schemas.search import SearchResult, SuggestResponse
|
|
from services.search_service import SearchService
|
|
|
|
|
|
def _make_search_result(
|
|
type: str,
|
|
title: str,
|
|
score: int = 0,
|
|
musicbrainz_id: str = "",
|
|
artist: str | None = None,
|
|
year: int | None = None,
|
|
disambiguation: str | None = None,
|
|
) -> SearchResult:
|
|
return SearchResult(
|
|
type=type,
|
|
title=title,
|
|
musicbrainz_id=musicbrainz_id or f"mbid-{title.lower().replace(' ', '-')}",
|
|
score=score,
|
|
artist=artist,
|
|
year=year,
|
|
disambiguation=disambiguation,
|
|
in_library=False,
|
|
requested=False,
|
|
)
|
|
|
|
|
|
def _make_preferences(secondary_types: list[str] | None = None) -> MagicMock:
|
|
prefs = MagicMock()
|
|
prefs.secondary_types = secondary_types or []
|
|
return prefs
|
|
|
|
|
|
def _make_service(
|
|
grouped: dict[str, list[SearchResult]] | None = None,
|
|
library_mbids: set[str] | None = None,
|
|
queue_items: list | None = None,
|
|
mb_error: Exception | None = None,
|
|
lidarr_library_error: Exception | None = None,
|
|
lidarr_queue_error: Exception | None = None,
|
|
) -> SearchService:
|
|
mb_repo = MagicMock()
|
|
if mb_error:
|
|
mb_repo.search_grouped = AsyncMock(side_effect=mb_error)
|
|
else:
|
|
mb_repo.search_grouped = AsyncMock(return_value=grouped or {"artists": [], "albums": []})
|
|
|
|
lidarr_repo = MagicMock()
|
|
if lidarr_library_error:
|
|
lidarr_repo.get_library_mbids = AsyncMock(side_effect=lidarr_library_error)
|
|
else:
|
|
lidarr_repo.get_library_mbids = AsyncMock(return_value=library_mbids or set())
|
|
|
|
if lidarr_queue_error:
|
|
lidarr_repo.get_queue = AsyncMock(side_effect=lidarr_queue_error)
|
|
else:
|
|
lidarr_repo.get_queue = AsyncMock(return_value=queue_items or [])
|
|
|
|
coverart_repo = MagicMock()
|
|
preferences_service = MagicMock()
|
|
preferences_service.get_preferences.return_value = _make_preferences()
|
|
|
|
return SearchService(
|
|
mb_repo=mb_repo,
|
|
lidarr_repo=lidarr_repo,
|
|
coverart_repo=coverart_repo,
|
|
preferences_service=preferences_service,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_returns_suggest_response():
|
|
artists = [_make_search_result("artist", "Muse", score=90)]
|
|
albums = [_make_search_result("album", "Origin of Symmetry", score=85, artist="Muse")]
|
|
svc = _make_service(grouped={"artists": artists, "albums": albums})
|
|
|
|
result = await svc.suggest(query="muse", limit=5)
|
|
|
|
assert isinstance(result, SuggestResponse)
|
|
assert len(result.results) == 2
|
|
assert result.results[0].title == "Muse"
|
|
assert result.results[1].title == "Origin of Symmetry"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_score_interleaving():
|
|
artists = [
|
|
_make_search_result("artist", "Artist A", score=90),
|
|
_make_search_result("artist", "Artist B", score=80),
|
|
]
|
|
albums = [
|
|
_make_search_result("album", "Album X", score=95, artist="X"),
|
|
_make_search_result("album", "Album Y", score=85, artist="Y"),
|
|
]
|
|
svc = _make_service(grouped={"artists": artists, "albums": albums})
|
|
|
|
result = await svc.suggest(query="test", limit=5)
|
|
|
|
assert len(result.results) == 4
|
|
assert result.results[0].title == "Album X"
|
|
assert result.results[0].score == 95
|
|
assert result.results[1].title == "Artist A"
|
|
assert result.results[1].score == 90
|
|
assert result.results[2].title == "Album Y"
|
|
assert result.results[2].score == 85
|
|
assert result.results[3].title == "Artist B"
|
|
assert result.results[3].score == 80
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_equal_score_artist_before_album():
|
|
artists = [_make_search_result("artist", "Bee", score=80)]
|
|
albums = [_make_search_result("album", "Ant", score=80, artist="Someone")]
|
|
svc = _make_service(grouped={"artists": artists, "albums": albums})
|
|
|
|
result = await svc.suggest(query="test", limit=5)
|
|
|
|
assert len(result.results) == 2
|
|
assert result.results[0].type == "artist"
|
|
assert result.results[0].title == "Bee"
|
|
assert result.results[1].type == "album"
|
|
assert result.results[1].title == "Ant"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_alphabetical_tiebreak_within_same_type():
|
|
artists = [
|
|
_make_search_result("artist", "Zebra", score=80),
|
|
_make_search_result("artist", "Alpha", score=80),
|
|
]
|
|
svc = _make_service(grouped={"artists": artists, "albums": []})
|
|
|
|
result = await svc.suggest(query="test", limit=5)
|
|
|
|
assert len(result.results) == 2
|
|
assert result.results[0].title == "Alpha"
|
|
assert result.results[1].title == "Zebra"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_truncates_to_limit():
|
|
artists = [
|
|
_make_search_result("artist", f"Artist {i}", score=100 - i)
|
|
for i in range(3)
|
|
]
|
|
albums = [
|
|
_make_search_result("album", f"Album {i}", score=99 - i, artist="X")
|
|
for i in range(3)
|
|
]
|
|
svc = _make_service(grouped={"artists": artists, "albums": albums})
|
|
|
|
result = await svc.suggest(query="test", limit=4)
|
|
|
|
assert len(result.results) == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_lidarr_failure_returns_default_flags():
|
|
artists = [_make_search_result("artist", "Muse", score=90)]
|
|
albums = [
|
|
_make_search_result("album", "Absolution", score=85, artist="Muse",
|
|
musicbrainz_id="album-1"),
|
|
]
|
|
svc = _make_service(
|
|
grouped={"artists": artists, "albums": albums},
|
|
lidarr_library_error=Exception("Lidarr unavailable"),
|
|
lidarr_queue_error=Exception("Lidarr unavailable"),
|
|
)
|
|
|
|
result = await svc.suggest(query="muse", limit=5)
|
|
|
|
assert len(result.results) == 2
|
|
for r in result.results:
|
|
assert r.in_library is False
|
|
assert r.requested is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_musicbrainz_failure_returns_empty():
|
|
svc = _make_service(mb_error=Exception("MusicBrainz down"))
|
|
|
|
result = await svc.suggest(query="muse", limit=5)
|
|
|
|
assert isinstance(result, SuggestResponse)
|
|
assert len(result.results) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_query_normalization():
|
|
artists = [_make_search_result("artist", "Muse", score=90)]
|
|
svc = _make_service(grouped={"artists": artists, "albums": []})
|
|
|
|
result = await svc.suggest(query=" muse ", limit=5)
|
|
|
|
assert len(result.results) == 1
|
|
assert result.results[0].title == "Muse"
|
|
svc._mb_repo.search_grouped.assert_called_once()
|
|
call_args = svc._mb_repo.search_grouped.call_args
|
|
assert call_args[0][0] == "muse"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_in_library_flag():
|
|
albums = [
|
|
_make_search_result("album", "Absolution", score=85, artist="Muse",
|
|
musicbrainz_id="album-lib-1"),
|
|
]
|
|
svc = _make_service(
|
|
grouped={"artists": [], "albums": albums},
|
|
library_mbids={"album-lib-1"},
|
|
)
|
|
|
|
result = await svc.suggest(query="absolution", limit=5)
|
|
|
|
assert len(result.results) == 1
|
|
assert result.results[0].in_library is True
|
|
assert result.results[0].requested is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_requested_flag():
|
|
albums = [
|
|
_make_search_result("album", "Absolution", score=85, artist="Muse",
|
|
musicbrainz_id="album-q-1"),
|
|
]
|
|
queue_item = MagicMock()
|
|
queue_item.musicbrainz_id = "album-q-1"
|
|
svc = _make_service(
|
|
grouped={"artists": [], "albums": albums},
|
|
queue_items=[queue_item],
|
|
)
|
|
|
|
result = await svc.suggest(query="absolution", limit=5)
|
|
|
|
assert len(result.results) == 1
|
|
assert result.results[0].in_library is False
|
|
assert result.results[0].requested is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_whitespace_only_query_returns_empty():
|
|
"""Whitespace-padded query that becomes too short after strip returns empty."""
|
|
svc = _make_service(grouped={"artists": [], "albums": []})
|
|
|
|
result = await svc.suggest(query=" a ", limit=5)
|
|
|
|
assert isinstance(result, SuggestResponse)
|
|
assert len(result.results) == 0
|
|
svc._mb_repo.search_grouped.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_single_char_after_strip_returns_empty():
|
|
"""Single-char query after stripping returns empty without calling MusicBrainz."""
|
|
svc = _make_service(grouped={"artists": [], "albums": []})
|
|
|
|
result = await svc.suggest(query="x", limit=5)
|
|
|
|
assert isinstance(result, SuggestResponse)
|
|
assert len(result.results) == 0
|
|
svc._mb_repo.search_grouped.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_case_insensitive_alphabetical_tiebreak():
|
|
"""Alphabetical tiebreak is case-insensitive: 'alpha' before 'Bravo'."""
|
|
artists = [
|
|
_make_search_result("artist", "Bravo", score=80),
|
|
_make_search_result("artist", "alpha", score=80),
|
|
]
|
|
svc = _make_service(grouped={"artists": artists, "albums": []})
|
|
|
|
result = await svc.suggest(query="test", limit=5)
|
|
|
|
assert len(result.results) == 2
|
|
assert result.results[0].title == "alpha"
|
|
assert result.results[1].title == "Bravo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suggest_deduplication_single_mb_call():
|
|
"""Concurrent suggest calls with same normalized query produce only one MusicBrainz call."""
|
|
artists = [_make_search_result("artist", "Muse", score=90)]
|
|
|
|
call_event = asyncio.Event()
|
|
call_count = 0
|
|
|
|
async def slow_search_grouped(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
await call_event.wait()
|
|
return {"artists": artists, "albums": []}
|
|
|
|
mb_repo = MagicMock()
|
|
mb_repo.search_grouped = slow_search_grouped
|
|
|
|
lidarr_repo = MagicMock()
|
|
lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
|
|
lidarr_repo.get_queue = AsyncMock(return_value=[])
|
|
|
|
coverart_repo = MagicMock()
|
|
preferences_service = MagicMock()
|
|
preferences_service.get_preferences.return_value = _make_preferences()
|
|
|
|
svc = SearchService(
|
|
mb_repo=mb_repo,
|
|
lidarr_repo=lidarr_repo,
|
|
coverart_repo=coverart_repo,
|
|
preferences_service=preferences_service,
|
|
)
|
|
|
|
task1 = asyncio.create_task(svc.suggest(query="muse", limit=5))
|
|
task2 = asyncio.create_task(svc.suggest(query="muse", limit=5))
|
|
await asyncio.sleep(0.05)
|
|
call_event.set()
|
|
|
|
r1, r2 = await asyncio.gather(task1, task2)
|
|
|
|
assert call_count == 1
|
|
assert len(r1.results) == 1
|
|
assert len(r2.results) == 1
|