/
/
/
1"""
2Tests for the add_to_queue MCP tool.
3
4Validates that:
51. Valid option values (add/next/play/replace/replace_next) are accepted and forwarded to MA.
62. Invalid option values raise a clean ToolError.
73. A successful add returns AddToQueueResult with item_id, uri, name, and option.
8"""
9
10from __future__ import annotations
11
12from types import SimpleNamespace
13from typing import Any
14from unittest.mock import AsyncMock, MagicMock
15
16import pytest
17from fastmcp import Client, FastMCP
18from fastmcp.exceptions import ToolError
19from music_assistant_models.enums import MediaType, QueueOption
20
21from music_assistant.providers.fastmcp_server.tools.queue import build_queue_server
22
23
24@pytest.fixture
25def mounted_queue_no_delete(mock_mass: Any) -> FastMCP:
26 """Queue server with delete:queue permission disabled."""
27 mcp: FastMCP = FastMCP(name="test")
28 mcp.mount(
29 build_queue_server(mock_mass, delete_queue_enabled=False, require_confirmation=False),
30 namespace="queue",
31 )
32 return mcp
33
34
35def _queue_item(*, item_id: str, uri: str, name: str) -> SimpleNamespace:
36 return SimpleNamespace(queue_item_id=item_id, uri=uri, name=name, media_item=None)
37
38
39def _mock_items_before_after(
40 mock_mass: MagicMock, *, before: list[SimpleNamespace], after: list[SimpleNamespace]
41) -> None:
42 mock_mass.player_queues.items = MagicMock(side_effect=[before, after])
43
44
45async def test_add_to_queue_accepts_valid_options(
46 mounted_queue: FastMCP, mock_mass: MagicMock
47) -> None:
48 """Each valid option value is accepted and forwarded to MA."""
49 uri = "spotify://track/1"
50 for opt in ("add", "next", "play", "replace", "replace_next"):
51 mock_mass.player_queues.play_media.reset_mock()
52 _mock_items_before_after(
53 mock_mass,
54 before=[],
55 after=[_queue_item(item_id=f"item-{opt}", uri=uri, name="Track One")],
56 )
57 async with Client(mounted_queue) as client:
58 result = await client.call_tool(
59 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "option": opt}
60 )
61 mock_mass.player_queues.play_media.assert_awaited_once_with(
62 "q1", uri, option=QueueOption(opt)
63 )
64 assert result.data.item_id == f"item-{opt}"
65 assert result.data.uri == uri
66 assert result.data.option == opt
67
68
69async def test_add_to_queue_rejects_invalid_option(mounted_queue: FastMCP) -> None:
70 """Invalid option raises ToolError with the list of valid options."""
71 async with Client(mounted_queue) as client:
72 with pytest.raises(ToolError, match="bogus") as exc_info:
73 await client.call_tool(
74 "queue_add_to_queue",
75 {"queue_id": "q1", "uri": "spotify://track/1", "option": "bogus"},
76 )
77 msg = str(exc_info.value)
78 assert "``add``" in msg
79 assert "``replace_next``" in msg
80
81
82async def test_add_to_queue_defaults_to_add(mounted_queue: FastMCP, mock_mass: MagicMock) -> None:
83 """Calling add_to_queue without option defaults to 'add'."""
84 uri = "spotify://track/1"
85 mock_mass.player_queues.play_media.reset_mock()
86 _mock_items_before_after(
87 mock_mass,
88 before=[],
89 after=[_queue_item(item_id="item-1", uri=uri, name="Track One")],
90 )
91 async with Client(mounted_queue) as client:
92 result = await client.call_tool("queue_add_to_queue", {"queue_id": "q1", "uri": uri})
93 mock_mass.player_queues.play_media.assert_awaited_once_with("q1", uri, option=QueueOption.ADD)
94 assert result.data.option == "add"
95 assert result.data.name == "Track One"
96
97
98async def test_add_to_queue_returns_ack_for_new_row(
99 mounted_queue: FastMCP, mock_mass: MagicMock
100) -> None:
101 """Returns the newly added row when the same uri already exists elsewhere."""
102 uri = "library://track/169"
103 _mock_items_before_after(
104 mock_mass,
105 before=[_queue_item(item_id="old-dup", uri=uri, name="If I Had $1000000")],
106 after=[
107 _queue_item(item_id="old-dup", uri=uri, name="If I Had $1000000"),
108 _queue_item(item_id="new-row", uri=uri, name="If I Had $1000000"),
109 ],
110 )
111 async with Client(mounted_queue) as client:
112 result = await client.call_tool(
113 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "option": "add"}
114 )
115 assert result.data.item_id == "new-row"
116 assert result.data.uri == uri
117 assert result.data.name == "If I Had $1000000"
118 assert result.data.option == "add"
119
120
121async def test_add_to_queue_raises_when_row_not_found(
122 mounted_queue: FastMCP, mock_mass: MagicMock
123) -> None:
124 """Surfaces ToolError when play_media succeeds but the new row cannot be located."""
125 mock_mass.player_queues.items = MagicMock(side_effect=[[], []])
126 async with Client(mounted_queue) as client:
127 with pytest.raises(ToolError, match="could not locate"):
128 await client.call_tool(
129 "queue_add_to_queue",
130 {"queue_id": "q1", "uri": "spotify://track/1", "option": "add"},
131 )
132
133
134async def test_add_to_queue_finds_album_expanded_track_by_id(
135 mounted_queue: FastMCP, mock_mass: MagicMock
136) -> None:
137 """Album URIs expand to track rows â detection uses id-diff, not input URI."""
138 album_uri = "library://album/42"
139 track_uri = "library://track/99"
140 _mock_items_before_after(
141 mock_mass,
142 before=[_queue_item(item_id="old-1", uri=track_uri, name="Existing")],
143 after=[
144 _queue_item(item_id="old-1", uri=track_uri, name="Existing"),
145 _queue_item(item_id="new-album-track", uri=track_uri, name="From Album"),
146 ],
147 )
148 async with Client(mounted_queue) as client:
149 result = await client.call_tool(
150 "queue_add_to_queue", {"queue_id": "q1", "uri": album_uri, "option": "add"}
151 )
152 assert result.data.item_id == "new-album-track"
153 assert result.data.uri == album_uri
154
155
156async def test_add_to_queue_uses_tail_window_for_long_queues(
157 mounted_queue: FastMCP, mock_mass: MagicMock
158) -> None:
159 """Queues longer than 500 items fetch the tail window to locate appended rows."""
160 uri = "spotify://track/1"
161 mock_mass.player_queues.get = MagicMock(
162 return_value=SimpleNamespace(items=501, current_index=0, queue_id="q1")
163 )
164 mock_mass.player_queues.items = MagicMock(
165 side_effect=[
166 [],
167 [_queue_item(item_id="tail-new", uri=uri, name="Tail Track")],
168 ]
169 )
170 async with Client(mounted_queue) as client:
171 result = await client.call_tool(
172 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "option": "add"}
173 )
174 assert result.data.item_id == "tail-new"
175 assert mock_mass.player_queues.items.call_args_list[0].kwargs["offset"] == 1
176 assert mock_mass.player_queues.items.call_args_list[1].kwargs["offset"] == 1
177
178
179async def test_add_to_queue_replace_requires_delete_permission(
180 mounted_queue_no_delete: FastMCP,
181) -> None:
182 """Replace and replace_next require delete:queue permission."""
183 async with Client(mounted_queue_no_delete) as client:
184 for opt in ("replace", "replace_next"):
185 with pytest.raises(ToolError, match="delete:queue"):
186 await client.call_tool(
187 "queue_add_to_queue",
188 {"queue_id": "q1", "uri": "spotify://track/1", "option": opt},
189 )
190
191
192def _mock_queue(
193 *, current_index: int | None = 0, index_in_buffer: int | None = None, items: int = 0
194) -> SimpleNamespace:
195 return SimpleNamespace(
196 queue_id="q1",
197 current_index=current_index,
198 index_in_buffer=index_in_buffer,
199 items=items,
200 )
201
202
203def _mock_track(*, uri: str = "spotify://track/1", name: str = "Track One") -> SimpleNamespace:
204 return SimpleNamespace(
205 uri=uri,
206 name=name,
207 available=True,
208 media_type=MediaType.TRACK,
209 provider="spotify",
210 item_id="1",
211 image=None,
212 duration=180,
213 )
214
215
216def _setup_index_add_mocks(
217 mock_mass: MagicMock,
218 *,
219 uri: str,
220 before: list[SimpleNamespace],
221 after: list[SimpleNamespace],
222 current_index: int = 1,
223 index_in_buffer: int | None = None,
224 items_count: int | None = None,
225) -> None:
226 mock_mass.player_queues.get.return_value = _mock_queue(
227 current_index=current_index,
228 index_in_buffer=index_in_buffer,
229 items=items_count if items_count is not None else len(before),
230 )
231 # Index path fetches the lookahead window before and after the load.
232 mock_mass.player_queues.items = MagicMock(side_effect=[before, after])
233 mock_mass.music.get_item_by_uri = AsyncMock(return_value=_mock_track(uri=uri))
234 mock_mass.player_queues._media_resolver._resolve_media_items = AsyncMock(
235 return_value=[_mock_track(uri=uri)]
236 )
237 mock_mass.player_queues.load = AsyncMock()
238
239
240async def test_add_to_queue_index_calls_load(mounted_queue: FastMCP, mock_mass: MagicMock) -> None:
241 """Index path inserts via load() without calling play_media."""
242 uri = "spotify://track/new"
243 before = [_queue_item(item_id=f"i{i}", uri=f"u{i}", name=f"n{i}") for i in range(5)]
244 after = [
245 *before[:3],
246 _queue_item(item_id="new-row", uri=uri, name="Track One"),
247 *before[3:],
248 ]
249 _setup_index_add_mocks(mock_mass, uri=uri, before=before, after=after, current_index=1)
250 async with Client(mounted_queue) as client:
251 result = await client.call_tool(
252 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "index": 3}
253 )
254 mock_mass.player_queues.play_media.assert_not_called()
255 mock_mass.player_queues.load.assert_awaited_once()
256 _, kwargs = mock_mass.player_queues.load.call_args
257 assert kwargs["insert_at_index"] == 3
258 assert kwargs["shuffle"] is False
259 assert result.data.index == 3
260 assert result.data.item_id == "new-row"
261
262
263async def test_add_to_queue_index_resolves_via_media_resolver(
264 mounted_queue: FastMCP, mock_mass: MagicMock
265) -> None:
266 """
267 Index path resolves media through the controller's MediaResolver.
268
269 Regression (issue #159): the private ``_resolve_media_items`` moved from
270 ``PlayerQueuesController`` onto an internal ``_media_resolver`` upstream, so
271 calling it on the controller directly raises ``AttributeError`` at runtime.
272 """
273 uri = "spotify://track/new"
274 before = [_queue_item(item_id=f"i{i}", uri=f"u{i}", name=f"n{i}") for i in range(5)]
275 after = [*before[:3], _queue_item(item_id="new-row", uri=uri, name="Track One"), *before[3:]]
276 mock_mass.player_queues.get.return_value = _mock_queue(current_index=1, items=len(before))
277 mock_mass.player_queues.items = MagicMock(side_effect=[before, after])
278 mock_mass.music.get_item_by_uri = AsyncMock(return_value=_mock_track(uri=uri))
279 # The resolver lives on the controller's MediaResolver, not the controller.
280 mock_mass.player_queues._media_resolver._resolve_media_items = AsyncMock(
281 return_value=[_mock_track(uri=uri)]
282 )
283 mock_mass.player_queues.load = AsyncMock()
284 async with Client(mounted_queue) as client:
285 await client.call_tool("queue_add_to_queue", {"queue_id": "q1", "uri": uri, "index": 3})
286 mock_mass.player_queues._media_resolver._resolve_media_items.assert_awaited_once()
287
288
289async def test_add_to_queue_index_rejects_past_current(
290 mounted_queue: FastMCP, mock_mass: MagicMock
291) -> None:
292 """Index before the next insertable position raises ToolError."""
293 mock_mass.player_queues.get.return_value = _mock_queue(current_index=2)
294 mock_mass.player_queues.items = MagicMock(
295 return_value=[_queue_item(item_id="i0", uri="u", name="n")] * 5
296 )
297 async with Client(mounted_queue) as client:
298 with pytest.raises(ToolError, match="insertable position"):
299 await client.call_tool(
300 "queue_add_to_queue",
301 {"queue_id": "q1", "uri": "spotify://track/1", "index": 1},
302 )
303
304
305async def test_add_to_queue_index_rejects_buffered(
306 mounted_queue: FastMCP, mock_mass: MagicMock
307) -> None:
308 """Index at or before the buffered row raises ToolError."""
309 mock_mass.player_queues.get.return_value = _mock_queue(current_index=1, index_in_buffer=2)
310 mock_mass.player_queues.items = MagicMock(
311 return_value=[_queue_item(item_id="i0", uri="u", name="n")] * 5
312 )
313 async with Client(mounted_queue) as client:
314 with pytest.raises(ToolError, match="index_in_buffer=2"):
315 await client.call_tool(
316 "queue_add_to_queue",
317 {"queue_id": "q1", "uri": "spotify://track/1", "index": 2},
318 )
319
320
321async def test_add_to_queue_index_rejects_replace_combo(
322 mounted_queue: FastMCP, mock_mass: MagicMock
323) -> None:
324 """replace/replace_next cannot be combined with index."""
325 mock_mass.player_queues.get.return_value = _mock_queue(current_index=0)
326 mock_mass.player_queues.items = MagicMock(
327 return_value=[_queue_item(item_id="i0", uri="u", name="n")] * 3
328 )
329 async with Client(mounted_queue) as client:
330 with pytest.raises(ToolError, match="cannot be combined"):
331 await client.call_tool(
332 "queue_add_to_queue",
333 {
334 "queue_id": "q1",
335 "uri": "spotify://track/1",
336 "index": 2,
337 "option": "replace",
338 },
339 )
340
341
342async def test_add_to_queue_index_returns_ack_with_index(
343 mounted_queue: FastMCP, mock_mass: MagicMock
344) -> None:
345 """Successful index insert returns AddToQueueResult with index set."""
346 uri = "spotify://track/new"
347 before = [_queue_item(item_id=f"i{i}", uri=f"u{i}", name=f"n{i}") for i in range(4)]
348 after = [
349 *before[:2],
350 _queue_item(item_id="inserted", uri=uri, name="Track One"),
351 *before[2:],
352 ]
353 _setup_index_add_mocks(mock_mass, uri=uri, before=before, after=after, current_index=0)
354 async with Client(mounted_queue) as client:
355 result = await client.call_tool(
356 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "index": 2}
357 )
358 assert result.data.index == 2
359 assert result.data.item_id == "inserted"
360 assert result.data.uri == uri
361
362
363async def test_add_to_queue_index_expands_album(
364 mounted_queue: FastMCP, mock_mass: MagicMock
365) -> None:
366 """Album URIs expand to multiple rows in a single load() call."""
367 album_uri = "library://album/203"
368 track_a = _mock_track(uri="library://track/206", name="One Week")
369 track_b = _mock_track(uri="library://track/207", name="It's All Been Done")
370 before = [_queue_item(item_id="playing", uri="library://track/93", name="Adrift")]
371 after = [
372 *before,
373 _queue_item(item_id="stunt-1", uri="library://track/206", name="One Week"),
374 _queue_item(item_id="stunt-2", uri="library://track/207", name="It's All Been Done"),
375 ]
376 mock_mass.player_queues.get.return_value = _mock_queue(current_index=0, items=len(before))
377 mock_mass.player_queues.items = MagicMock(side_effect=[before, after])
378 mock_mass.music.get_item_by_uri = AsyncMock(
379 return_value=_mock_track(uri=album_uri, name="Stunt")
380 )
381 mock_mass.player_queues._media_resolver._resolve_media_items = AsyncMock(
382 return_value=[track_a, track_b]
383 )
384 mock_mass.player_queues.load = AsyncMock()
385 async with Client(mounted_queue) as client:
386 result = await client.call_tool(
387 "queue_add_to_queue",
388 {"queue_id": "q1", "uri": album_uri, "index": 1},
389 )
390 queue_items = mock_mass.player_queues.load.call_args[0][1]
391 assert len(queue_items) == 2
392 assert result.data.item_id == "stunt-1"
393 assert result.data.index == 1
394
395
396async def test_add_to_queue_add_tracks_growing_tail_window(
397 mounted_queue: FastMCP, mock_mass: MagicMock
398) -> None:
399 """
400 option=add recomputes the tail window from the post-add total (regression: #1).
401
402 For a queue longer than MAX_QUEUE_ITEMS the appended row lands beyond the
403 pre-add window; the after-window offset must follow the new total or the
404 successful add is wrongly reported as 'could not locate the new row'.
405 """
406 uri = "spotify://track/1"
407 mock_mass.player_queues.get = MagicMock(
408 side_effect=[
409 SimpleNamespace(items=501, current_index=0, queue_id="q1"),
410 SimpleNamespace(items=502, current_index=0, queue_id="q1"),
411 ]
412 )
413 mock_mass.player_queues.items = MagicMock(
414 side_effect=[[], [_queue_item(item_id="tail-new", uri=uri, name="Tail Track")]]
415 )
416 async with Client(mounted_queue) as client:
417 result = await client.call_tool(
418 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "option": "add"}
419 )
420 assert result.data.item_id == "tail-new"
421 # before-window offset from total 501, after-window offset from total 502.
422 assert mock_mass.player_queues.items.call_args_list[0].kwargs["offset"] == 1
423 assert mock_mass.player_queues.items.call_args_list[1].kwargs["offset"] == 2
424
425
426async def test_add_to_queue_index_count_uses_queue_total_not_page(
427 mounted_queue: FastMCP, mock_mass: MagicMock
428) -> None:
429 """
430 A valid index past the 500-row page cap is accepted (regression: #2).
431
432 item_count must come from the queue's own row count, not len() of a capped
433 items() page, or inserts beyond index 500 are wrongly rejected.
434 """
435 uri = "spotify://track/new"
436 before = [_queue_item(item_id="i0", uri="u0", name="n0")]
437 after = [*before, _queue_item(item_id="deep-row", uri=uri, name="Deep Track")]
438 _setup_index_add_mocks(
439 mock_mass, uri=uri, before=before, after=after, current_index=0, items_count=1000
440 )
441 async with Client(mounted_queue) as client:
442 result = await client.call_tool(
443 "queue_add_to_queue", {"queue_id": "q1", "uri": uri, "index": 600}
444 )
445 assert result.data.index == 600
446 assert result.data.item_id == "deep-row"
447
448
449async def test_add_to_queue_index_rejects_invalid_option(
450 mounted_queue: FastMCP, mock_mass: MagicMock
451) -> None:
452 """An invalid option is rejected even when index is set (regression: #3)."""
453 mock_mass.player_queues.get.return_value = _mock_queue(current_index=0, items=5)
454 async with Client(mounted_queue) as client:
455 with pytest.raises(ToolError, match="Invalid option"):
456 await client.call_tool(
457 "queue_add_to_queue",
458 {"queue_id": "q1", "uri": "spotify://track/1", "option": "bogus", "index": 2},
459 )
460