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