/
/
/
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