diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 20dc7526..07b5dce2 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -104,7 +104,11 @@ jobs: -r requirements.txt \ -r requirements_test.txt - - name: Tests suite + - name: Tests suite (HA integration — harness) run: | - pytest --cov=./custom_components/zaptec --cov-branch + pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch + + - name: Tests suite (API client — plain pytest, no harness) + run: | + pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 882bd3e2..087460f1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,24 @@ To run tests and check test coverage: report, or enable the "Coverage Gutters" extension to view the coverage directly in VSCode. +The suite runs as **two pytest invocations**, and `./scripts/test` runs both: + +- **HA-integration tests** (`tests/test_*.py`) run under the + `pytest-homeassistant-custom-component` harness, which autoloads on Linux. + Run directly with: + `pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch` +- **API-client tests** (`tests/zaptec/*`) test the vendored `zaptec/` client, + which is destined to become a standalone PyPI library (issue #257) and has no + Home Assistant dependency. They run as plain pytest with the harness disabled + (the harness blocks non-localhost sockets, which would break their live + `api.zaptec.com/api/constants` call): + `pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append` + +Because the harness (and its socket block) is process-wide, a bare `pytest` +is not the entry point — use `./scripts/test` or the two commands above. The +HA-integration tests require Linux; run them in the Dev Container (native +Windows is not supported for that half). `tests/zaptec/*` run anywhere. + HA requires [95% coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage/) for all core integration modules, and while HACS doesn't have the same requirements, reaching this level is still a goal for this integration. diff --git a/custom_components/zaptec/__init__.py b/custom_components/zaptec/__init__.py index acb5e111..5fc6618e 100644 --- a/custom_components/zaptec/__init__.py +++ b/custom_components/zaptec/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Container import logging from homeassistant.config_entries import ConfigEntry @@ -15,6 +16,7 @@ CONF_CHARGERS, CONF_MANUAL_SELECT, CONF_PREFIX, + DOMAIN, REDACT_DUMP_ON_STARTUP, REDACT_LOGS, ZAPTEC_POLL_INTERVAL_BUILD, @@ -109,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise _config_entry_error(err) from err # Get the structure of devices from Zaptec and determine the zaptec objects to track - tracked_devices = await ZaptecManager.first_time_setup( + tracked_devices, all_selected_present = await ZaptecManager.first_time_setup( zaptec=zaptec, configured_chargers=configured_chargers, ) @@ -218,7 +220,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Make a set of the circuit ids from zaptec to check for deprecated Circuit-devices circuit_ids = {cid for c in manager.zaptec.chargers if (cid := c.get("CircuitId"))} - # Clean up unused device entries with no entities + # Removal needs both: track-all mode has no deselection to detect, and a partial + # API response is indistinguishable from one. + manual_select = configured_chargers is not None + check_untracked = manual_select and all_selected_present + + if manual_select and not all_selected_present: + _LOGGER.warning( + "One or more selected chargers were not returned by the Zaptec API " + "this session; skipping removal of untracked devices to avoid deleting " + "a still-selected charger due to a transient/partial response" + ) + + _cleanup_devices( + hass, + entry, + manager.tracked_devices, + circuit_ids, + check_untracked=check_untracked, + ) + + return True + + +def _cleanup_devices( + hass: HomeAssistant, + entry: ZaptecConfigEntry, + tracked_devices: Container[str], + circuit_ids: Container[str], + check_untracked: bool, +) -> None: + """Remove device entries that no longer belong to this config entry. + + Handles devices with no entities, deprecated Circuit-devices, and - only when + `check_untracked` - devices whose zaptec id is no longer tracked. Passing + `check_untracked` while `tracked_devices` may be incomplete would permanently + delete a still-selected charger's device. + """ device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) @@ -232,20 +270,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not dev_entities: device_registry.async_remove_device(dev.id) continue - # identifiers is a set with a (single) tuple ('zaptec', '') - for _, zap_dev_id in dev.identifiers: + for domain, zap_dev_id in dev.identifiers: + # A foreign id is never in tracked_devices, so without this the + # untracked check would delete the device. + if domain != DOMAIN: + continue if zap_dev_id in circuit_ids: _LOGGER.warning( "Detected deprecated Circuit device %s, " "removing device and associated entities", zap_dev_id, ) - for ent in dev_entities: - _LOGGER.debug("Deleting entity %s", ent.entity_id) - entity_registry.async_remove(ent.entity_id) - device_registry.async_remove_device(dev.id) + elif check_untracked and zap_dev_id not in tracked_devices: + _LOGGER.warning( + "Detected stale device %s no longer selected, " + "removing device and associated entities", + zap_dev_id, + ) + else: + continue - return True + for ent in dev_entities: + _LOGGER.debug("Deleting entity %s", ent.entity_id) + entity_registry.async_remove(ent.entity_id) + device_registry.async_remove_device(dev.id) + # Removing twice would raise KeyError, so stop at the first matching id. + break def remove_deprecated_entities(hass: HomeAssistant, entry: ZaptecConfigEntry) -> None: diff --git a/custom_components/zaptec/manager.py b/custom_components/zaptec/manager.py index 79b9fc02..23e7e448 100644 --- a/custom_components/zaptec/manager.py +++ b/custom_components/zaptec/manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Collection, Iterable import contextlib from copy import copy from dataclasses import dataclass @@ -174,7 +174,7 @@ def create_entities_from_zaptec( elif isinstance(obj, Charger): info = DeviceInfo() - if obj.installation: + if obj.installation is not None: info["via_device"] = (DOMAIN, obj.installation.id) entities.extend( @@ -229,8 +229,16 @@ async def stream_callback(self, event: dict) -> None: coordinator.async_update_listeners() @staticmethod - async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None) -> set[str]: - """Run the first time setup for the account.""" + async def first_time_setup( + zaptec: Zaptec, configured_chargers: Collection[str] | None + ) -> tuple[set[str], bool]: + """Run the first time setup for the account. + + Returns the tracked device ids, plus False if a selected charger was missing + from the Zaptec API this session. Callers must not treat a device as + user-deselected while that is False - a transient partial response would + look identical to a deselection. + """ _LOGGER.debug("Running first time setup") # Build the Zaptec hierarchy @@ -238,15 +246,18 @@ async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None) all_objects = set(zaptec) tracked_devices = all_objects + all_selected_present = True # Selected chargers to add if configured_chargers is not None: _LOGGER.debug("Configured chargers: %s", configured_chargers) want = set(configured_chargers) - # Log if there are any objects listed not found in Zaptec - if not_present := want - all_objects: + # Chargers, not every zaptec object: only chargers reach `keep`, so a + # non-charger id would count as present but drop out of tracked_devices. + if not_present := want - {charger.id for charger in zaptec.chargers}: _LOGGER.error("Charger objects %s not found", not_present) + all_selected_present = False # Calculate the objects to keep. From the list of chargers we # want to keep, we also want to keep the installation objects. @@ -254,7 +265,9 @@ async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None) for charger in zaptec.chargers: if charger.id in want: keep.add(charger.id) - if charger.installation: + # `is not None`, not truthiness: ZaptecBase is a Mapping, so an + # attribute-less installation is falsy - and untracked means deleted. + if charger.installation is not None: keep.add(charger.installation.id) if not keep: @@ -263,4 +276,4 @@ async def first_time_setup(zaptec: Zaptec, configured_chargers: set[str] | None) # These objects will be updated by the coordinator tracked_devices = keep - return tracked_devices + return tracked_devices, all_selected_present diff --git a/requirements.txt b/requirements.txt index 1adb323f..be7d8a98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,5 @@ ruff==0.15.22 # Copy from manifest.json to get this into the dev container # without needing to start HA azure-servicebus==7.14.3 -pydantic==2.13.1 +pydantic>=2.11.7,<2.14 aiolimiter==1.2.1 diff --git a/requirements_test.txt b/requirements_test.txt index cf21f7bc..be62f2c4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,6 @@ pytest pytest-asyncio pytest-mock -pytest-cov \ No newline at end of file +pytest-cov +pytest-homeassistant-custom-component==0.13.324; python_version >= "3.14" +pytest-homeassistant-custom-component==0.13.316; python_version < "3.14" diff --git a/scripts/test b/scripts/test index b3d15862..58923e90 100755 --- a/scripts/test +++ b/scripts/test @@ -5,8 +5,14 @@ set -e if [ "$1" == "--skip-api" ]; then export SKIP_ZAPTEC_API_TEST="true" fi + +# HA-integration tests run under the pytest-hacc harness (autoloads on Linux). +# API-client tests (tests/zaptec/*) run as plain pytest with the harness +# disabled, so their live constants call is not socket-blocked. Coverage +# from both is combined via --cov-append. # run tests with -s to display printouts and --log-cli-level to get logger output -pytest --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s +pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s +pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append --log-cli-level=INFO -s # generate coverage report in html and xml coverage html diff --git a/tests/conftest.py b/tests/conftest.py index ba07a5c5..b1727e24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,20 @@ """Zaptec testing configuration file.""" -import asyncio +from collections.abc import Callable, Iterable import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.zaptec.const import DOMAIN +from custom_components.zaptec.manager import ZaptecManager +from custom_components.zaptec.zaptec import MISSING, Charger, Installation from custom_components.zaptec.zaptec.api import Zaptec +from custom_components.zaptec.zaptec.utils import to_under @pytest.fixture(scope="session") @@ -24,12 +33,7 @@ def skip_if_user_disabled_api_tests() -> None: @pytest.fixture(scope="session") def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001 (the inputs are purely to create dependencies to the env-flags above) - """ - Get the zaptec username stored in env. - - Any test relying on this fixture will be skipped if the test is running - in Gihub Actions, or the user has disabled tests requiring API login. - """ + """Get the zaptec username from env, skipping if API-login tests are disabled.""" username = os.environ.get("ZAPTEC_USERNAME") assert username, ( "Missing username, either set it with \"export ZAPTEC_USERNAME='username'\" " @@ -40,12 +44,7 @@ def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions) @pytest.fixture(scope="session") def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001 - """ - Get the zaptec password stored in env. - - Any test relying on this fixture will be skipped if the test is running - in Gihub Actions, or the user has disabled tests requiring API login. - """ + """Get the zaptec password from env, skipping if API-login tests are disabled.""" password = os.environ.get("ZAPTEC_PASSWORD") assert password, ( "Missing password, either set it with \"export ZAPTEC_PASSWORD='password'\" " @@ -54,14 +53,122 @@ def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions) return password -@pytest.fixture(scope="session") -def zaptec_constants() -> dict: - """Get latest constants from Zaptec API.""" +def _backed_get(data: dict[str, Any]) -> Callable[..., Any]: + """Return a `.get(key, default=MISSING)` implementation backed by `data`. + + Mirrors `ZaptecBase.__getitem__`'s key normalization (`to_under`); defaults + to `MISSING` rather than `Mapping.get`'s `None` since every real call site + (`entity.py`'s `_get_zaptec_value`) passes `default=MISSING` explicitly. + """ + + def _get(key: str, default: Any = MISSING) -> Any: + return data.get(to_under(key), default) - async def get_zaptec_constants() -> dict: - async with Zaptec("N/A", "N/A") as zaptec: - # the constants API endpoint does not require login - const: dict = await zaptec.request("constants") - return const + return _get - return asyncio.run(get_zaptec_constants()) + +def make_charger( + data: dict[str, Any], *, installation: MagicMock | None = None, charging: bool = False +) -> MagicMock: + """Build a spec'd Charger double backed by `data`. + + `model` is hardcoded rather than modeling `Charger.model`'s real + `ZCONST.serial_to_model` lookup — a deliberate simplification. + """ + charger = MagicMock(spec=Charger) + charger.id = data["id"] + charger.name = data.get("name", "Mock Charger") + charger.model = "Zaptec Charger" + charger.qual_id = f"Charger[{data['id'][-6:]}]" + charger.get.side_effect = _backed_get(data) + charger.is_charging.return_value = charging + charger.installation = installation + return charger + + +def make_installation(data: dict[str, Any], *, chargers: Iterable[MagicMock] = ()) -> MagicMock: + """Build a spec'd Installation double backed by `data`.""" + install = MagicMock(spec=Installation) + # Installation is a Mapping, so `spec` alone gives the double a __len__ of 0 and + # makes it falsy, unlike any real populated installation. + install.__len__.return_value = len(data) + install.id = data["id"] + install.name = data.get("name", "Mock Installation") + install.model = "Zaptec Installation" + install.qual_id = f"Installation[{data['id'][-6:]}]" + install.get.side_effect = _backed_get(data) + install.chargers = list(chargers) + install.stream_main = AsyncMock(return_value=None) + install.stream_close = AsyncMock(return_value=None) + return install + + +@pytest.fixture +def mock_zaptec() -> MagicMock: + """A spec'd Zaptec client seeded with one installation and one charger. + + `__getitem__`/`__iter__`/`__contains__`/`__len__` are wired because `Zaptec` + is itself `Mapping[str, ZaptecBase]` in production, and real code (e.g. + `zaptec[deviceid]` in `__init__.py`/`coordinator.py`) indexes into it directly. + """ + installation = make_installation({"id": "inst-mock-1", "name": "Mock Home"}) + charger = make_charger( + { + "id": "chg-mock-1", + "name": "Mock Charger", + # Keys read by entities under test; extend as needed for coverage. + "operating_mode": "Connected", + "charger_operation_mode": "Connected", + }, + installation=installation, + charging=False, + ) + installation.chargers = [charger] + + objects = {installation.id: installation, charger.id: charger} + + zaptec = MagicMock(spec=Zaptec) + zaptec.__getitem__.side_effect = objects.__getitem__ + zaptec.__iter__.side_effect = lambda: iter(objects) + zaptec.__contains__.side_effect = objects.__contains__ + zaptec.__len__.side_effect = lambda: len(objects) + zaptec.objects.return_value = list(objects.values()) + zaptec.installations = [installation] + zaptec.chargers = [charger] + zaptec.login = AsyncMock(return_value=None) + zaptec.build = AsyncMock(return_value=None) + zaptec.poll = AsyncMock(return_value=None) + zaptec.show_all_updates = False + zaptec.redact = MagicMock() + # Load-bearing, not incidental: __init__.py's startup debug-dump path does + # `message += manager.zaptec.redact.dumps()`, which setup_integration actually + # exercises. An unconfigured MagicMock here would raise TypeError on the +=. + zaptec.redact.dumps.return_value = "" + return zaptec + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """A MockConfigEntry for the zaptec domain.""" + return MockConfigEntry( + domain=DOMAIN, + title="Mock Zaptec", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + entry_id="mock_entry_1", + ) + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_zaptec: MagicMock +) -> ZaptecManager: + """Set the integration up through the real async_setup, with a mocked client. + + Patches `custom_components.zaptec.Zaptec` — where `__init__.py` looks the name + up, per unittest.mock's patch-at-the-lookup rule — not the original definition + in `zaptec/api.py`, which `__init__.py`'s own import wouldn't see patched. + """ + mock_config_entry.add_to_hass(hass) + with patch("custom_components.zaptec.Zaptec", return_value=mock_zaptec): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry.runtime_data diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 00000000..fdb74b39 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,184 @@ +"""Behavior tests for ZaptecUpdateCoordinator, driven through the real harness.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.zaptec.const import ( + ZAPTEC_POLL_INTERVAL_CHARGING, + ZAPTEC_POLL_INTERVAL_IDLE, +) +from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator, ZaptecUpdateOptions +from custom_components.zaptec.zaptec import ZaptecApiError +from tests.conftest import setup_integration + + +async def test_successful_poll_marks_last_update_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A successful poll leaves every coordinator reporting success.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + for coordinator in manager.all_coordinators: + assert coordinator.last_update_success is True + mock_zaptec.poll.assert_awaited() + + +async def test_poll_failure_sets_update_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A ZaptecApiError during poll flips last_update_success to False.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + head = manager.head_coordinator + + mock_zaptec.poll.side_effect = ZaptecApiError("boom") + await head.async_refresh() + + assert head.last_update_success is False + + +async def test_device_coordinator_switches_interval_when_charging( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A charger's coordinator uses the shorter interval once it reports charging.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + charger_coord = manager.device_coordinators["chg-mock-1"] + idle_interval = charger_coord.update_interval + assert idle_interval == timedelta(seconds=ZAPTEC_POLL_INTERVAL_IDLE) + + # Flip the seeded charger to 'charging' and re-run the update-listener path. + mock_zaptec.chargers[0].is_charging.return_value = True + charger_coord.set_update_interval() + + assert charger_coord.update_interval == timedelta(seconds=ZAPTEC_POLL_INTERVAL_CHARGING) + # Also assert the relation directly, catching e.g. const.py setting + # CHARGING >= IDLE, which the equality asserts alone would miss. + assert charger_coord.update_interval < idle_interval + + +async def test_charging_update_interval_requires_charger_object( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Constructing a coordinator with a charging interval on a non-Charger object errors. + + Skips `setup_integration` to hit the constructor-time guard directly: a + bare `manager=MagicMock()` auto-vivifies `self.zaptec` with no error before + the `isinstance(zaptec_object, Charger)` check runs. + """ + mock_config_entry.add_to_hass(hass) + + with pytest.raises(ValueError, match="Charging update interval requires a Charger object"): + ZaptecUpdateCoordinator( + hass, + entry=mock_config_entry, + manager=MagicMock(), + options=ZaptecUpdateOptions( + name="bad", + update_interval=60, + charging_update_interval=30, + tracked_devices=set(), + poll_args={}, + zaptec_object=object(), # not a Charger instance + ), + ) + + +async def test_trigger_poll_is_noop_without_zaptec_object( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """trigger_poll() on a coordinator with no bound zaptec object does nothing. + + `head_coordinator` is the one coordinator built with `zaptec_object=None` + (device coordinators always get a real Charger/Installation), satisfying + trigger_poll()'s no-op guard. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + await manager.head_coordinator.trigger_poll() + + assert manager.head_coordinator._trigger_task is None # noqa: SLF001 + + +async def test_trigger_poll_cancels_in_flight_task_and_reschedules( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A second trigger_poll() call cancels the running poll sequence and starts a new one.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + charger_coord = manager.device_coordinators["chg-mock-1"] + + # Collapse the real delays to zero, keeping real asyncio.sleep(0) checkpoints so + # the eagerly-started background task actually suspends and can be cancelled mid-flight. + monkeypatch.setattr( + "custom_components.zaptec.coordinator.ZAPTEC_POLL_CHARGER_TRIGGER_DELAYS", [0, 0, 0] + ) + + # HA's eager task factory runs the task immediately; it suspends at the + # first sleep(0) checkpoint and is left pending. + await charger_coord.trigger_poll() + first_task = charger_coord._trigger_task # noqa: SLF001 + assert first_task is not None + + # Second call sees the still-pending first task and cancels it before rescheduling. + await charger_coord.trigger_poll() + assert first_task.cancelled() + + second_task = charger_coord._trigger_task # noqa: SLF001 + assert second_task is not None + assert second_task is not first_task + await second_task + await hass.async_block_till_done() + + assert charger_coord._trigger_task is None # noqa: SLF001 + assert charger_coord.last_update_success is True + + +async def test_trigger_poll_triggers_child_charger_coordinators( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Polling an installation also triggers the poll sequence of its tracked chargers. + + Patches `asyncio.sleep` globally, rather than the delays-list constant (as the + cancel/reschedule test does), just to reach the loop's first iteration fast — + installations use their own delay constant this test doesn't care about. + `charger_coord.trigger_poll` is mocked to isolate "parent calls child" from + the child's own trigger_poll logic (covered elsewhere). + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + install_coord = manager.device_coordinators["inst-mock-1"] + charger_coord = manager.device_coordinators["chg-mock-1"] + + monkeypatch.setattr( + "custom_components.zaptec.coordinator.asyncio.sleep", AsyncMock(return_value=None) + ) + charger_coord.trigger_poll = AsyncMock() + + await install_coord.trigger_poll() + task = install_coord._trigger_task # noqa: SLF001 + assert task is not None + await task + await hass.async_block_till_done() + + charger_coord.trigger_poll.assert_awaited_once() diff --git a/tests/test_entity.py b/tests/test_entity.py new file mode 100644 index 00000000..cf7da4e9 --- /dev/null +++ b/tests/test_entity.py @@ -0,0 +1,264 @@ +"""Behavior tests for ZaptecBaseEntity, driven through the real harness.""" + +import logging +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.zaptec.const import KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK +from custom_components.zaptec.coordinator import ZaptecUpdateCoordinator +from custom_components.zaptec.entity import KeyUnavailableError, ZaptecBaseEntity +from custom_components.zaptec.zaptec import MISSING, ZaptecApiError +from tests.conftest import setup_integration + + +def _entity_from_coordinator( + coordinator: ZaptecUpdateCoordinator, *, key_not_in_skip_list: bool = False +) -> ZaptecBaseEntity: + """Return a real entity instance bound to `coordinator`. + + Reaches into the private `_listeners` dict since there's no public way to + list entities subscribed to a coordinator. `hasattr(candidate, "_log_value")` + filters out non-entity listeners (e.g. the coordinator's own + `set_update_interval`, also registered as a listener). + + `key_not_in_skip_list=True` skips entities whose `.key` is in + `KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK`, needed when asserting on the + "Getting value failed" log line those keys suppress. + """ + for cb, _context in coordinator._listeners.values(): # noqa: SLF001 + candidate = cb.__self__ + if not hasattr(candidate, "_log_value"): + continue + if key_not_in_skip_list and candidate.key in KEYS_TO_SKIP_ENTITY_AVAILABILITY_CHECK: + continue + return candidate + raise AssertionError("no matching zaptec entity found") + + +async def _get_zaptec_entity(hass: HomeAssistant) -> str: + """Return one live zaptec entity_id whose value is backed by seeded data. + + Not every zaptec entity reads a key `mock_zaptec` seeds, and setup order + isn't guaranteed to surface a backed one first — skip unbacked entities. + """ + for state in hass.states.async_all(): + if state.entity_id.startswith( + ("sensor.", "binary_sensor.", "switch.", "number.") + ) and state.state not in ("unavailable", "unknown"): + return state.entity_id + raise AssertionError("no backed zaptec entity found") + + +async def test_entity_reports_value_from_zaptec( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A backed key surfaces as the entity's state (not 'unavailable'/'unknown').""" + await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + state = hass.states.get(entity_id) + assert state.state not in ("unavailable", "unknown") + + +async def test_entity_stays_available_when_single_key_missing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A single missing backing key does NOT mark the entity unavailable. + + `CoordinatorEntity.available` is driven by `coordinator.last_update_success`, + not `_attr_available` — `_handle_coordinator_update` catches the + `KeyUnavailableError` from `_update_from_zaptec`, so the poll still succeeds + and the entity keeps its previous value/state. + """ + await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + + state_before = hass.states.get(entity_id) + assert state_before.state not in ("unavailable", "unknown") + + # Make every key lookup miss, then re-run a refresh so entities re-read. + mock_zaptec.chargers[0].get.side_effect = lambda _key, default=MISSING: default + manager = mock_config_entry.runtime_data + for coordinator in manager.all_coordinators: + await coordinator.async_refresh() + await hass.async_block_till_done() + + state_after = hass.states.get(entity_id) + assert state_after.state != "unavailable" + assert state_after.state == state_before.state + + +async def test_entity_unavailable_when_coordinator_poll_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """The entity reports 'unavailable' when its coordinator's poll fails. + + `CoordinatorEntity.available` reflects `coordinator.last_update_success`, + not any per-key state. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + entity_id = await _get_zaptec_entity(hass) + + mock_zaptec.poll.side_effect = ZaptecApiError("boom") + coordinator = manager.device_coordinators["chg-mock-1"] + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + +async def test_log_value_logs_on_change_then_skips_when_unchanged( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """_log_value logs when the tracked value changes and stays quiet when it doesn't. + + `_log_value` reads an arbitrary attribute via `getattr` and dedups against + `_prev_value`, purely to feed a debug log line — no `hass.states` to assert on. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + entity.some_attr = "value1" + + with caplog.at_level(logging.DEBUG): + entity._log_value("some_attr") # noqa: SLF001 + assert "value1" in caplog.text + + caplog.clear() + with caplog.at_level(logging.DEBUG): + entity._log_value("some_attr") # noqa: SLF001 + assert caplog.text == "" + + +async def test_get_zaptec_value_returns_default_when_key_missing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """_get_zaptec_value() returns the caller's default when the key isn't backed. + + Covers the one production call site that opts out of the `MISSING`-triggers- + `KeyUnavailableError` default (`sensor.py`'s `default={}` for `completed_session`). + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + sentinel = object() + assert entity._get_zaptec_value(key="totally_missing_key", default=sentinel) is sentinel # noqa: SLF001 + + +async def test_get_zaptec_value_raises_when_intermediate_value_not_mapping( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A dotted key whose first segment resolves to a non-Mapping value raises. + + No shipped entity uses a dotted key today, so this exercises + `_get_zaptec_value`'s "obj isn't Mapping-like" branch directly, guarding it + for whenever one does. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + # "operating_mode" is seeded as a plain string, which has no `.get()`. + with pytest.raises(KeyUnavailableError): + entity._get_zaptec_value(key="operating_mode.sub") # noqa: SLF001 + + +async def test_log_zaptec_attribute_formats_none_str_iterable_and_scalar_keys( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """_log_zaptec_attribute formats None, a single key, an iterable, and a scalar. + + Pokes all four branches directly since the property only feeds a debug log + line. `str` (default `description.key`) and `Iterable` (sensor.py/update.py's + multi-key logging) are live; `None` and the scalar fallback are currently + unreachable in production but worth guarding. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + entity._log_zaptec_key = None # noqa: SLF001 + assert entity._log_zaptec_attribute == "" # noqa: SLF001 + + entity._log_zaptec_key = "foo" # noqa: SLF001 + assert entity._log_zaptec_attribute == ".foo" # noqa: SLF001 + + entity._log_zaptec_key = ["foo", "bar"] # noqa: SLF001 + assert entity._log_zaptec_attribute == ".foo and .bar" # noqa: SLF001 + + entity._log_zaptec_key = 42 # noqa: SLF001 + assert entity._log_zaptec_attribute == ".42" # noqa: SLF001 + + +async def test_log_unavailable_logs_error_and_recovery_transitions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """_log_unavailable logs the real exception on going unavailable, and logs recovery. + + Sets `_attr_available`/`_prev_available` directly since they're decoupled + from `CoordinatorEntity.available` (which reads `last_update_success`) — no + real coordinator refresh can drive both log transitions. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator, key_not_in_skip_list=True) + + entity._prev_available = True # noqa: SLF001 + entity._attr_available = False # noqa: SLF001 + with caplog.at_level(logging.DEBUG): + entity._log_unavailable(RuntimeError("boom")) # noqa: SLF001 + assert f"Entity {entity.entity_id} is unavailable" in caplog.text + assert "Getting value failed" in caplog.text + + caplog.clear() + entity._prev_available = False # noqa: SLF001 + entity._attr_available = True # noqa: SLF001 + with caplog.at_level(logging.DEBUG): + entity._log_unavailable() # noqa: SLF001 + assert f"Entity {entity.entity_id} is available" in caplog.text + + +async def test_entity_trigger_poll_delegates_to_coordinator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """ZaptecBaseEntity.trigger_poll() awaits the bound coordinator's trigger_poll().""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + coordinator = manager.device_coordinators["chg-mock-1"] + entity = _entity_from_coordinator(coordinator) + + coordinator.trigger_poll = AsyncMock() + await entity.trigger_poll() + + coordinator.trigger_poll.assert_awaited_once() diff --git a/tests/test_init.py b/tests/test_init.py index 4082b177..fefe15cb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,17 +1,28 @@ """Tests for custom_components.zaptec.__init__.""" +from collections.abc import Collection from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.zaptec import _config_entry_error +from custom_components.zaptec import _cleanup_devices, _config_entry_error +from custom_components.zaptec.const import CONF_CHARGERS, CONF_MANUAL_SELECT, DOMAIN +from custom_components.zaptec.entity import ZaptecBaseEntity +from custom_components.zaptec.manager import ZaptecEntityDescription, ZaptecManager +from custom_components.zaptec.zaptec.api import Zaptec from custom_components.zaptec.zaptec.exceptions import ( AuthenticationError, RequestConnectionError, RequestError, RequestTimeoutError, ) +from tests.conftest import make_charger, make_installation, setup_integration @pytest.mark.parametrize( @@ -22,7 +33,7 @@ # Connection/timeout are recoverable -> HA retries setup. (RequestTimeoutError("slow"), ConfigEntryNotReady), (RequestConnectionError("down"), ConfigEntryNotReady), - # Transient server statuses are recoverable -> HA retries setup (issue #392). + # Transient server statuses are recoverable -> HA retries setup. (RequestError("unavailable", HTTPStatus.SERVICE_UNAVAILABLE), ConfigEntryNotReady), (RequestError("too many", HTTPStatus.TOO_MANY_REQUESTS), ConfigEntryNotReady), # Other HTTP errors stay permanent. @@ -33,3 +44,416 @@ def test_config_entry_error_mapping(err: Exception, expected: type[Exception]) -> None: """Setup login errors map to the right Home Assistant config-entry error.""" assert isinstance(_config_entry_error(err), expected) + + +async def test_setup_entry_creates_manager_and_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + device_registry: dr.DeviceRegistry, + enable_custom_integrations: None, +) -> None: + """A full setup wires up the manager and registers at least one entity.""" + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + assert isinstance(manager, ZaptecManager) + assert mock_config_entry.runtime_data is manager + # Matches because mock_zaptec seeds "Mock Charger"/"Mock Home", which HA + # slugifies into "mock..." entity_ids. Update if that seed naming changes. + states = [s for s in hass.states.async_all() if s.entity_id.split(".")[1].startswith("mock")] + assert states, "expected at least one zaptec entity to be created" + + assert device_registry.async_get_device(identifiers={(DOMAIN, "chg-mock-1")}) is not None + + +async def test_setup_entry_charger_with_installation_has_via_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A charger belonging to an installation gets a `via_device` pointing at it. + + Asserted on the DeviceInfo rather than the registry's `via_device_id`: HA resolves + `via_device` when the device is created, and `mock_zaptec` adds only one + installation entity, late, so the installation device may not exist yet when the + charger is registered. Asserting on the registry here is measurably flaky. + """ + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + entities = manager.create_entities_from_zaptec( + [], [ZaptecEntityDescription(key="operating_mode", cls=ZaptecBaseEntity)] + ) + charger_entity = next(e for e in entities if e.zaptec_obj.id == "chg-mock-1") + assert charger_entity.device_info["via_device"] == (DOMAIN, "inst-mock-1") + + +async def test_setup_entry_charger_without_installation_has_no_via_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zaptec: MagicMock, + enable_custom_integrations: None, +) -> None: + """A charger with no installation gets a DeviceInfo without `via_device`.""" + mock_zaptec.chargers[0].installation = None + + manager = await setup_integration(hass, mock_config_entry, mock_zaptec) + + entities = manager.create_entities_from_zaptec( + [], [ZaptecEntityDescription(key="operating_mode", cls=ZaptecBaseEntity)] + ) + charger_entity = next(e for e in entities if e.zaptec_obj.id == "chg-mock-1") + assert "via_device" not in charger_entity.device_info + + +def _zaptec_double(chargers: list[MagicMock], objects: dict[str, MagicMock]) -> MagicMock: + """A minimal Zaptec double for `first_time_setup`, which only needs iteration.""" + zaptec = MagicMock(spec=Zaptec) + zaptec.__iter__.side_effect = lambda: iter(objects) + zaptec.build = AsyncMock(return_value=None) + zaptec.chargers = chargers + return zaptec + + +async def test_first_time_setup_keeps_installation_of_selected_charger() -> None: + """A selected charger's installation is tracked, even when the charger has none. + + The `is not None` check matters here rather than truthiness: `Installation` is a + `Mapping`, so an installation carrying no attributes is falsy. Since + `_cleanup_devices` deletes untracked devices, dropping one on falsiness + would reap a live installation's device. + """ + empty_install = make_installation({"id": "inst-empty"}) + empty_install.__len__.return_value = 0 # falsy, as a real attribute-less one is + with_install = make_charger({"id": "chg-a"}, installation=empty_install) + without_install = make_charger({"id": "chg-b"}, installation=None) + + tracked, all_present = await ZaptecManager.first_time_setup( + _zaptec_double( + [with_install, without_install], + {"inst-empty": empty_install, "chg-a": with_install, "chg-b": without_install}, + ), + {"chg-a", "chg-b"}, + ) + + assert not empty_install, "fixture must be falsy for this test to mean anything" + assert tracked == {"chg-a", "chg-b", "inst-empty"} + assert all_present is True + + +@pytest.mark.parametrize( + ("configured", "expected_tracked", "expected_all_present"), + [ + # Track-all mode keeps every object in the account. + (None, {"inst-1", "chg-a", "chg-b"}, True), + # A charger present in the account but not selected is simply not tracked. + ({"chg-a"}, {"chg-a", "inst-1"}, True), + # A selected charger absent from the API response -> flag goes False, and the + # chargers that *were* returned are still tracked. + ({"chg-a", "chg-absent"}, {"chg-a", "inst-1"}, False), + # Nothing selected that exists -> nothing tracked at all. + ({"chg-absent"}, set(), False), + # A selected id that exists but isn't a charger can never reach `keep`. It has + # to count as absent: otherwise this returns an empty tracked set while + # reporting the account complete, which authorizes deleting every device. + ({"inst-1"}, set(), False), + # A list, not a set: this is what entry.data actually holds. + (["chg-a"], {"chg-a", "inst-1"}, True), + ], +) +async def test_first_time_setup_tracked_devices( + configured: Collection[str] | None, + expected_tracked: set[str], + expected_all_present: bool, +) -> None: + """`first_time_setup` reports both what to track and whether the account was complete.""" + installation = make_installation({"id": "inst-1"}) + charger_a = make_charger({"id": "chg-a"}, installation=installation) + charger_b = make_charger({"id": "chg-b"}, installation=installation) + objects = {"inst-1": installation, "chg-a": charger_a, "chg-b": charger_b} + + tracked, all_present = await ZaptecManager.first_time_setup( + _zaptec_double([charger_a, charger_b], objects), configured + ) + + assert tracked == expected_tracked + assert all_present is expected_all_present + + +def _add_device( + device_registry: dr.DeviceRegistry, entry: MockConfigEntry, zaptec_id: str +) -> dr.DeviceEntry: + """Register a device identified the way manager.py's device_info does.""" + return device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, zaptec_id)}, + name=zaptec_id, + ) + + +def _add_entity( + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + device: dr.DeviceEntry, + unique_id: str, +) -> str: + """Register one sensor entity against `device` and return its entity_id.""" + return entity_registry.async_get_or_create( + domain="sensor", + platform=DOMAIN, + unique_id=unique_id, + device_id=device.id, + config_entry=entry, + ).entity_id + + +async def test_cleanup_removes_device_with_no_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """A device with zero registered entities is removed outright.""" + mock_config_entry.add_to_hass(hass) + device = _add_device(device_registry, mock_config_entry, "charger-empty") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices={"charger-empty"}, # tracked, but still has no entities + circuit_ids=set(), + check_untracked=True, + ) + + assert device_registry.async_get(device.id) is None + + +async def test_cleanup_removes_deprecated_circuit_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A device matching a known Circuit id is removed along with its entities. + + Tracked on purpose, so the Circuit match is the only thing that can remove it - + otherwise the untracked branch would carry this test and deleting the Circuit + check would go unnoticed. + """ + mock_config_entry.add_to_hass(hass) + device = _add_device(device_registry, mock_config_entry, "circuit-123") + entity_id = _add_entity(entity_registry, mock_config_entry, device, "circuit-123_power") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices={"circuit-123"}, + circuit_ids={"circuit-123"}, + check_untracked=True, + ) + + assert entity_registry.async_get(entity_id) is None + assert device_registry.async_get(device.id) is None + + +@pytest.mark.parametrize("zaptec_id", ["charger-kept", "installation-1"]) +async def test_cleanup_keeps_tracked_device_with_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + zaptec_id: str, +) -> None: + """A tracked device with entities is left alone, charger or installation alike. + + `tracked_devices` holds both charger ids and their installation ids + (`first_time_setup` keeps an installation whenever any of its chargers is + selected), so installations go through the same tracked-device check. + """ + mock_config_entry.add_to_hass(hass) + device = _add_device(device_registry, mock_config_entry, zaptec_id) + entity_id = _add_entity(entity_registry, mock_config_entry, device, f"{zaptec_id}_power") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices={zaptec_id}, + circuit_ids=set(), + check_untracked=True, + ) + + assert entity_registry.async_get(entity_id) is not None + assert device_registry.async_get(device.id) is not None + + +async def test_cleanup_removes_deselected_charger_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A charger deselected via reconfigure loses its device and entities. + + `tracked_devices` already excluded it, so no new entities were created - but its + entity-registry entries from the prior session were never removed, leaving + `dev_entities` non-empty and the device untouched. + """ + mock_config_entry.add_to_hass(hass) + stale = _add_device(device_registry, mock_config_entry, "charger-stale") + stale_entity = _add_entity(entity_registry, mock_config_entry, stale, "charger-stale_power") + kept = _add_device(device_registry, mock_config_entry, "charger-kept") + kept_entity = _add_entity(entity_registry, mock_config_entry, kept, "charger-kept_power") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices={"charger-kept"}, # charger-stale was deselected + circuit_ids=set(), + check_untracked=True, + ) + + assert entity_registry.async_get(stale_entity) is None + assert device_registry.async_get(stale.id) is None + assert entity_registry.async_get(kept_entity) is not None + assert device_registry.async_get(kept.id) is not None + + +async def test_cleanup_skips_untracked_removal_when_selection_incomplete( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """An untracked device survives when `check_untracked` is False. + + If the Zaptec API returns a partial account this session, a charger the user + still has selected drops out of `tracked_devices`. Without this guard that blip + would be mistaken for a deselection and permanently delete the device. + """ + mock_config_entry.add_to_hass(hass) + device = _add_device(device_registry, mock_config_entry, "charger-maybe-stale") + entity_id = _add_entity( + entity_registry, mock_config_entry, device, "charger-maybe-stale_power" + ) + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices=set(), # would look untracked... + circuit_ids=set(), + check_untracked=False, # ...but the API response was incomplete this session + ) + + assert entity_registry.async_get(entity_id) is not None + assert device_registry.async_get(device.id) is not None + + +async def test_cleanup_removes_multi_identifier_device_once( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """A device is removed once even when several of its ids match. + + Zaptec devices carry a single identifier today, but nothing enforces that, and + `async_remove_device` pops without a default - a second removal would raise + KeyError and abort setup. + """ + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "charger-stale-a"), (DOMAIN, "charger-stale-b")}, + name="two-identifier device", + ) + entity_id = _add_entity(entity_registry, mock_config_entry, device, "two_id_power") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices=set(), # both identifiers are untracked + circuit_ids=set(), + check_untracked=True, + ) + + assert entity_registry.async_get(entity_id) is None + assert device_registry.async_get(device.id) is None + + +async def test_cleanup_ignores_identifiers_from_other_domains( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """An identifier from another integration never triggers removal. + + A foreign id is by definition absent from `tracked_devices`, so without the + domain guard the untracked check would delete a device we still track. + """ + mock_config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "chg-mock-1"), ("other_domain", "foreign-id")}, + name="shared device", + ) + entity_id = _add_entity(entity_registry, mock_config_entry, device, "shared_power") + + _cleanup_devices( + hass, + mock_config_entry, + tracked_devices={"chg-mock-1"}, + circuit_ids=set(), + check_untracked=True, + ) + + assert entity_registry.async_get(entity_id) is not None + assert device_registry.async_get(device.id) is not None + + +@pytest.mark.parametrize( + ("manual_select", "configured", "expect_removed"), + [ + # Manual-select mode with every selection present -> the leftover goes. + (True, ["chg-mock-1"], True), + # A selected charger missing from the API response this session -> keep everything. + (True, ["chg-mock-1", "chg-gone-from-api"], False), + # Track-all mode has no deselection to detect, so nothing is ever reaped. + (False, None, False), + ], +) +async def test_setup_entry_cleans_up_deselected_charger( + hass: HomeAssistant, + mock_zaptec: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, + manual_select: bool, + configured: list[str] | None, + expect_removed: bool, +) -> None: + """End-to-end: a full setup reaps a previously-selected charger's leftover device.""" + data: dict[str, object] = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + if manual_select: + data |= {CONF_MANUAL_SELECT: True, CONF_CHARGERS: configured} + entry = MockConfigEntry( + domain=DOMAIN, + title="Mock Zaptec", + data=data, + entry_id="mock_entry_1", + ) + entry.add_to_hass(hass) + # Left behind by a previous session, for a charger since deselected. + stale = _add_device(device_registry, entry, "chg-deselected-1") + stale_entity = _add_entity(entity_registry, entry, stale, "chg-deselected-1_power") + + with patch("custom_components.zaptec.Zaptec", return_value=mock_zaptec): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (device_registry.async_get(stale.id) is None) is expect_removed + assert (entity_registry.async_get(stale_entity) is None) is expect_removed + # The still-selected charger keeps its device either way. + assert device_registry.async_get_device(identifiers={(DOMAIN, "chg-mock-1")}) is not None + # So does its installation: `first_time_setup` keeps the installation of any + # selected charger, so it must never be seen as untracked and reaped. + assert device_registry.async_get_device(identifiers={(DOMAIN, "inst-mock-1")}) is not None diff --git a/tests/zaptec/conftest.py b/tests/zaptec/conftest.py new file mode 100644 index 00000000..2b86570b --- /dev/null +++ b/tests/zaptec/conftest.py @@ -0,0 +1,37 @@ +"""Test configuration for the vendored Zaptec API client (tests/zaptec/*).""" + +import asyncio + +import pytest + +from custom_components.zaptec.zaptec.api import Zaptec + + +@pytest.fixture(scope="session") +def zaptec_constants() -> dict: + """Get latest constants from Zaptec API. + + Uses a self-contained event loop instead of `asyncio.run()`: under + pytest-hacc's `HassEventLoopPolicy`, `asyncio.run()` resets the thread's + event loop to `None` on exit, breaking later `asyncio_mode=auto` tests. + Saving/restoring the previous loop avoids clobbering that global state. + """ + + async def get_zaptec_constants() -> dict: + async with Zaptec("N/A", "N/A") as zaptec: + # the constants API endpoint does not require login + const: dict = await zaptec.request("constants") + return const + + try: + previous_loop = asyncio.get_event_loop() + except RuntimeError: + previous_loop = None + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(get_zaptec_constants()) + finally: + loop.close() + asyncio.set_event_loop(previous_loop)