/
/
/
1"""
2Regression tests for media-item URI resolution.
3
4Two robustness fixes are pinned here:
5
61. ``resolve_uri`` now translates Music Assistant's distinct error classes
7 to distinct ``ToolError`` messages. Previously every failure (typo,
8 provider offline, malformed URI) flattened to the same string, so the
9 LLM caller couldn't decide whether to retry, fix the URI, or surface
10 the outage to the user.
11
122. ``get_*_by_uri`` and ``get_lyrics`` now refuse wrong-type URIs
13 with a clean ``ToolError``. Previously ``to_brief_track`` would
14 happily coerce an album or playlist into a garbage ``TrackBrief``,
15 and ``get_lyrics`` would silently return ``None`` rather than
16 surface the type confusion.
17"""
18# mypy: disable-error-code="arg-type, no-untyped-def, type-arg, assignment, operator, misc, union-attr"
19
20from __future__ import annotations
21
22from typing import Any
23from unittest.mock import AsyncMock, MagicMock
24
25import pytest
26from fastmcp import Client, FastMCP
27from fastmcp.exceptions import ToolError
28from music_assistant_models.enums import MediaType
29from music_assistant_models.errors import (
30 InvalidProviderURI,
31 MediaNotFoundError,
32 ProviderUnavailableError,
33)
34
35from music_assistant.providers.fastmcp_server.tools._common import (
36 brief_from_uri,
37 resolve_typed_uri,
38 resolve_uri,
39 to_brief_track,
40)
41from music_assistant.providers.fastmcp_server.tools.metadata import build_metadata_server
42
43from .media_fakes import fake_media_item
44
45
46@pytest.fixture
47def metadata_server(mock_mass: Any) -> FastMCP:
48 """Mount only the metadata sub-server."""
49 mcp: FastMCP = FastMCP(name="t")
50 mcp.mount(build_metadata_server(mock_mass), namespace="metadata")
51 return mcp
52
53
54# ââ resolve_uri narrow-exception handling âââââââââââââââââââââââââââââââââââ
55
56
57class TestResolveUriNarrowsExceptions:
58 """Each MA error class maps to its own ToolError message."""
59
60 @pytest.mark.parametrize(
61 ("exc", "expected_fragment"),
62 [
63 (MediaNotFoundError("nope"), "not found"),
64 (InvalidProviderURI("bad uri"), "Malformed"),
65 (ProviderUnavailableError("offline"), "offline or unreachable"),
66 ],
67 )
68 async def test_each_error_class_distinct_message(
69 self, mock_mass: Any, exc: Exception, expected_fragment: str
70 ) -> None:
71 """The LLM caller sees a distinct, actionable message per failure class."""
72 mock_mass.music.get_item_by_uri = AsyncMock(side_effect=exc)
73 with pytest.raises(ToolError, match=expected_fragment):
74 await resolve_uri(mock_mass, "library://track/1")
75
76 async def test_unknown_exception_propagates(self, mock_mass: Any) -> None:
77 """Unrecognised errors are not silently flattened â they propagate."""
78 mock_mass.music.get_item_by_uri = AsyncMock(side_effect=RuntimeError("?!"))
79 with pytest.raises(RuntimeError, match=r"\?!"):
80 await resolve_uri(mock_mass, "library://track/1")
81
82
83# ââ resolve_typed_uri / brief_from_uri media-type assertion âââââââââââââââââ
84
85
86class TestResolveTypedUriRejectsWrongMediaType:
87 """Wrong-type URIs must raise with a hint for the resolved type."""
88
89 @pytest.mark.parametrize(
90 ("expected", "type_label", "wrong"),
91 [
92 (MediaType.TRACK, "track", MediaType.ALBUM),
93 (MediaType.ALBUM, "album", MediaType.TRACK),
94 (MediaType.ARTIST, "artist", MediaType.PLAYLIST),
95 (MediaType.PLAYLIST, "playlist", MediaType.RADIO),
96 (MediaType.RADIO, "radio", MediaType.TRACK),
97 ],
98 )
99 async def test_rejects_wrong_type_with_matching_hint(
100 self,
101 mock_mass: Any,
102 expected: MediaType,
103 type_label: str,
104 wrong: MediaType,
105 ) -> None:
106 """Hint names the tool for the resolved media type, not the caller's tool."""
107 mock_mass.music.get_item_by_uri = AsyncMock(
108 return_value=fake_media_item(wrong, uri=f"library://{wrong.value}/42")
109 )
110 with pytest.raises(ToolError, match=rf"is not an? {type_label}") as exc_info:
111 await resolve_typed_uri(
112 mock_mass,
113 f"library://{wrong.value}/42",
114 expected,
115 type_label=type_label,
116 )
117 assert wrong.value in str(exc_info.value).lower()
118
119 async def test_album_uri_on_track_tool_mentions_album_not_track(self, mock_mass: Any) -> None:
120 """Album URI passed where a track is expected suggests album tools."""
121 mock_mass.music.get_item_by_uri = AsyncMock(
122 return_value=fake_media_item(MediaType.ALBUM, uri="library://album/7")
123 )
124 with pytest.raises(ToolError, match="library_get_album_by_uri"):
125 await brief_from_uri(
126 mock_mass,
127 "library://album/7",
128 MediaType.TRACK,
129 to_brief=to_brief_track,
130 type_label="track",
131 )
132
133
134class TestGetByUriIntegration:
135 """Smoke-test MCP wiring for URI resolver tools."""
136
137 async def test_get_track_by_uri_happy_path(
138 self, library_server: FastMCP, mock_mass: Any
139 ) -> None:
140 """Track URI returns a brief via the mounted library server."""
141 uri = "library://track/1"
142 mock_mass.music.get_item_by_uri = AsyncMock(
143 return_value=fake_media_item(MediaType.TRACK, uri=uri)
144 )
145 async with Client(library_server) as client:
146 result = await client.call_tool("library_get_track_by_uri", {"uri": uri})
147 text_blocks = [c.text for c in result.content if hasattr(c, "text")]
148 assert any(uri in t for t in text_blocks)
149
150 async def test_get_album_by_uri_happy_path(
151 self, library_server: FastMCP, mock_mass: Any
152 ) -> None:
153 """Album URI returns a brief via the mounted library server."""
154 uri = "library://album/1"
155 mock_mass.music.get_item_by_uri = AsyncMock(
156 return_value=fake_media_item(MediaType.ALBUM, uri=uri, artist="Artist")
157 )
158 async with Client(library_server) as client:
159 result = await client.call_tool("library_get_album_by_uri", {"uri": uri})
160 text_blocks = [c.text for c in result.content if hasattr(c, "text")]
161 assert any(uri in t for t in text_blocks)
162
163
164class TestGetLyricsRejectsNonTracks:
165 """Lyrics are track-only; album/playlist URIs raise ``ToolError``."""
166
167 async def test_rejects_album_uri(self, metadata_server: FastMCP, mock_mass: Any) -> None:
168 """An album URI raises rather than returning ``None`` silently."""
169 mock_mass.music.get_item_by_uri = AsyncMock(
170 return_value=fake_media_item(MediaType.ALBUM, uri="library://album/7")
171 )
172 async with Client(metadata_server) as client:
173 with pytest.raises(ToolError, match="is not a track"):
174 await client.call_tool(
175 "metadata_get_lyrics",
176 {"track_uri": "library://album/7"},
177 )
178
179 async def test_returns_lyrics_for_real_track(
180 self, metadata_server: FastMCP, mock_mass: Any
181 ) -> None:
182 """A real track URI surfaces ``metadata.lyrics`` if present."""
183 metadata = MagicMock()
184 metadata.lyrics = "verse one\nverse two"
185 item = fake_media_item(MediaType.TRACK, metadata=metadata)
186 mock_mass.music.get_item_by_uri = AsyncMock(return_value=item)
187
188 async with Client(metadata_server) as client:
189 result = await client.call_tool(
190 "metadata_get_lyrics",
191 {"track_uri": "library://track/1"},
192 )
193 text_blocks = [c.text for c in result.content if hasattr(c, "text")]
194 assert any("verse one" in t for t in text_blocks)
195