forked from homeassistant-ai/ha-mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_tools_config_dashboards.py
More file actions
270 lines (229 loc) · 12.1 KB
/
Copy pathtest_tools_config_dashboards.py
File metadata and controls
270 lines (229 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Unit tests for the dashboard-resolver helpers in tools_config_dashboards.
The helpers under test (`_should_lazy_resolve`, `_resolve_dashboard`,
`_lazy_resolve_and_retry`) hold the substring-trigger contract and the
two-call-site resolver glue that the rest of the dual-accept identifier
design rests on. End-to-end tests would only catch a regression here on
the right HA-version axis; these unit tests pin the contract independent
of HA wording stability.
"""
import logging
from unittest.mock import AsyncMock, MagicMock
import pytest
from ha_mcp.tools.tools_config_dashboards import (
_LAZY_RESOLVE_TRIGGER,
_lazy_resolve_and_retry,
_resolve_dashboard,
_should_lazy_resolve,
)
# -----------------------------------------------------------------------------
# Fixtures / helpers
# -----------------------------------------------------------------------------
@pytest.fixture
def fake_client():
client = MagicMock()
client.send_websocket_message = AsyncMock()
return client
def _trigger_response(missing_id: str = "anything") -> dict:
"""Build the WS error envelope HA emits when ``lovelace/config`` is
called with an identifier it does not recognise. Includes the literal
trigger substring."""
return {
"success": False,
"error": {
"message": f"{_LAZY_RESOLVE_TRIGGER}: {missing_id}",
"code": "config_not_found",
},
}
def _success_response(payload: dict | None = None) -> dict:
return {"success": True, "result": payload or {"views": []}}
# -----------------------------------------------------------------------------
# _should_lazy_resolve — substring contract
# -----------------------------------------------------------------------------
class TestShouldLazyResolve:
"""The substring trigger is the only signal available at the tool
layer. Pin the contract so an HA-side wording change is caught here
rather than degrading to "lazy fallback never fires" silently."""
def test_exact_trigger_message(self):
assert _should_lazy_resolve(_LAZY_RESOLVE_TRIGGER) is True
def test_trigger_with_identifier_suffix(self):
# The HA emit form is f"Unknown config specified: {url_path}".
assert _should_lazy_resolve("Unknown config specified: my_dashboard") is True
def test_trigger_embedded_in_longer_message(self):
assert (
_should_lazy_resolve("Command failed: Unknown config specified: foo")
is True
)
def test_unrelated_message_does_not_match(self):
assert _should_lazy_resolve("Some other error") is False
assert _should_lazy_resolve("") is False
def test_legitimate_empty_dashboard_message_does_not_match(self):
# HA emits "No config found." for genuinely empty (un-initialised)
# dashboards — must NOT trigger a lazy retry, otherwise the
# caller's empty-state path is hidden.
assert _should_lazy_resolve("No config found.") is False
# -----------------------------------------------------------------------------
# _resolve_dashboard — registry lookup
# -----------------------------------------------------------------------------
class TestResolveDashboard:
"""``_resolve_dashboard`` returns ``(match, dashboards)``: the match
(or ``None``) plus the raw dashboards list (or ``None`` on
unexpected shape). The list is what enables list-call dedup at the
pre-resolver call site (issue #1085); test both arms here."""
async def test_match_by_url_path(self, fake_client):
dashboards_list = [
{"url_path": "my-dash", "id": "my_dash"},
{"url_path": "other", "id": "other_id"},
]
fake_client.send_websocket_message.return_value = {"result": dashboards_list}
match, dashboards = await _resolve_dashboard(fake_client, "my-dash")
assert match == {"url_path": "my-dash", "id": "my_dash"}
assert dashboards == dashboards_list
async def test_match_by_internal_id(self, fake_client):
dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
fake_client.send_websocket_message.return_value = {"result": dashboards_list}
match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
assert match == {"url_path": "my-dash", "id": "my_dash"}
assert dashboards == dashboards_list
async def test_response_as_bare_list(self, fake_client):
# Older HA versions / different response shapes return the list
# directly rather than wrapped in {"result": ...}.
dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
fake_client.send_websocket_message.return_value = dashboards_list
match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
assert match == {"url_path": "my-dash", "id": "my_dash"}
assert dashboards == dashboards_list
async def test_no_match_still_returns_dashboards_list(self, fake_client):
# When the identifier doesn't match any dashboard, ``match`` is
# None but ``dashboards`` still carries the fetched list — the
# fetch happened, only the match check failed.
dashboards_list = [{"url_path": "my-dash", "id": "my_dash"}]
fake_client.send_websocket_message.return_value = {"result": dashboards_list}
match, dashboards = await _resolve_dashboard(fake_client, "nonexistent")
assert match is None
assert dashboards == dashboards_list
async def test_malformed_shape_logs_warning_and_returns_none_pair(
self, fake_client, caplog
):
# Neither dict-with-result nor list — could be a future HA shape
# change or an error envelope. Must surface as a logger.warning,
# not silently degrade to "always no match". Both elements of
# the tuple are None so callers know the fetch failed and they
# can fall back to a fresh fetch instead of treating ``[]`` as
# an authoritative empty registry.
fake_client.send_websocket_message.return_value = "unexpected string"
with caplog.at_level(
logging.WARNING, logger="ha_mcp.tools.tools_config_dashboards"
):
match, dashboards = await _resolve_dashboard(fake_client, "anything")
assert match is None
assert dashboards is None
assert any("unexpected shape" in rec.message for rec in caplog.records), (
f"expected an 'unexpected shape' warning; got {caplog.records}"
)
async def test_missing_url_path_in_match_returns_none_match(self, fake_client):
# Malformed registry entry where the matching dashboard is
# missing one of the required fields. Match is None (skipped
# rather than forwarding empty strings to delete_dashboard) but
# the list itself is still returned.
dashboards_list = [{"id": "my_dash"}] # url_path missing entirely
fake_client.send_websocket_message.return_value = {"result": dashboards_list}
match, dashboards = await _resolve_dashboard(fake_client, "my_dash")
assert match is None
assert dashboards == dashboards_list
async def test_empty_id_in_match_returns_none_match(self, fake_client):
dashboards_list = [{"url_path": "my-dash", "id": ""}]
fake_client.send_websocket_message.return_value = {"result": dashboards_list}
match, dashboards = await _resolve_dashboard(fake_client, "my-dash")
assert match is None
assert dashboards == dashboards_list
# -----------------------------------------------------------------------------
# _lazy_resolve_and_retry — composition + no-op axes
# -----------------------------------------------------------------------------
class TestLazyResolveAndRetry:
async def test_success_response_short_circuits(self, fake_client):
ws_data = {"type": "lovelace/config", "url_path": "anything"}
response = _success_response()
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, "anything", ws_data, response
)
assert (new_url, new_response) == ("anything", response)
# No WS call — short-circuit must not pay the round-trip.
fake_client.send_websocket_message.assert_not_called()
async def test_empty_url_path_short_circuits(self, fake_client):
# Default-dashboard path: caller passes None for url_path.
ws_data = {"type": "lovelace/config"}
response = _trigger_response()
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, None, ws_data, response
)
assert new_url is None
assert new_response is response
fake_client.send_websocket_message.assert_not_called()
async def test_non_trigger_failure_short_circuits(self, fake_client):
# Failure response, but with a different error message. Must NOT
# invoke the resolver — that would surface a synthetic resolver
# error instead of the real HA error to the caller.
ws_data = {"type": "lovelace/config", "url_path": "x"}
response = {
"success": False,
"error": {"message": "permission denied"},
}
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, "x", ws_data, response
)
assert (new_url, new_response) == ("x", response)
fake_client.send_websocket_message.assert_not_called()
async def test_resolver_no_match_returns_original_response(self, fake_client):
# Trigger fired, resolver runs, but the registry has no match —
# original failure response wins so the caller's existing error
# path runs against the real HA error.
fake_client.send_websocket_message.side_effect = [
{"result": []}, # resolver: empty list, no match
]
ws_data = {"type": "lovelace/config", "url_path": "ghost"}
original = _trigger_response("ghost")
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, "ghost", ws_data, original
)
assert new_url == "ghost"
assert new_response is original
async def test_resolver_exception_falls_through(self, fake_client, caplog):
# Resolver raises (timeout, network blip). Must NOT escape; must
# log at WARNING and fall through to the original response so
# the caller's existing error path surfaces the real HA error.
fake_client.send_websocket_message.side_effect = ConnectionError("ws gone")
ws_data = {"type": "lovelace/config", "url_path": "x"}
original = _trigger_response("x")
with caplog.at_level(
logging.WARNING, logger="ha_mcp.tools.tools_config_dashboards"
):
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, "x", ws_data, original
)
assert (new_url, new_response) == ("x", original)
assert any("Lazy resolver failed" in rec.message for rec in caplog.records)
async def test_happy_path_resolves_and_retries(self, fake_client):
# Trigger fires, resolver finds the canonical url_path, retry
# succeeds with new url_path on the WS data dict.
fake_client.send_websocket_message.side_effect = [
{ # resolver
"result": [{"url_path": "my-dash", "id": "my_dash"}]
},
_success_response({"views": [{"cards": []}]}), # retry
]
ws_data = {"type": "lovelace/config", "url_path": "my_dash", "force": True}
original = _trigger_response("my_dash")
new_url, new_response = await _lazy_resolve_and_retry(
fake_client, "my_dash", ws_data, original
)
assert new_url == "my-dash"
assert new_response["success"] is True
# Caller's ws_data dict must NOT be mutated — the retry uses a
# shallow copy. Verify both the contract and that the retry call
# carried the canonical url_path.
assert ws_data["url_path"] == "my_dash", (
"_lazy_resolve_and_retry mutated the caller's ws_data dict"
)
retry_call = fake_client.send_websocket_message.call_args_list[1]
assert retry_call.args[0]["url_path"] == "my-dash"
assert retry_call.args[0]["type"] == "lovelace/config"