music-assistant-server

5.9 KBPY
test_search_audiobooks.py
5.9 KB163 lines • python
1"""Tests for audiobook search routing."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, Mock
6
7import pytest
8from music_assistant_models.enums import MediaType
9
10from music_assistant.providers.yandex_music.provider import YandexMusicProvider
11
12from .conftest import use_real_create_task
13
14
15def _fake_album(*, album_id: int, title: str, meta_type: str | None, type_: str | None) -> Mock:
16    """Minimal Yandex Album stand-in sufficient for classify_album + parse_audiobook."""
17    album = Mock()
18    album.id = album_id
19    album.title = title
20    album.version = None
21    album.available = True
22    album.meta_type = meta_type
23    album.type = type_
24    album.artists = []
25    album.labels = []
26    album.description = None
27    album.short_description = None
28    album.content_warning = None
29    album.genre = None
30    album.release_date = None
31    album.cover_uri = None
32    album.og_image = None
33    album.listening_finished = None
34    album.track_count = None
35    return album
36
37
38def _fake_search_result(albums: list[Mock]) -> Mock:
39    """Build a search-result stub with an `albums.results` list and empty siblings."""
40    result = Mock()
41    result.tracks = None
42    result.artists = None
43    result.playlists = None
44    result.podcasts = None
45    result.albums = Mock()
46    result.albums.results = albums
47    return result
48
49
50@pytest.fixture
51def provider_mock() -> Mock:
52    """Return a provider mock with a stubbed api_client.search."""
53    provider = Mock(spec=YandexMusicProvider)
54    provider.domain = "yandex_music"
55    provider.instance_id = "yandex_music_instance"
56    provider.logger = Mock()
57    provider.client = AsyncMock()
58    # @use_cache decorator reads self.mass.cache — stub returning None (cache miss)
59    provider.mass = Mock()
60    provider.mass.cache = AsyncMock()
61    provider.mass.cache.get = AsyncMock(return_value=None)
62    provider.mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
63    provider.mass.cache.set = AsyncMock()
64    use_real_create_task(provider.mass)
65    return provider
66
67
68@pytest.mark.asyncio
69async def test_search_audiobook_only_filters_albums(provider_mock: Mock) -> None:
70    """Requesting AUDIOBOOK only routes audiobook albums and drops music ones."""
71    music = _fake_album(album_id=1, title="Plain Music", meta_type="music", type_="music")
72    book = _fake_album(album_id=2, title="Cool Book", meta_type="podcast", type_="audiobook")
73    provider_mock.client.search = AsyncMock(return_value=_fake_search_result([music, book]))
74
75    result = await YandexMusicProvider.search(
76        provider_mock, "query", [MediaType.AUDIOBOOK], limit=5
77    )
78
79    # Single Yandex API call with type_="album"
80    provider_mock.client.search.assert_awaited_once()
81    assert provider_mock.client.search.await_args.kwargs["search_type"] == "album"
82
83    assert [a.item_id for a in result.audiobooks] == ["2"]
84    assert list(result.albums) == []
85
86
87@pytest.mark.asyncio
88async def test_search_album_and_audiobook_split(provider_mock: Mock) -> None:
89    """Requesting both ALBUM and AUDIOBOOK splits the albums bucket cleanly."""
90    music = _fake_album(album_id=10, title="Music", meta_type="music", type_="music")
91    book = _fake_album(album_id=20, title="Book", meta_type="podcast", type_="audiobook")
92    podcast = _fake_album(album_id=30, title="Podcast", meta_type="podcast", type_="podcast")
93    provider_mock.client.search = AsyncMock(
94        return_value=_fake_search_result([music, book, podcast])
95    )
96
97    result = await YandexMusicProvider.search(
98        provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=5
99    )
100
101    assert [a.item_id for a in result.albums] == ["10"]
102    assert [a.item_id for a in result.audiobooks] == ["20"]
103
104
105@pytest.mark.asyncio
106async def test_search_audiobook_not_dropped_by_limit_when_music_dominates(
107    provider_mock: Mock,
108) -> None:
109    """
110    Limit applied per bucket after classification, not before.
111
112    Audiobooks tail-listed by Yandex must still appear when top ``limit``
113    results are music albums.
114    """
115    music_albums = [
116        _fake_album(album_id=i, title=f"Music {i}", meta_type="music", type_="music")
117        for i in range(5)
118    ]
119    tail_audiobook = _fake_album(
120        album_id=99, title="Tail Book", meta_type="podcast", type_="audiobook"
121    )
122    provider_mock.client.search = AsyncMock(
123        return_value=_fake_search_result([*music_albums, tail_audiobook])
124    )
125
126    result = await YandexMusicProvider.search(provider_mock, "q", [MediaType.AUDIOBOOK], limit=3)
127
128    # Even with only 3 results requested and 5 music albums ahead of it,
129    # the audiobook tail entry still lands in the audiobooks bucket.
130    assert [a.item_id for a in result.audiobooks] == ["99"]
131
132
133@pytest.mark.asyncio
134async def test_search_album_bucket_respects_limit_independently(
135    provider_mock: Mock,
136) -> None:
137    """Albums bucket is capped at ``limit`` regardless of audiobook count."""
138    albums = [
139        _fake_album(album_id=i, title=f"M{i}", meta_type="music", type_="music") for i in range(10)
140    ]
141    provider_mock.client.search = AsyncMock(return_value=_fake_search_result(albums))
142
143    result = await YandexMusicProvider.search(
144        provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=3
145    )
146
147    assert len(result.albums) == 3
148    assert list(result.audiobooks) == []
149
150
151@pytest.mark.asyncio
152async def test_search_albums_type_mapping_dedupe(provider_mock: Mock) -> None:
153    """ALBUM + AUDIOBOOK both map to Yandex 'album' — dedup keeps a single call type."""
154    provider_mock.client.search = AsyncMock(return_value=_fake_search_result([]))
155
156    await YandexMusicProvider.search(
157        provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=3
158    )
159
160    provider_mock.client.search.assert_awaited_once()
161    # both map to "album"; with dedup there's a single requested_type → search_type='album'
162    assert provider_mock.client.search.await_args.kwargs["search_type"] == "album"
163