@@ -242,6 +242,29 @@ async def test_existing_operations_call_shape_remains_supported() -> None:
242242 )
243243
244244
245+ @pytest .mark .asyncio
246+ async def test_operations_mode_single_entity_skips_states_fetch () -> None :
247+ """A single distinct entity_id can never contain a group/member
248+ conflict -- there's no second row to overlap with -- so the
249+ group-safety check must not fetch states for it at all. The
250+ overwhelming majority of operations-mode calls target one entity (or
251+ several rows for the SAME entity) and never touch a group, and
252+ shouldn't pay for a states round-trip this check can never use.
253+ """
254+ device_tools = MagicMock ()
255+ device_tools .bulk_device_control = AsyncMock (return_value = {"success" : True })
256+ client = MagicMock ()
257+ client .get_states = AsyncMock (return_value = [])
258+ tools = ServiceTools (client , device_tools )
259+
260+ result = await tools .ha_bulk_control (
261+ [{"entity_id" : "light.one" , "action" : "off" }], False
262+ )
263+
264+ assert result == {"success" : True }
265+ client .get_states .assert_not_awaited ()
266+
267+
245268def _light_state (entity_id : str , members : list [str ] | None = None ) -> dict :
246269 """A minimal HA state dict; ``members`` sets the group-membership
247270 attribute Home Assistant (and ha_mcp.utils.entity_membership) reads as
@@ -396,14 +419,119 @@ async def test_operations_mode_rejects_opposing_action_group_member_conflict() -
396419 device_tools .bulk_device_control .assert_not_awaited ()
397420
398421
422+ @pytest .mark .asyncio
423+ async def test_operations_mode_rejects_nested_group_conflict () -> None :
424+ """A conflict must be caught through a NESTED aggregate, not just a
425+ direct member -- an outer Zone group containing an inner Room group,
426+ with the batch targeting the outer group and a leaf that only belongs
427+ to the inner one. Checking only ``light.outer``'s own direct
428+ ``entity_id`` attribute (``["light.inner"]``) would miss this: the
429+ outer group's own service call still cascades through the inner group
430+ down to the leaf, exactly as unsafe as targeting the inner group and
431+ that leaf directly.
432+ """
433+ device_tools = MagicMock ()
434+ device_tools .bulk_device_control = AsyncMock (return_value = {"success" : True })
435+ client = MagicMock ()
436+ client .get_states = AsyncMock (
437+ return_value = [
438+ _light_state ("light.outer" , ["light.inner" ]),
439+ _light_state ("light.inner" , ["light.leaf_a" , "light.leaf_b" ]),
440+ _light_state ("light.leaf_a" ),
441+ _light_state ("light.leaf_b" ),
442+ ]
443+ )
444+ tools = ServiceTools (client , device_tools )
445+ operations = [
446+ {"entity_id" : "light.outer" , "action" : "off" },
447+ {"entity_id" : "light.leaf_a" , "action" : "off" },
448+ ]
449+
450+ with pytest .raises (ToolError , match = "group/aggregate entity" ):
451+ await tools .ha_bulk_control (operations , False )
452+
453+ device_tools .bulk_device_control .assert_not_awaited ()
454+
455+
456+ @pytest .mark .asyncio
457+ async def test_operations_mode_allows_unrelated_nested_groups () -> None :
458+ """Two independent group hierarchies with no shared membership must
459+ not cross-contaminate -- the transitive walk for one group must not
460+ leak into flagging an entity that only belongs to a completely
461+ different group's tree.
462+ """
463+ device_tools = MagicMock ()
464+ device_tools .bulk_device_control = AsyncMock (return_value = {"success" : True })
465+ client = MagicMock ()
466+ client .get_states = AsyncMock (
467+ return_value = [
468+ _light_state ("light.outer_a" , ["light.inner_a" ]),
469+ _light_state ("light.inner_a" , ["light.leaf_a" ]),
470+ _light_state ("light.leaf_a" ),
471+ _light_state ("light.outer_b" , ["light.inner_b" ]),
472+ _light_state ("light.inner_b" , ["light.leaf_b" ]),
473+ _light_state ("light.leaf_b" ),
474+ ]
475+ )
476+ tools = ServiceTools (client , device_tools )
477+ operations = [
478+ {"entity_id" : "light.outer_a" , "action" : "off" },
479+ {"entity_id" : "light.leaf_b" , "action" : "off" },
480+ ]
481+
482+ result = await tools .ha_bulk_control (operations , False )
483+
484+ assert result == {"success" : True }
485+ device_tools .bulk_device_control .assert_awaited_once_with (
486+ operations = operations , parallel = False , ctx = None
487+ )
488+
489+
490+ @pytest .mark .asyncio
491+ async def test_operations_mode_cyclic_membership_does_not_hang () -> None :
492+ """A membership cycle (A lists B as a member, B lists A back) must not
493+ hang or crash the group-safety check -- unlike ``bulk_selector``'s own
494+ selector-mode expansion (which is resolving an authoritative dispatch
495+ set and is right to raise on a cycle), this is a best-effort safety
496+ scan over one batch and should degrade gracefully instead.
497+ """
498+ device_tools = MagicMock ()
499+ device_tools .bulk_device_control = AsyncMock (return_value = {"success" : True })
500+ client = MagicMock ()
501+ client .get_states = AsyncMock (
502+ return_value = [
503+ _light_state ("light.a" , ["light.b" ]),
504+ _light_state ("light.b" , ["light.a" ]),
505+ _light_state ("light.c" ),
506+ ]
507+ )
508+ tools = ServiceTools (client , device_tools )
509+ operations = [
510+ {"entity_id" : "light.a" , "action" : "off" },
511+ {"entity_id" : "light.c" , "action" : "off" },
512+ ]
513+
514+ result = await tools .ha_bulk_control (operations , False )
515+
516+ assert result == {"success" : True }
517+ device_tools .bulk_device_control .assert_awaited_once_with (
518+ operations = operations , parallel = False , ctx = None
519+ )
520+
521+
399522@pytest .mark .asyncio
400523async def test_operations_mode_group_safety_check_fails_closed_on_states_error () -> (
401524 None
402525):
403526 """A states-fetch failure during the group-safety check must not
404527 silently skip the check and dispatch anyway: an unverifiable batch is
405528 not a verified-safe one, matching the fail-closed stance
406- bulk_selector.py takes for the analogous selector-mode read."""
529+ bulk_selector.py takes for the analogous selector-mode read.
530+
531+ Two distinct entities: a single-entity batch short-circuits before the
532+ states fetch (see test_operations_mode_single_entity_skips_states_fetch),
533+ so it would never reach this code path.
534+ """
407535 device_tools = MagicMock ()
408536 device_tools .bulk_device_control = AsyncMock (return_value = {"success" : True })
409537 client = MagicMock ()
@@ -412,7 +540,11 @@ async def test_operations_mode_group_safety_check_fails_closed_on_states_error()
412540
413541 with pytest .raises (ToolError ):
414542 await tools .ha_bulk_control (
415- [{"entity_id" : "light.one" , "action" : "off" }], False
543+ [
544+ {"entity_id" : "light.one" , "action" : "off" },
545+ {"entity_id" : "light.two" , "action" : "off" },
546+ ],
547+ False ,
416548 )
417549
418550 device_tools .bulk_device_control .assert_not_awaited ()
@@ -433,7 +565,11 @@ async def test_operations_mode_group_safety_check_fails_closed_on_malformed_stat
433565
434566 with pytest .raises (ToolError , match = "Home Assistant" ):
435567 await tools .ha_bulk_control (
436- [{"entity_id" : "light.one" , "action" : "off" }], False
568+ [
569+ {"entity_id" : "light.one" , "action" : "off" },
570+ {"entity_id" : "light.two" , "action" : "off" },
571+ ],
572+ False ,
437573 )
438574
439575 device_tools .bulk_device_control .assert_not_awaited ()
@@ -463,7 +599,11 @@ async def test_operations_mode_group_safety_check_propagates_tool_error_unchange
463599
464600 with pytest .raises (ToolError ) as exc_info :
465601 await tools .ha_bulk_control (
466- [{"entity_id" : "light.one" , "action" : "off" }], False
602+ [
603+ {"entity_id" : "light.one" , "action" : "off" },
604+ {"entity_id" : "light.two" , "action" : "off" },
605+ ],
606+ False ,
467607 )
468608
469609 assert str (exc_info .value ) == str (original )
0 commit comments