/
/
/
1"""End-to-end tests for the CONFIG_READ tool group."""
2# ruff: noqa: D103, PLC0415
3# D103: test functions don't need docstrings.
4# PLC0415: mock reconfiguration inside test bodies requires deferred imports.
5
6from __future__ import annotations
7
8from typing import Any
9
10import pytest
11from fastmcp import Client
12from fastmcp.exceptions import ToolError
13
14
15async def _call(mcp: Any, name: str, **kwargs: Any) -> Any:
16 async with Client(mcp) as client:
17 return await client.call_tool(f"config_{name}", kwargs)
18
19
20async def test_get_provider_masks_secret(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
21 result = await _call(mounted_config, "get_provider", instance_id="yandex_music")
22 by_key = {v.key: v for v in result.data.values}
23 assert by_key["token"].value == "this_value_is_encrypted"
24 assert "real-secret" not in str(result.data)
25
26
27async def test_get_core_returns_values(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
28 result = await _call(mounted_config, "get_core", domain="webserver")
29 assert any(v.key == "log_level" for v in result.data.values)
30
31
32async def test_get_player_returns_values(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
33 result = await _call(mounted_config, "get_player", player_id="kitchen")
34 assert result.data.player_id == "kitchen"
35 assert any(v.key == "http_port" for v in result.data.values)
36
37
38async def test_get_entries_lists_editable(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
39 result = await _call(
40 mounted_config, "get_entries", target_type="provider", target_id="yandex_music"
41 )
42 keys = {e.key for e in result.data.entries}
43 assert {"log_level", "http_port", "token"} <= keys
44
45
46def test_entry_dump_accepts_labelless_entry() -> None:
47 """
48 A ConfigEntry whose ``label`` is None must dump without choking.
49
50 Upstream ``music_assistant_models`` widened ``ConfigEntry.label`` to
51 ``str | None``; ``_entry_dump`` passes the label straight through, so the
52 dump must preserve None rather than assume a string is always present.
53 Regression for the release-gate mypy failure under the newer models.
54 """
55 from music_assistant_models.config_entries import ConfigEntry
56 from music_assistant_models.enums import ConfigEntryType
57
58 from music_assistant.providers.fastmcp_server.tools.config import _entry_dump
59
60 # Set the label out-of-band so the test type checks whether the installed
61 # models type ``label`` as ``str`` or ``str | None`` â ``object.__setattr__``
62 # also bypasses the frozen dataclass.
63 entry = ConfigEntry(key="k", type=ConfigEntryType.STRING, label="x")
64 object.__setattr__(entry, "label", None)
65 dump = _entry_dump(entry, "current")
66
67 assert dump.label is None
68 assert dump.key == "k"
69
70
71def test_entry_dump_resolves_localized_label_and_description() -> None:
72 """
73 `_entry_dump` surfaces the localized label/description, not raw None.
74
75 Server-category entries set label/description to None and rely on the
76 strings.json translations resolved at serialization, so the dump must read
77 the resolved values rather than the raw (None) attributes â otherwise
78 `config_get_entries` returns null text for every server setting (regression
79 from the strings.json migration, flagged on the #4486 review).
80 """
81 from music_assistant_models.config_entries import ConfigEntry
82 from music_assistant_models.enums import ConfigEntryType
83 from music_assistant_models.translations import TRANSLATION_RESOLVER
84
85 from music_assistant.providers.fastmcp_server.tools.config import _entry_dump
86
87 resolved = {
88 "config_entries.require_auth.label": "Require authentication",
89 "config_entries.require_auth.description": "Require a bearer token.",
90 }
91
92 def _resolver(key: str, **_kwargs: Any) -> str | None:
93 return resolved.get(key)
94
95 entry = ConfigEntry(key="require_auth", type=ConfigEntryType.BOOLEAN)
96 assert entry.label is None # raw attribute is unset; text lives in translations
97 token = TRANSLATION_RESOLVER.set(_resolver)
98 try:
99 dump = _entry_dump(entry, True)
100 finally:
101 TRANSLATION_RESOLVER.reset(token)
102
103 assert dump.label == "Require authentication"
104 assert dump.description == "Require a bearer token."
105
106
107async def test_get_provider_unknown_raises(mounted_config: Any, mock_mass: Any) -> None:
108 from unittest.mock import AsyncMock
109
110 mock_mass.config.get_provider_config = AsyncMock(side_effect=KeyError("nope"))
111 with pytest.raises(ToolError, match="not found"):
112 await _call(mounted_config, "get_provider", instance_id="nope")
113
114
115async def test_get_dsp_returns_shape(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
116 result = await _call(mounted_config, "get_dsp", player_id="kitchen")
117 assert result.data.player_id == "kitchen"
118 assert result.data.enabled is True
119 assert result.data.filters == []
120
121
122async def test_list_targets_rolls_up(mounted_config: Any, mock_config_targets: Any) -> None: # noqa: ARG001
123 result = await _call(mounted_config, "list_targets")
124 assert len(result.data.providers) >= 1
125 assert len(result.data.core) >= 1
126 assert len(result.data.players) >= 1
127
128
129async def test_get_dsp_unknown_player_raises(mounted_config: Any, mock_mass: Any) -> None:
130 from unittest.mock import AsyncMock
131
132 mock_mass.config.get_player_config = AsyncMock(side_effect=KeyError("nope"))
133 with pytest.raises(ToolError, match="not found"):
134 await _call(mounted_config, "get_dsp", player_id="nope")
135
136
137async def test_get_entries_masks_secret_current_value(
138 mounted_config: Any,
139 mock_config_targets: Any, # noqa: ARG001
140) -> None:
141 """
142 config_get_entries must mask SECURE_STRING current_value.
143
144 Regression for PR #99 review finding B.
145 """
146 from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE
147
148 result = await _call(
149 mounted_config, "get_entries", target_type="provider", target_id="yandex_music"
150 )
151 by_key = {e.key: e for e in result.data.entries}
152 assert by_key["token"].type == "secure_string"
153 assert by_key["token"].current_value == SECURE_STRING_SUBSTITUTE
154 # the raw fixture secret value must not leak
155 assert "raw-secret-xyz" not in str(result.data)
156