| # | |
| # Copyright (c) 2025 Project CHIP Authors | |
| # All rights reserved. | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """ | |
| Unit tests for commissioning status detection functions in commissioning.py. | |
| Tests cover: | |
| - DNS-SD discovery (_is_device_operational_via_dnssd) | |
| - Parallel session establishment (_establish_pase_or_case_session) | |
| - is_commissioned() integration scenarios | |
| - get_commissioned_fabric_count() integration scenarios | |
| Scenarios include factory fresh devices, devices commissioned on the current fabric, | |
| and devices commissioned on other fabrics. | |
| """ | |
| import asyncio | |
| import contextlib | |
| import sys | |
| from dataclasses import dataclass | |
| from unittest.mock import AsyncMock, MagicMock, patch | |
| from python_path import PythonPath | |
| # Add the python_testing directory to path so mdns_discovery module can be found | |
| with PythonPath('..', relative_to=__file__): | |
| import mdns_discovery.mdns_discovery # noqa: F401 | |
| # Test constants | |
| TEST_NODE_ID = 1234 | |
| TEST_COMPRESSED_FABRIC_ID = 0x1234567890ABCDEF | |
| TEST_ENDPOINT = 0 | |
| TEST_DISCRIMINATOR = 3840 | |
| TEST_PASSCODE = 20202021 | |
| @dataclass | |
| class MockMdnsServiceInfo: | |
| """Mock MdnsServiceInfo for testing DNS-SD discovery.""" | |
| instance_name: str | |
| class MockDeviceController: | |
| """Mock ChipDeviceController for testing.""" | |
| def __init__(self, compressed_fabric_id: int = TEST_COMPRESSED_FABRIC_ID): | |
| self._compressed_fabric_id = compressed_fabric_id | |
| # Mock methods that will be configured per-test | |
| self.EstablishPASESession = AsyncMock() | |
| self.GetConnectedDevice = AsyncMock() | |
| self.ReadAttribute = AsyncMock() | |
| self.CreateManualCode = MagicMock(return_value="MT:YNJV7VSC00KA0648G00") | |
| self.FindOrEstablishPASESession = AsyncMock() | |
| def GetCompressedFabricId(self) -> int: | |
| return self._compressed_fabric_id | |
| def build_expected_instance_name(compressed_fabric_id: int, node_id: int) -> str: | |
| """Build the expected DNS-SD instance name for a device on a fabric.""" | |
| return f'{compressed_fabric_id:016X}-{node_id:016X}' | |
| def create_mock_attribute_result(fabric_count: int): | |
| """Create a mock ReadAttribute result with CommissionedFabrics == fabric_count. | |
| OperationalCredentials is node-scoped and always on endpoint 0. | |
| """ | |
| try: | |
| import matter.clusters as Clusters | |
| return { | |
| 0: { | |
| Clusters.OperationalCredentials: { | |
| Clusters.OperationalCredentials.Attributes.CommissionedFabrics: fabric_count | |
| } | |
| } | |
| } | |
| except ImportError: | |
| mock_opcreds = MagicMock() | |
| mock_opcreds_attrs = MagicMock() | |
| mock_opcreds_attrs.CommissionedFabrics = fabric_count | |
| return { | |
| 0: { | |
| mock_opcreds: { | |
| mock_opcreds_attrs: fabric_count | |
| } | |
| } | |
| } | |
| @contextlib.contextmanager | |
| def mock_mdns_module(): | |
| """Context manager to inject a mock mdns_discovery module into sys.modules. | |
| This is necessary because the real mdns_discovery depends on zeroconf, | |
| which may not be installed in unit test environments. By injecting a mock | |
| module, the `from mdns_discovery.mdns_discovery import MdnsDiscovery` | |
| inside _is_device_operational_via_dnssd resolves correctly. | |
| """ | |
| mock_mdns_mod = MagicMock() | |
| mock_parent = MagicMock() | |
| mock_parent.mdns_discovery = mock_mdns_mod | |
| saved_parent = sys.modules.get('mdns_discovery') | |
| saved_child = sys.modules.get('mdns_discovery.mdns_discovery') | |
| sys.modules['mdns_discovery'] = mock_parent | |
| sys.modules['mdns_discovery.mdns_discovery'] = mock_mdns_mod | |
| try: | |
| yield mock_mdns_mod | |
| finally: | |
| # Restore original modules | |
| if saved_parent is not None: | |
| sys.modules['mdns_discovery'] = saved_parent | |
| else: | |
| sys.modules.pop('mdns_discovery', None) | |
| if saved_child is not None: | |
| sys.modules['mdns_discovery.mdns_discovery'] = saved_child | |
| else: | |
| sys.modules.pop('mdns_discovery.mdns_discovery', None) | |
| # ============================================================================= | |
| # CATEGORY A: DNS-SD Discovery Tests | |
| # ============================================================================= | |
| async def test_dnssd_device_found_on_this_fabric(): | |
| """ | |
| Test: Device found operational on THIS fabric via DNS-SD. | |
| Scenario: Device is commissioned to this fabric and advertising. | |
| Expected: Returns True - device is operational on our fabric. | |
| Value: This is the fast path. When DNS-SD finds the device, we know | |
| immediately it's commissioned to this fabric without needing CASE/PASE. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| expected_instance_name = build_expected_instance_name(TEST_COMPRESSED_FABRIC_ID, TEST_NODE_ID) | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| mock_mdns.get_operational_services = AsyncMock(return_value=[ | |
| MockMdnsServiceInfo(instance_name=expected_instance_name) | |
| ]) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| result = await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| if not result: | |
| return "Expected True when device is operational on this fabric" | |
| return None | |
| async def test_dnssd_device_on_different_fabric(): | |
| """ | |
| Test: Device advertising on different fabric (Scenario 3). | |
| Scenario: Device is commissioned to another fabric but not ours. | |
| Expected: Returns False - device not operational on OUR fabric. | |
| Value: Verifies we don't falsely detect devices on other fabrics as ours. | |
| The fabric ID in the instance name must match our compressed fabric ID. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| # Device advertising on different fabric (different compressed fabric ID) | |
| mock_mdns.get_operational_services = AsyncMock(return_value=[ | |
| MockMdnsServiceInfo(instance_name="AAAAAAAAAAAAAAAA-00000000000004D2") # Different fabric, same node | |
| ]) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| result = await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| if result: | |
| return "Expected False when device is on different fabric" | |
| return None | |
| async def test_dnssd_no_services_found(): | |
| """ | |
| Test: No operational services found via DNS-SD. | |
| Scenario: Factory fresh device or device not advertising. | |
| Expected: Returns False - no operational advertisements found. | |
| Value: Verifies correct behavior when DNS-SD finds nothing, | |
| which triggers the parallel PASE/CASE fallback. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| mock_mdns.get_operational_services = AsyncMock(return_value=[]) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| result = await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| if result: | |
| return "Expected False when no operational services found" | |
| return None | |
| async def test_dnssd_exception_handling(): | |
| """ | |
| Test: DNS-SD discovery raises a generic Exception. | |
| Scenario: Network error, timeout, or other DNS-SD failure not mapped to the | |
| handled types in _is_device_operational_via_dnssd. | |
| Expected: Exception propagates (only OSError/ValueError/RuntimeError/TypeError/ChipStackError are swallowed). | |
| Value: Callers see unexpected failures explicitly instead of a silent False. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| mock_mdns.get_operational_services = AsyncMock(side_effect=Exception("DNS-SD network error")) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| try: | |
| await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| return "Expected generic Exception from DNS-SD to propagate" | |
| except Exception as e: | |
| if "DNS-SD network error" not in str(e): | |
| return f"Unexpected exception message: {e}" | |
| return None | |
| async def test_dnssd_import_error_propagates(): | |
| """ | |
| Test: ImportError propagates when mdns_discovery is unavailable. | |
| Scenario: Running in environment without mdns_discovery installed. | |
| Expected: ImportError propagates (NOT silently caught). | |
| Value: Ensures that a missing mdns_discovery module causes a clear, | |
| loud failure instead of silently degrading to a slower path. | |
| The module is always expected to be available in test environments. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| import builtins | |
| # Simulate ImportError by intercepting the built-in import function | |
| original_import = builtins.__import__ | |
| def mock_import(name, *args, **kwargs): | |
| if name == 'mdns_discovery' or name == 'mdns_discovery.mdns_discovery': | |
| raise ModuleNotFoundError(f"No module named '{name}'") | |
| return original_import(name, *args, **kwargs) | |
| # Pop them from sys.modules first so the mock is invoked | |
| saved_mod = sys.modules.pop('mdns_discovery', None) | |
| saved_submod = sys.modules.pop('mdns_discovery.mdns_discovery', None) | |
| try: | |
| with patch('builtins.__import__', side_effect=mock_import): | |
| try: | |
| await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| return "Expected ImportError to propagate when mdns_discovery module not available" | |
| except (ImportError, ModuleNotFoundError): | |
| pass # Expected - ImportError should now propagate | |
| finally: | |
| if saved_mod: | |
| sys.modules['mdns_discovery'] = saved_mod | |
| if saved_submod: | |
| sys.modules['mdns_discovery.mdns_discovery'] = saved_submod | |
| return None | |
| async def test_dnssd_multiple_services_finds_correct_one(): | |
| """ | |
| Test: Multiple services found, one matches our fabric+node. | |
| Scenario: Network has multiple Matter devices, we find ours. | |
| Expected: Returns True when our specific device is in the list. | |
| Value: Verifies correct filtering when multiple devices advertise. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| expected_instance_name = build_expected_instance_name(TEST_COMPRESSED_FABRIC_ID, TEST_NODE_ID) | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| mock_mdns.get_operational_services = AsyncMock(return_value=[ | |
| MockMdnsServiceInfo(instance_name="OTHERFABRICID00000-0000000000005678"), | |
| MockMdnsServiceInfo(instance_name=expected_instance_name), # Our device | |
| MockMdnsServiceInfo(instance_name="ANOTHERFABRIC0000-0000000000009999"), | |
| ]) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| result = await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| if not result: | |
| return "Expected True when this fabric's service is in the list" | |
| return None | |
| async def test_dnssd_same_fabric_different_node(): | |
| """ | |
| Test: Service for same fabric but different node ID. | |
| Scenario: Another device on our fabric, but not the one we're looking for. | |
| Expected: Returns False - wrong node ID. | |
| Value: Verifies both fabric AND node ID must match. | |
| """ | |
| from matter.testing.commissioning import _is_device_operational_via_dnssd | |
| mock_controller = MockDeviceController() | |
| # Same fabric, different node | |
| different_node_instance = build_expected_instance_name(TEST_COMPRESSED_FABRIC_ID, 9999) | |
| with mock_mdns_module() as mock_mdns_mod: | |
| mock_mdns = MagicMock() | |
| mock_mdns.get_operational_services = AsyncMock(return_value=[ | |
| MockMdnsServiceInfo(instance_name=different_node_instance) | |
| ]) | |
| mock_mdns_mod.MdnsDiscovery.return_value = mock_mdns | |
| result = await _is_device_operational_via_dnssd(mock_controller, TEST_NODE_ID) | |
| if result: | |
| return "Expected False when service is for different node ID" | |
| return None | |
| # ============================================================================= | |
| # CATEGORY B: Parallel Session Establishment Tests | |
| # ============================================================================= | |
| async def test_parallel_session_pase_wins(): | |
| """ | |
| Test: PASE completes before CASE in parallel attempt. | |
| Scenario: Factory fresh device - PASE succeeds, CASE fails. | |
| Expected: Session established via PASE, CASE cancelled. | |
| Value: Verifies PASE path works for uncommissioned devices. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| # PASE succeeds immediately | |
| async def pase_success(*args, **kwargs): | |
| return None | |
| # CASE fails after a delay (simulating real network behavior) | |
| async def case_fail_slow(*args, **kwargs): | |
| await asyncio.sleep(0.1) # Delay so PASE wins the race | |
| raise RuntimeError("CASE failed - no fabric") | |
| mock_controller.FindOrEstablishPASESession = pase_success | |
| mock_controller.GetConnectedDevice = case_fail_slow | |
| pase_params = commissioning.PaseParams( | |
| setup_code="MT:YNJV7VSC00KA0648G00", passcode=TEST_PASSCODE, discriminator=TEST_DISCRIMINATOR) | |
| try: | |
| session_kind = await commissioning._establish_pase_or_case_session(mock_controller, TEST_NODE_ID, pase_params) | |
| if session_kind != commissioning.EstablishedSessionKind.PASE: | |
| return f"Expected PASE session, got {session_kind}" | |
| except Exception as e: | |
| return f"Should have succeeded via PASE: {e}" | |
| return None | |
| async def test_parallel_session_case_wins(): | |
| """ | |
| Test: CASE completes before PASE in parallel attempt. | |
| Scenario: Device commissioned to this fabric, CASE faster than PASE. | |
| Expected: Session established via CASE, PASE cancelled. | |
| Value: Verifies CASE path works when device is on our fabric. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| # PASE is slow (will be cancelled) | |
| async def pase_slow(*args, **kwargs): | |
| await asyncio.sleep(10) # Will be cancelled when CASE succeeds | |
| return | |
| # CASE succeeds immediately | |
| async def case_success(*args, **kwargs): | |
| return MagicMock() | |
| mock_controller.FindOrEstablishPASESession = pase_slow | |
| mock_controller.GetConnectedDevice = case_success | |
| pase_params = commissioning.PaseParams( | |
| setup_code="MT:YNJV7VSC00KA0648G00", passcode=TEST_PASSCODE, discriminator=TEST_DISCRIMINATOR) | |
| try: | |
| session_kind = await commissioning._establish_pase_or_case_session(mock_controller, TEST_NODE_ID, pase_params) | |
| if session_kind != commissioning.EstablishedSessionKind.CASE: | |
| return f"Expected CASE session, got {session_kind}" | |
| except Exception as e: | |
| return f"Should have succeeded via CASE: {e}" | |
| return None | |
| async def test_parallel_session_first_fails_second_succeeds(): | |
| """ | |
| Test: First connection attempt fails, second succeeds. | |
| Scenario: CASE fails quickly, PASE succeeds after. | |
| Expected: Session established via PASE after CASE failure. | |
| Value: Verifies fallback to second attempt when first fails. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| # Track call order | |
| call_order = [] | |
| async def pase_success(*args, **kwargs): | |
| call_order.append('pase') | |
| await asyncio.sleep(0.1) # Slight delay | |
| return | |
| async def case_fail(*args, **kwargs): | |
| call_order.append('case') | |
| raise RuntimeError("CASE failed immediately") | |
| mock_controller.FindOrEstablishPASESession = pase_success | |
| mock_controller.GetConnectedDevice = case_fail | |
| pase_params = commissioning.PaseParams( | |
| setup_code="MT:YNJV7VSC00KA0648G00", passcode=TEST_PASSCODE, discriminator=TEST_DISCRIMINATOR) | |
| try: | |
| session_kind = await commissioning._establish_pase_or_case_session(mock_controller, TEST_NODE_ID, pase_params) | |
| if session_kind != commissioning.EstablishedSessionKind.PASE: | |
| return f"Expected PASE after CASE failed first, got {session_kind}" | |
| except Exception as e: | |
| return f"Should have succeeded via PASE fallback: {e}" | |
| return None | |
| async def test_parallel_session_both_fail(): | |
| """ | |
| Test: Both PASE and CASE connection attempts fail. | |
| Scenario: Device unreachable or invalid credentials. | |
| Expected: RuntimeError raised with both error messages. | |
| Value: Verifies proper error handling when device can't be reached. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| # First task fails quickly, second task fails after delay | |
| # This ensures there's a pending task when first one fails | |
| async def pase_fail_fast(*args, **kwargs): | |
| raise RuntimeError("PASE failed") | |
| async def case_fail_slow(*args, **kwargs): | |
| await asyncio.sleep(0.1) # Delay so PASE fails first | |
| raise RuntimeError("CASE failed") | |
| mock_controller.FindOrEstablishPASESession = pase_fail_fast | |
| mock_controller.GetConnectedDevice = case_fail_slow | |
| pase_params = commissioning.PaseParams( | |
| setup_code="MT:YNJV7VSC00KA0648G00", passcode=TEST_PASSCODE, discriminator=TEST_DISCRIMINATOR) | |
| try: | |
| await commissioning._establish_pase_or_case_session(mock_controller, TEST_NODE_ID, pase_params) | |
| return "Should have raised RuntimeError when both fail" | |
| except RuntimeError as e: | |
| error_msg = str(e) | |
| if "PASE" not in error_msg or "CASE" not in error_msg: | |
| return f"Error should mention both PASE and CASE failures: {e}" | |
| except Exception as e: | |
| return f"Expected RuntimeError, got {type(e).__name__}: {e}" | |
| return None | |
| async def test_parallel_session_no_pase_params(): | |
| """ | |
| Test: No PASE params provided, only CASE attempted. | |
| Scenario: Caller doesn't have commissioning credentials. | |
| Expected: Only CASE is attempted (may succeed or fail). | |
| Value: Verifies graceful handling when PASE not possible. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| case_called = False | |
| async def case_success(*args, **kwargs): | |
| nonlocal case_called | |
| case_called = True | |
| return MagicMock() | |
| mock_controller.GetConnectedDevice = case_success | |
| try: | |
| session_kind = await commissioning._establish_pase_or_case_session(mock_controller, TEST_NODE_ID, None) | |
| if session_kind != commissioning.EstablishedSessionKind.CASE: | |
| return f"CASE-only path should yield CASE, got {session_kind}" | |
| if not case_called: | |
| return "CASE should have been attempted" | |
| except Exception as e: | |
| return f"Should succeed with CASE-only: {e}" | |
| return None | |
| # ============================================================================= | |
| # CATEGORY C: is_commissioned() Tests | |
| # ============================================================================= | |
| async def test_is_commissioned_on_current_fabric_scenario2_operational_shortcircuit(): | |
| """ | |
| Test: Device commissioned on THIS fabric. | |
| Behavior: DNS-SD finds device operational -> return True immediately. | |
| No PASE/CASE needed since DNS-SD confirms commissioning. | |
| Value: This is the FAST PATH optimization. Avoids connection overhead | |
| when DNS-SD already confirms the device is on our fabric. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_dnssd: | |
| mock_dnssd.return_value = True | |
| result = await commissioning.is_commissioned(mock_controller, TEST_NODE_ID) | |
| if not result: | |
| return "Expected True when DNS-SD confirms operational" | |
| if not mock_dnssd.called: | |
| return "DNS-SD check should have been called" | |
| return None | |
| async def test_is_commissioned_on_current_fabric_scenario1_factory_fresh(): | |
| """ | |
| Test: SCENARIO 1 - Device is commissionable (factory fresh, pairing window open). | |
| Behavior: | |
| - DNS-SD operational returns False (not on this fabric) | |
| - DNS-SD commissionable returns True (device advertising _matterc._udp) | |
| - is_commissioned returns False immediately (fast-fail) | |
| Value: Factory-fresh devices are detected without side-effects (no PASE session opened). | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_op, \ | |
| patch.object(commissioning, '_is_device_commissionable_via_dnssd', new_callable=AsyncMock) as mock_comm: | |
| mock_op.return_value = False | |
| mock_comm.return_value = True | |
| result = await commissioning.is_commissioned(mock_controller, TEST_NODE_ID) | |
| if result: | |
| return "Expected False when device is commissionable (factory fresh)" | |
| if not mock_comm.called: | |
| return "Commissionable DNS-SD check should have been called" | |
| return None | |
| async def test_is_commissioned_neither_dnssd_conclusive(): | |
| """ | |
| Test: Neither DNS-SD check is conclusive (device off, broken, or on another fabric). | |
| Behavior: | |
| - DNS-SD operational returns False | |
| - DNS-SD commissionable returns False | |
| - is_commissioned returns False (device not reachable) | |
| Value: No side-effects when device is unreachable. No PASE or CASE attempted. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_op, \ | |
| patch.object(commissioning, '_is_device_commissionable_via_dnssd', new_callable=AsyncMock) as mock_comm: | |
| mock_op.return_value = False | |
| mock_comm.return_value = False | |
| result = await commissioning.is_commissioned(mock_controller, TEST_NODE_ID) | |
| if result: | |
| return "Expected False when neither DNS-SD check is conclusive" | |
| return None | |
| async def test_is_commissioned_exception_propagation(): | |
| """ | |
| Test: Exception from DNS-SD operational check propagates correctly. | |
| Value: Ensures callers see infrastructure failures. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_dnssd: | |
| mock_dnssd.side_effect = RuntimeError("DNS-SD infrastructure failure") | |
| try: | |
| await commissioning.is_commissioned(mock_controller, TEST_NODE_ID) | |
| return "Expected RuntimeError to propagate" | |
| except RuntimeError as e: | |
| if "DNS-SD infrastructure failure" not in str(e): | |
| return f"Unexpected error message: {e}" | |
| return None | |
| # ============================================================================= | |
| # CATEGORY D: get_commissioned_fabric_count() Tests | |
| # ============================================================================= | |
| async def test_get_fabric_count_scenario2_operational(): | |
| """ | |
| Test: SCENARIO 2 - Device operational on this fabric, get count via CASE. | |
| Value: Verifies correct count retrieval when device is on our fabric. | |
| """ | |
| import matter.clusters as Clusters | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_dnssd: | |
| mock_dnssd.return_value = True | |
| # Fabric count is len(TrustedRootCertificates); use two placeholder roots. | |
| mock_controller.ReadAttribute = AsyncMock(return_value={ | |
| TEST_ENDPOINT: { | |
| Clusters.OperationalCredentials: { | |
| Clusters.OperationalCredentials.Attributes.TrustedRootCertificates: [b'root_a', b'root_b'] | |
| } | |
| } | |
| }) | |
| result = await commissioning.get_commissioned_fabric_count(mock_controller, TEST_NODE_ID) | |
| if result != 2: | |
| return f"Expected fabric count 2, got {result}" | |
| return None | |
| async def test_get_fabric_count_scenario1_factory_fresh(): | |
| """ | |
| Test: SCENARIO 1 - Factory fresh device, should return 0. | |
| Value: Verifies 0 count for uncommissioned devices. | |
| """ | |
| import matter.clusters as Clusters | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_dnssd, \ | |
| patch.object(commissioning, '_establish_pase_or_case_session', new_callable=AsyncMock) as mock_parallel: | |
| mock_dnssd.return_value = False | |
| mock_parallel.return_value = commissioning.EstablishedSessionKind.PASE | |
| mock_controller.ReadAttribute = AsyncMock(return_value={ | |
| TEST_ENDPOINT: { | |
| Clusters.OperationalCredentials: { | |
| Clusters.OperationalCredentials.Attributes.TrustedRootCertificates: [] | |
| } | |
| } | |
| }) | |
| pase_params = commissioning.PaseParams( | |
| setup_code="MT:YNJV7VSC00KA0648G00", passcode=TEST_PASSCODE, discriminator=TEST_DISCRIMINATOR) | |
| result = await commissioning.get_commissioned_fabric_count( | |
| mock_controller, TEST_NODE_ID, pase_params=pase_params | |
| ) | |
| if result != 0: | |
| return f"Expected fabric count 0 for factory fresh, got {result}" | |
| return None | |
| async def test_get_fabric_count_not_operational_no_pase_params(): | |
| """ | |
| Test: Device not operational via DNS-SD and no PASE params provided. | |
| Value: Ensures ValueError is raised to prevent timeout. | |
| """ | |
| from matter.testing import commissioning | |
| mock_controller = MockDeviceController() | |
| with patch.object(commissioning, '_is_device_operational_via_dnssd', new_callable=AsyncMock) as mock_dnssd: | |
| mock_dnssd.return_value = False | |
| try: | |
| await commissioning.get_commissioned_fabric_count(mock_controller, TEST_NODE_ID, pase_params=None) | |
| return "Expected ValueError when not operational and no PASE params" | |
| except ValueError as e: | |
| if "not operational on this fabric" not in str(e): | |
| return f"Unexpected error message: {e}" | |
| return None | |
| # ============================================================================= | |
| # TEST RUNNER | |
| # ============================================================================= | |
| def main(): | |
| """Run all tests and report results.""" | |
| tests = [ | |
| # Category A: DNS-SD Discovery Tests | |
| ("A1. DNS-SD: device found on THIS fabric", test_dnssd_device_found_on_this_fabric), | |
| ("A2. DNS-SD: device on DIFFERENT fabric", test_dnssd_device_on_different_fabric), | |
| ("A3. DNS-SD: no services found", test_dnssd_no_services_found), | |
| ("A4. DNS-SD: exception handling", test_dnssd_exception_handling), | |
| ("A5. DNS-SD: import error propagates", test_dnssd_import_error_propagates), | |
| ("A6. DNS-SD: multiple services finds correct one", test_dnssd_multiple_services_finds_correct_one), | |
| ("A7. DNS-SD: same fabric different node", test_dnssd_same_fabric_different_node), | |
| # Category B: Parallel Session Establishment Tests | |
| ("B1. Parallel: PASE wins race", test_parallel_session_pase_wins), | |
| ("B2. Parallel: CASE wins race", test_parallel_session_case_wins), | |
| ("B3. Parallel: first fails, second succeeds", test_parallel_session_first_fails_second_succeeds), | |
| ("B4. Parallel: both fail", test_parallel_session_both_fail), | |
| ("B5. Parallel: no PASE params (CASE only)", test_parallel_session_no_pase_params), | |
| # Category C: is_commissioned() Tests | |
| ("C1. is_commissioned: operational shortcircuit", | |
| test_is_commissioned_on_current_fabric_scenario2_operational_shortcircuit), | |
| ("C2. is_commissioned: commissionable fast-fail (factory fresh)", | |
| test_is_commissioned_on_current_fabric_scenario1_factory_fresh), | |
| ("C3. is_commissioned: neither DNS-SD conclusive", | |
| test_is_commissioned_neither_dnssd_conclusive), | |
| ("C4. is_commissioned: exception propagation", | |
| test_is_commissioned_exception_propagation), | |
| # Category D: get_commissioned_fabric_count() Tests | |
| ("D1. get_fabric_count: SCENARIO 2 - operational", test_get_fabric_count_scenario2_operational), | |
| ("D2. get_fabric_count: SCENARIO 1 - factory fresh", test_get_fabric_count_scenario1_factory_fresh), | |
| ("D3. get_fabric_count: not operational, no PASE params", test_get_fabric_count_not_operational_no_pase_params), | |
| ] | |
| print("\n" + "=" * 70) | |
| print("COMMISSIONING STATUS DETECTION - UNIT TESTS") | |
| print("=" * 70) | |
| failures = [] | |
| passed = 0 | |
| loop = asyncio.new_event_loop() | |
| asyncio.set_event_loop(loop) | |
| for test_name, test_func in tests: | |
| try: | |
| error = loop.run_until_complete(test_func()) | |
| if error: | |
| failures.append(f"{test_name}: {error}") | |
| print(f" FAIL: {test_name}") | |
| print(f" {error}") | |
| else: | |
| print(f" PASS: {test_name}") | |
| passed += 1 | |
| except Exception as e: | |
| failures.append(f"{test_name}: Exception - {e}") | |
| print(f" CRASH: {test_name}") | |
| print(f" {type(e).__name__}: {e}") | |
| loop.close() | |
| # Summary | |
| print("\n" + "-" * 70) | |
| print(f"SUMMARY: {passed}/{len(tests)} tests passed, {len(failures)} failures") | |
| print("-" * 70) | |
| if failures: | |
| print("\nFailed tests:") | |
| for f in failures: | |
| print(f" - {f}") | |
| return 1 if failures else 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |