/
/
/
1"""Test NetEase Cloud Music two-method recommendations contract."""
2
3from __future__ import annotations
4
5from typing import Any
6from unittest.mock import AsyncMock, Mock
7
8import pytest
9
10from music_assistant.providers.neteasecloudmusic import NeteaseCloudMusicProvider
11
12_SONG = {
13 "id": 1001,
14 "name": "Song One",
15 "dt": 200000,
16 "ar": [{"id": 5, "name": "Artist"}],
17 "al": {"id": 7, "name": "Album", "picUrl": "https://p1.music.126.net/a.jpg"},
18}
19PERSONAL_FM_PAYLOAD = {"code": 200, "data": [{"song": _SONG}]}
20DAILY_PAYLOAD = {"code": 200, "data": {"dailySongs": [_SONG]}}
21USER_PLAYLIST_PAYLOAD = {
22 "code": 200,
23 "playlist": [
24 {"id": 2002, "name": "My Playlist", "coverImgUrl": "https://p1.music.126.net/p.jpg"}
25 ],
26}
27NEWSONG_PAYLOAD = {"code": 200, "result": [{"song": {**_SONG, "id": 1002}}]}
28PLAYLISTS_PAYLOAD = {
29 "code": 200,
30 "result": [{"id": 3003, "name": "Rec Playlist", "picUrl": "https://p1.music.126.net/r.jpg"}],
31}
32
33
34def _stub_client_get(provider: NeteaseCloudMusicProvider) -> AsyncMock:
35 """Attach a client.get stub that returns canned payloads keyed by path."""
36
37 async def _fake(path: str, **_kwargs: Any) -> dict[str, Any]:
38 return {
39 "/personal_fm": PERSONAL_FM_PAYLOAD,
40 "/recommend/songs": DAILY_PAYLOAD,
41 "/user/playlist": USER_PLAYLIST_PAYLOAD,
42 "/personalized/newsong": NEWSONG_PAYLOAD,
43 "/personalized": PLAYLISTS_PAYLOAD,
44 }[path]
45
46 mock = AsyncMock(side_effect=_fake)
47 provider._client = Mock(get=mock)
48 return mock
49
50
51def _install_cache_mocks(provider: NeteaseCloudMusicProvider) -> None:
52 """Make the recommendation payload cache treat every call as a miss."""
53 provider.mass.cache.get = AsyncMock(return_value=None) # type: ignore[method-assign]
54 provider.mass.cache.set = AsyncMock() # type: ignore[method-assign]
55
56
57@pytest.mark.asyncio
58async def test_get_recommendations_static_rows_without_backend_calls(
59 provider: NeteaseCloudMusicProvider,
60) -> None:
61 """get_recommendations returns all four row descriptors without any backend call."""
62 client_mock = _stub_client_get(provider)
63
64 result = await provider.get_recommendations()
65
66 assert client_mock.call_args_list == []
67 assert [folder.item_id for folder in result] == [
68 "recommended_radios",
69 "daily_songs",
70 "recommended_new_songs",
71 "recommended_playlists",
72 ]
73 assert all(not folder.items for folder in result)
74
75
76@pytest.mark.asyncio
77async def test_get_recommendation_items_radios(
78 provider: NeteaseCloudMusicProvider,
79) -> None:
80 """recommended_radios builds both dynamic playlists from its dedicated fetches only."""
81 _install_cache_mocks(provider)
82 client_mock = _stub_client_get(provider)
83
84 result = await provider.get_recommendation_items("recommended_radios")
85
86 called_paths = {call.args[0] for call in client_mock.call_args_list}
87 assert called_paths == {"/personal_fm", "/recommend/songs", "/user/playlist"}
88 assert [item.item_id for item in result] == [
89 "personal_fm_dynamic",
90 "heart_mode_dynamic:1001:2002",
91 ]
92
93
94@pytest.mark.asyncio
95async def test_get_recommendation_items_daily_songs(
96 provider: NeteaseCloudMusicProvider,
97) -> None:
98 """daily_songs fetches only /recommend/songs and returns the parsed tracks."""
99 _install_cache_mocks(provider)
100 client_mock = _stub_client_get(provider)
101
102 result = await provider.get_recommendation_items("daily_songs")
103
104 called_paths = [call.args[0] for call in client_mock.call_args_list]
105 assert called_paths == ["/recommend/songs"]
106 assert [item.item_id for item in result] == ["1001"]
107
108
109@pytest.mark.asyncio
110async def test_get_recommendation_items_new_songs(
111 provider: NeteaseCloudMusicProvider,
112) -> None:
113 """recommended_new_songs fetches only /personalized/newsong and returns the parsed tracks."""
114 _install_cache_mocks(provider)
115 client_mock = _stub_client_get(provider)
116
117 result = await provider.get_recommendation_items("recommended_new_songs")
118
119 called_paths = [call.args[0] for call in client_mock.call_args_list]
120 assert called_paths == ["/personalized/newsong"]
121 assert [item.item_id for item in result] == ["1002"]
122
123
124@pytest.mark.asyncio
125async def test_get_recommendation_items_playlists(
126 provider: NeteaseCloudMusicProvider,
127) -> None:
128 """recommended_playlists fetches only /personalized and returns the parsed playlists."""
129 _install_cache_mocks(provider)
130 client_mock = _stub_client_get(provider)
131
132 result = await provider.get_recommendation_items("recommended_playlists")
133
134 called_paths = [call.args[0] for call in client_mock.call_args_list]
135 assert called_paths == ["/personalized"]
136 assert [item.item_id for item in result] == ["3003"]
137
138
139@pytest.mark.asyncio
140async def test_get_recommendation_items_unknown_id_returns_empty(
141 provider: NeteaseCloudMusicProvider,
142) -> None:
143 """An unknown row item_id returns an empty result without any backend call."""
144 client_mock = _stub_client_get(provider)
145
146 result = await provider.get_recommendation_items("no_such_row")
147
148 assert client_mock.call_args_list == []
149 assert not result
150