"""Test the Portainer initial specific behavior.""" from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from pyportainer.exceptions import ( PortainerAuthenticationError, PortainerConnectionError, PortainerTimeoutError, ) from pyportainer.models.docker import DockerContainer, EndpointStatus from pyportainer.models.portainer import Endpoint from pyportainer.models.stacks import Stack import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_API_TOKEN, CONF_HOST, CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import setup_integration from .conftest import MOCK_TEST_CONFIG, TEST_INSTANCE_ID from tests.common import MockConfigEntry, async_load_json_array_fixture from tests.typing import WebSocketGenerator @pytest.mark.parametrize( ("exception", "expected_state"), [ (PortainerAuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), (PortainerConnectionError("cannot connect"), ConfigEntryState.SETUP_RETRY), (PortainerTimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), ], ) async def test_setup_exceptions( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, expected_state: ConfigEntryState, ) -> None: """Test the _async_setup.""" mock_portainer_client.portainer_system_status.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state async def test_migrations( hass: HomeAssistant, mock_portainer_client: AsyncMock, ) -> None: """Test migration from v1 config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "http://test_host", CONF_API_KEY: "test_key", }, unique_id="1", version=1, ) entry.add_to_hass(hass) assert entry.version == 1 assert CONF_VERIFY_SSL not in entry.data await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert CONF_HOST not in entry.data assert CONF_API_KEY not in entry.data assert entry.data[CONF_URL] == "http://test_host" assert entry.data[CONF_API_TOKEN] == "test_key" assert entry.data[CONF_VERIFY_SSL] is True # Confirm we went through all current migrations assert entry.version == 5 assert entry.unique_id == TEST_INSTANCE_ID @pytest.mark.parametrize( ("container_id", "expected_result"), [("1", False), ("5", True)], ids=("Present container", "Stale container"), ) async def test_remove_config_entry_device( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, hass_ws_client: WebSocketGenerator, container_id: str, expected_result: bool, ) -> None: """Test manually removing a stale device.""" assert await async_setup_component(hass, "config", {}) mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) device_entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_{container_id}")}, ) ws_client = await hass_ws_client(hass) response = await ws_client.remove_device(device_entry.id) assert response["success"] == expected_result async def test_migration_v3_to_v5( hass: HomeAssistant, mock_portainer_client: AsyncMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test migration from v3 config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_URL: "http://test_host", CONF_API_TOKEN: "test_key", CONF_VERIFY_SSL: True, }, unique_id="1", version=3, ) entry.add_to_hass(hass) assert entry.version == 3 endpoint_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, f"{entry.entry_id}_endpoint_1")}, name="Test Endpoint", ) original_container_identifier = f"{entry.entry_id}_adguard" container_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, original_container_identifier)}, via_device_id=endpoint_device.id, name="Test Container", ) container_entity = entity_registry.async_get_or_create( domain="switch", platform=DOMAIN, unique_id=f"{entry.entry_id}_adguard_container", config_entry=entry, device_id=container_device.id, original_name="Test Container Switch", ) assert container_device.via_device_id == endpoint_device.id assert container_device.identifiers == {(DOMAIN, original_container_identifier)} assert container_entity.unique_id == f"{entry.entry_id}_adguard_container" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.version == 5 # Fetch again, to assert the new identifiers container_after = device_registry.async_get(container_device.id) entity_after = entity_registry.async_get(container_entity.entity_id) assert container_after.identifiers == { (DOMAIN, original_container_identifier), (DOMAIN, f"{entry.entry_id}_1_adguard"), } assert entity_after.unique_id == f"{entry.entry_id}_1_adguard_container" async def test_unload_entry( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_portainer_watcher: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test async_unload_entry.""" await setup_integration(hass, mock_config_entry) assert mock_config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state == ConfigEntryState.NOT_LOADED mock_portainer_watcher.stop.assert_called_once() async def test_watcher_start_stop( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_portainer_watcher: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that watcher starts and stops on HA events.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() mock_portainer_watcher.start.assert_called_once() mock_portainer_watcher.stop.reset_mock() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() mock_portainer_watcher.stop.assert_called_once() async def test_migration_v4_to_v5( hass: HomeAssistant, mock_portainer_client: AsyncMock, ) -> None: """Test v4 config entry migration updates unique_id.""" entry = MockConfigEntry( domain=DOMAIN, data=MOCK_TEST_CONFIG, unique_id=MOCK_TEST_CONFIG[CONF_API_TOKEN], version=4, ) entry.add_to_hass(hass) assert entry.version == 4 assert entry.unique_id == MOCK_TEST_CONFIG[CONF_API_TOKEN] await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.version == 5 assert entry.unique_id == TEST_INSTANCE_ID @pytest.mark.parametrize( ("exception"), [ (PortainerAuthenticationError), (PortainerConnectionError), (PortainerTimeoutError), (Exception("Some other error")), ], ) async def test_migration_v4_to_v5_exceptions( hass: HomeAssistant, mock_portainer_client: AsyncMock, exception: type[Exception], ) -> None: """Test v4 config entry migration updates unique_id.""" entry = MockConfigEntry( domain=DOMAIN, data=MOCK_TEST_CONFIG, unique_id=MOCK_TEST_CONFIG[CONF_API_TOKEN], version=4, ) entry.add_to_hass(hass) assert entry.version == 4 assert entry.unique_id == MOCK_TEST_CONFIG[CONF_API_TOKEN] mock_portainer_client.portainer_system_status.side_effect = exception await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.MIGRATION_ERROR async def test_device_registry( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test devices are correctly registered.""" await setup_integration(hass, mock_config_entry) device_entries = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) # Sort by identifier to ensure consistent order in snapshot assert sorted(device_entries, key=lambda x: list(x.identifiers)[0][1]) == snapshot async def test_container_stack_device_links( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Test that stack-linked containers are nested under the correct stack device.""" await setup_integration(hass, mock_config_entry) endpoint_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1"), mock_config_entry.entry_id ) assert endpoint_device is not None dashy_stack_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_2"), mock_config_entry.entry_id ) assert dashy_stack_device is not None assert dashy_stack_device.via_device_id == endpoint_device.id webstack_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_1"), mock_config_entry.entry_id ) assert webstack_device is not None assert webstack_device.via_device_id == endpoint_device.id swarm_container_device = device_registry.async_get_device_by_identifier( ( DOMAIN, f"{mock_config_entry.entry_id}_1_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05", ), mock_config_entry.entry_id, ) assert swarm_container_device is not None assert swarm_container_device.via_device_id == dashy_stack_device.id compose_container_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_serene_banach"), mock_config_entry.entry_id, ) assert compose_container_device is not None assert compose_container_device.via_device_id == webstack_device.id standalone_container_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_focused_einstein"), mock_config_entry.entry_id, ) assert standalone_container_device is not None assert standalone_container_device.via_device_id == endpoint_device.id async def test_docker_system_df_refresh_runs_on_ha_start( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test docker system df coordinator refreshes DF data on HA start.""" await setup_integration(hass, mock_config_entry) assert ( state := hass.states.get("sensor.my_environment_image_disk_usage_total_size") ) assert state.state != STATE_UNAVAILABLE async def test_new_endpoint_callback( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test new endpoint creates entities after refresh.""" mock_portainer_client.get_endpoints.return_value = [] await setup_integration(hass, mock_config_entry) entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert len(entities) == 0 mock_portainer_client.get_endpoints.return_value = [ Endpoint.from_dict(endpoint) for endpoint in cast( list[dict[str, Any]], await async_load_json_array_fixture(hass, "endpoints.json", DOMAIN), ) if endpoint["Status"] == EndpointStatus.UP ] coordinator = mock_config_entry.runtime_data await coordinator.async_refresh() await hass.async_block_till_done() entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert len(entities) > 0 # The endpoint and its stacks are discovered together on this refresh, so # the stack device must resolve the freshly-created endpoint device as its # via device instead of racing its creation. endpoint_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1"), mock_config_entry.entry_id ) assert endpoint_device is not None stack_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_2"), mock_config_entry.entry_id, ) assert stack_device is not None assert stack_device.via_device_id == endpoint_device.id async def test_removed_endpoint_stops_event_listener( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_portainer_event_listeners: dict[int, MagicMock], mock_config_entry: MockConfigEntry, ) -> None: """Test a removed endpoint's Docker event listener is stopped and dropped.""" await setup_integration(hass, mock_config_entry) coordinator = mock_config_entry.runtime_data assert 1 in coordinator._event_listeners mock_portainer_client.get_endpoints.return_value = [] await coordinator.async_refresh() await hass.async_block_till_done() mock_portainer_event_listeners[1].stop.assert_called_once() assert 1 not in coordinator._event_listeners async def test_new_container_callback( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test new container creates entities after refresh.""" mock_portainer_client.get_containers.return_value = [] await setup_integration(hass, mock_config_entry) entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) mock_portainer_client.get_containers.return_value = [ DockerContainer.from_dict(container) for container in cast( list[dict[str, Any]], await async_load_json_array_fixture(hass, "containers.json", DOMAIN), ) if "/focused_einstein" in container["Names"] ] coordinator = mock_config_entry.runtime_data await coordinator.async_refresh() await hass.async_block_till_done() assert len( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) ) > len(entities) async def test_swarm_stacks_fetched_by_swarm_id( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test Swarm manager calls get_stacks with both IDs.""" await setup_integration(hass, mock_config_entry) calls = mock_portainer_client.get_stacks.call_args_list # Expect exactly two calls: one by endpoint_id, one by swarm_id assert len(calls) == 2 assert calls[0].kwargs == {"endpoint_id": 1} assert calls[1].kwargs == {"endpoint_id": 1, "swarm_id": "swarm-cluster-id"} async def test_new_stack_callback( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test new stack creates entities after refresh.""" mock_portainer_client.get_stacks.return_value = [] await setup_integration(hass, mock_config_entry) entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) mock_portainer_client.get_stacks.return_value = [ Stack.from_dict(stack) for stack in cast( list[dict[str, Any]], await async_load_json_array_fixture(hass, "stacks.json", DOMAIN), ) if stack["Name"] == "webstack" ] coordinator = mock_config_entry.runtime_data await coordinator.async_refresh() await hass.async_block_till_done() assert len( er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) ) > len(entities) async def test_stack_recreated_with_new_id( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Test a stack recreated with the same name but a new ID re-registers its device. The stack device identifier and a container's via device are keyed by the stack ID, so a stack recreated with a new ID must be detected as new and its device registered before a container in it resolves the stack as its via device. Tracking only the name would skip registration and the via device lookup would then raise, aborting the refresh. """ await setup_integration(hass, mock_config_entry) endpoint_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1"), mock_config_entry.entry_id ) assert endpoint_device is not None assert ( device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_1"), mock_config_entry.entry_id, ) is not None ) stacks = cast( list[dict[str, Any]], await async_load_json_array_fixture(hass, "stacks.json", DOMAIN), ) for stack in stacks: if stack["Name"] == "webstack": stack["Id"] = 99 mock_portainer_client.get_stacks.return_value = [ Stack.from_dict(stack) for stack in stacks ] containers = cast( list[dict[str, Any]], await async_load_json_array_fixture(hass, "containers.json", DOMAIN), ) new_container = { **next(c for c in containers if c["Names"] == ["/serene_banach"]), "Names": ["/brave_newton"], "Id": "cc97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", } mock_portainer_client.get_containers.return_value = [ DockerContainer.from_dict(container) for container in (*containers, new_container) ] coordinator = mock_config_entry.runtime_data await coordinator.async_refresh() await hass.async_block_till_done() assert coordinator.last_update_success new_stack_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_stack_99"), mock_config_entry.entry_id, ) assert new_stack_device is not None assert new_stack_device.via_device_id == endpoint_device.id new_container_device = device_registry.async_get_device_by_identifier( (DOMAIN, f"{mock_config_entry.entry_id}_1_brave_newton"), mock_config_entry.entry_id, ) assert new_container_device is not None assert new_container_device.via_device_id == new_stack_device.id