Skip to content

Commit 3cb4fd7

Browse files
Patch76claude
andcommitted
test(tools_integrations): pin negative paths of 404-narrow + Path 4 non-not_found
Three new unit tests defend the idempotent contract's narrow against future regression: 1. test_simple_path_404_on_state_check_routes_to_already_deleted — pins the positive case of the Path 1 fallback-2 fix from 781e083: get_entity_state raises HomeAssistantAPIError(status_code=404) → classified as state_gone → registry-verify confirms absent → idempotent already_deleted return. Without this test, a future refactor that removes the 404 branch could silently break the never-existed-target idempotent contract. 2. test_simple_path_non_404_apierror_propagates — pins the negative case of the same narrow: HomeAssistantAPIError(status_code=500) on the state-check must propagate via the outer exception chain, NOT silently classify as state_gone. Defends against a regression that drops the ``if e.status_code != 404: raise`` guard. 3. test_remove_helpers_integrations_subentry_other_error_surfaces_service_call_failed — pins the Path 4 negative case: an HA error response with a code other than "not_found" (e.g. "permission_denied") must raise SERVICE_CALL_FAILED with the original error message preserved, not idempotent-succeed. Symmetric counterpart to test_remove_helpers_integrations_subentry_not_found_is_idempotent which pins the positive (idempotent) case. 74 unit tests pass across test_tools_integrations.py (52) and test_config_subentries_folded.py (22). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 23a19a3 commit 3cb4fd7

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

tests/src/unit/test_config_subentries_folded.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,32 @@ async def test_remove_helpers_integrations_subentry_not_found_is_idempotent(
526526
assert result["fallback_used"] == "already_deleted"
527527
assert result["subentry_id"] == "ghost-subentry"
528528
assert result["method"] == "config_subentry_delete"
529+
530+
531+
async def test_remove_helpers_integrations_subentry_other_error_surfaces_service_call_failed(
532+
mock_client,
533+
):
534+
"""Path 4 negative case: a non-not_found error code (e.g. HA returns
535+
a permission, validation, or unknown-error code) must NOT silently
536+
classify as idempotent success. SERVICE_CALL_FAILED is the expected
537+
surface, with the underlying error message preserved.
538+
"""
539+
mock_client.delete_config_subentry.return_value = {
540+
"success": False,
541+
"error": {
542+
"code": "permission_denied",
543+
"message": "Insufficient permissions to delete subentry",
544+
},
545+
}
546+
547+
with pytest.raises(ToolError) as exc_info:
548+
await IntegrationTools(mock_client).ha_remove_helpers_integrations(
549+
target="entry-1",
550+
helper_type="config_subentry",
551+
subentry_id="subentry-1",
552+
confirm=True,
553+
)
554+
555+
error_data = json.loads(str(exc_info.value))
556+
assert error_data["error"]["code"] == "SERVICE_CALL_FAILED"
557+
assert "Insufficient permissions" in error_data["error"]["message"]

tests/src/unit/test_tools_integrations.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,73 @@ async def test_simple_path_fallback_already_deleted(self, tools, mock_client):
328328
assert result["success"] is True
329329
assert result["fallback_used"] == "already_deleted"
330330

331+
async def test_simple_path_404_on_state_check_routes_to_already_deleted(
332+
self, tools, mock_client
333+
):
334+
"""Pin the Path 1 fallback-2 contract for never-existed targets:
335+
get_entity_state raising HomeAssistantAPIError(status_code=404)
336+
must classify as state_gone → registry-verify confirms absent →
337+
idempotent already_deleted return. Without the 404 catch in
338+
fallback-2 a never-existed target falls through to ENTITY_NOT_FOUND.
339+
"""
340+
# 3x registry retries return success=False → no unique_id
341+
# 1x direct-id delete returns success=False → falls through to
342+
# fallback-2
343+
# 1x verify-registry returns success=False → registry confirms absent
344+
mock_client.send_websocket_message.side_effect = (
345+
[{"success": False, "error": "Entity not found"}] * 3
346+
+ [{"success": False, "error": "Unable to find input_button_id"}]
347+
+ [{"success": False, "error": "Entity not found"}]
348+
)
349+
# State check raises 404 throughout — never-existed entity case
350+
mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
351+
"Entity not found", status_code=404
352+
)
353+
354+
result = await tools.ha_remove_helpers_integrations(
355+
target="never_existed_button",
356+
helper_type="input_button",
357+
confirm=True,
358+
wait=False,
359+
)
360+
assert result["success"] is True
361+
assert result["fallback_used"] == "already_deleted"
362+
363+
async def test_simple_path_non_404_apierror_propagates(
364+
self, tools, mock_client
365+
):
366+
"""Pin the Path 1 narrow: only status_code == 404 classifies as
367+
state_gone. Other APIError status codes (e.g. 500 server error,
368+
401 auth failure) must propagate via the outer exception chain
369+
rather than silently mis-classify as idempotent success.
370+
"""
371+
# 3x registry retries return success=False → no unique_id
372+
# 1x direct-id delete returns success=False → falls through
373+
mock_client.send_websocket_message.side_effect = (
374+
[{"success": False, "error": "Entity not found"}] * 3
375+
+ [{"success": False, "error": "Unable to find"}]
376+
)
377+
# State check raises a non-404 APIError — must propagate
378+
mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
379+
"Internal server error", status_code=500
380+
)
381+
382+
with pytest.raises(ToolError) as exc_info:
383+
await tools.ha_remove_helpers_integrations(
384+
target="server_error_button",
385+
helper_type="input_button",
386+
confirm=True,
387+
wait=False,
388+
)
389+
err = json.loads(str(exc_info.value))
390+
assert err["success"] is False
391+
# Outer chain converts the propagated APIError via
392+
# exception_to_structured_error — must NOT be classified as
393+
# already_deleted.
394+
assert err["error"]["code"] != "ENTITY_NOT_FOUND" or (
395+
"already_deleted" not in err.get("error", {}).get("message", "")
396+
)
397+
331398
async def test_simple_path_disabled_no_unique_id_surfaces_error(
332399
self, tools, mock_client
333400
):

0 commit comments

Comments
 (0)