From 0066880003a3faeee7c43b4c627f189287d45386 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:56:30 +0000 Subject: [PATCH 1/2] Initial plan From 4a9d87c87954836dac6008a184930fb8d7b27de1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:08:07 +0000 Subject: [PATCH 2/2] Add integration tests and move unit tests to tests/unit_tests Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- .github/workflows/integration-tests.yml | 26 + .github/workflows/pylint.yml | 2 +- .github/workflows/test.yml | 2 +- .gitignore | 5 +- pytest.ini | 2 + requirements_integration_tests.txt | 1 + run_tests.sh | 2 +- {test => tests}/__init__.py | 0 .../integration_tests}/__init__.py | 0 tests/integration_tests/conftest.py | 61 ++ tests/integration_tests/test_integration.py | 677 ++++++++++++++++++ .../helpers => tests/unit_tests}/__init__.py | 0 .../homeassistant/__init__.py | 0 .../homeassistant/config_entries.py | 0 .../homeassistant_mock/homeassistant/const.py | 0 .../homeassistant_mock/homeassistant/core.py | 0 .../homeassistant/exceptions.py | 0 .../homeassistant/helpers/__init__.py | 0 .../helpers/config_validation.py | 0 .../homeassistant/helpers/typing.py | 0 .../homeassistant_mock/voluptuous.py | 0 .../unit_tests}/test_async_execute.py | 2 +- .../unit_tests}/test_async_setup.py | 2 +- .../unit_tests}/test_config_flow.py | 2 +- .../unit_tests}/test_coordinator.py | 2 +- .../unit_tests}/test_validate_service_data.py | 2 +- 26 files changed, 778 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 pytest.ini create mode 100644 requirements_integration_tests.txt rename {test => tests}/__init__.py (100%) rename {test/homeassistant_mock/homeassistant => tests/integration_tests}/__init__.py (100%) create mode 100644 tests/integration_tests/conftest.py create mode 100644 tests/integration_tests/test_integration.py rename {test/homeassistant_mock/homeassistant/helpers => tests/unit_tests}/__init__.py (100%) create mode 100644 tests/unit_tests/homeassistant_mock/homeassistant/__init__.py rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/config_entries.py (100%) rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/const.py (100%) rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/core.py (100%) rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/exceptions.py (100%) create mode 100644 tests/unit_tests/homeassistant_mock/homeassistant/helpers/__init__.py rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/helpers/config_validation.py (100%) rename {test => tests/unit_tests}/homeassistant_mock/homeassistant/helpers/typing.py (100%) rename {test => tests/unit_tests}/homeassistant_mock/voluptuous.py (100%) rename {test => tests/unit_tests}/test_async_execute.py (99%) rename {test => tests/unit_tests}/test_async_setup.py (97%) rename {test => tests/unit_tests}/test_config_flow.py (94%) rename {test => tests/unit_tests}/test_coordinator.py (98%) rename {test => tests/unit_tests}/test_validate_service_data.py (96%) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..7707b24 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,26 @@ +name: Integration Tests + +on: [push] + +jobs: + integration-test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v3 + with: + python-version: "3.14" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_integration_tests.txt + + - name: Run integration tests + run: | + pytest tests/integration_tests/ -v diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 1c18233..f8934da 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -27,4 +27,4 @@ jobs: - name: Analysing the code with pylint run: | - pylint --fail-under=9 --max-line-length=120 --disable=import-error,wrong-import-position,wrong-import-order,too-many-instance-attributes,too-many-locals,too-many-arguments,too-few-public-methods --ignore-paths=^test/.*$ $(git ls-files '*.py') + pylint --fail-under=9 --max-line-length=120 --disable=import-error,wrong-import-position,wrong-import-order,too-many-instance-attributes,too-many-locals,too-many-arguments,too-few-public-methods --ignore-paths=^tests/.*$ $(git ls-files '*.py') diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7f326b..aed93ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,4 +27,4 @@ jobs: - name: Run tests run: | - python -m unittest -v + python -m unittest discover -s tests/unit_tests -v diff --git a/.gitignore b/.gitignore index 4472829..588ff39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -/htmlcov/ -/.coverage __pycache__/ *.pyc +/htmlcov/ +/.coverage +custom_components/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/requirements_integration_tests.txt b/requirements_integration_tests.txt new file mode 100644 index 0000000..7a039d5 --- /dev/null +++ b/requirements_integration_tests.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component==0.13.318 diff --git a/run_tests.sh b/run_tests.sh index fc17ec3..e63db2f 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,3 +1,3 @@ #!/bin/bash -coverage run --omit='test/*' -m unittest; coverage html +coverage run --omit='tests/unit_tests/*' -m unittest discover -s tests/unit_tests; coverage html diff --git a/test/__init__.py b/tests/__init__.py similarity index 100% rename from test/__init__.py rename to tests/__init__.py diff --git a/test/homeassistant_mock/homeassistant/__init__.py b/tests/integration_tests/__init__.py similarity index 100% rename from test/homeassistant_mock/homeassistant/__init__.py rename to tests/integration_tests/__init__.py diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py new file mode 100644 index 0000000..decc368 --- /dev/null +++ b/tests/integration_tests/conftest.py @@ -0,0 +1,61 @@ +"""pytest-homeassistant-custom-component conftest for SSH Command integration tests. + +This file wires the component (which lives at the repo root, not inside a +conventional ``custom_components/`` sub-directory) into Home Assistant's custom +component loader, and provides shared fixtures used by all integration tests. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- + +# The repo root IS the component package (content_in_root: true). +REPO_ROOT = Path(__file__).parent.parent.parent + +# Make the repo root importable so Python can see it as a top-level package. +_REPO_PARENT = str(REPO_ROOT.parent) +if _REPO_PARENT not in sys.path: + sys.path.insert(0, _REPO_PARENT) + +# --------------------------------------------------------------------------- +# custom_components symlink +# --------------------------------------------------------------------------- +# Home Assistant's component loader imports the ``custom_components`` package +# and iterates its subdirectories. We create a symlink +# /custom_components/ssh_command -> +# so that HA can discover and load the component as ``custom_components.ssh_command``. + +_CUSTOM_COMPONENTS = REPO_ROOT / "custom_components" +_CUSTOM_COMPONENTS.mkdir(exist_ok=True) +(_CUSTOM_COMPONENTS / "__init__.py").touch() + +_SYMLINK = _CUSTOM_COMPONENTS / "ssh_command" +if not _SYMLINK.exists(): + _SYMLINK.symlink_to(REPO_ROOT) + +# Make custom_components importable from within the repo root. +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def expected_lingering_timers() -> bool: + """Allow any timers registered during setup to linger during teardown.""" + return True + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations for every test in this package.""" diff --git a/tests/integration_tests/test_integration.py b/tests/integration_tests/test_integration.py new file mode 100644 index 0000000..9098af9 --- /dev/null +++ b/tests/integration_tests/test_integration.py @@ -0,0 +1,677 @@ +"""Integration tests for the SSH Command custom component. + +These tests use ``pytest-homeassistant-custom-component`` which spins up a +real (in-process) Home Assistant instance per test. No hand-rolled mocks +are needed for the HA side: the ``hass`` fixture IS a real ``HomeAssistant`` +object, and services behave exactly as they do at runtime. + +The SSH layer (asyncssh) is patched out so tests run without a real SSH server. + +Run with: + pytest tests/integration_tests/ -v +""" + +from __future__ import annotations + +import socket +import tempfile +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from asyncssh import HostKeyNotVerifiable, PermissionDenied + +from custom_components.ssh_command.const import ( + CONF_CHECK_KNOWN_HOSTS, + CONF_ERROR, + CONF_EXIT_STATUS, + CONF_INPUT, + CONF_KEY_FILE, + CONF_KNOWN_HOSTS, + CONF_OUTPUT, + DOMAIN, + SERVICE_EXECUTE, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entry(entry_id: str = "entry1") -> MockConfigEntry: + """Return a MockConfigEntry for SSH Command.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id=entry_id, + title="SSH Command", + data={}, + version=1, + ) + + +async def _setup_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Add *entry* to hass and wait for setup to complete.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +class _MockConnect: + def __init__(self, conn): + self._conn = conn + + async def __aenter__(self): + return self._conn + + async def __aexit__(self, *args): + return None + + +class _MockConnectRaises: + def __init__(self, exc): + self._exc = exc + + async def __aenter__(self): + raise self._exc + + async def __aexit__(self, *args): + return None + + +def _make_mock_conn(stdout="", stderr="", exit_status=0): + """Return a mock SSH connection that returns the given result.""" + mock_result = MagicMock() + mock_result.stdout = stdout + mock_result.stderr = stderr + mock_result.exit_status = exit_status + mock_conn = AsyncMock() + mock_conn.run = AsyncMock(return_value=mock_result) + return mock_conn + + +# Service call data for a minimal valid execute request. +SERVICE_DATA_BASE = { + "host": "192.0.2.1", + "username": "user", + "password": "secret", + "command": "echo hello", + "check_known_hosts": False, +} + + +# --------------------------------------------------------------------------- +# Entry setup +# --------------------------------------------------------------------------- + + +class TestSetupEntry: + """Config entry setup creates the expected coordinator.""" + + async def test_coordinator_stored_in_hass_data(self, hass: HomeAssistant) -> None: + from custom_components.ssh_command.coordinator import SshCommandCoordinator + + entry = _make_entry(entry_id="e1") + await _setup_entry(hass, entry) + + coordinator = hass.data[DOMAIN]["e1"] + assert isinstance(coordinator, SshCommandCoordinator) + + async def test_coordinator_holds_hass_reference(self, hass: HomeAssistant) -> None: + entry = _make_entry(entry_id="e1") + await _setup_entry(hass, entry) + + coordinator = hass.data[DOMAIN]["e1"] + assert coordinator.hass is hass + + async def test_service_registered_after_setup(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + assert hass.services.has_service(DOMAIN, SERVICE_EXECUTE) + + async def test_multiple_entries_not_allowed(self, hass: HomeAssistant) -> None: + """SSH Command is single-instance; a second flow attempt is aborted.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + await hass.async_block_till_done() + assert result["type"] == "create_entry" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + await hass.async_block_till_done() + assert result2["type"] == "abort" + assert result2["reason"] == "single_instance_allowed" + + +# --------------------------------------------------------------------------- +# Entry unload +# --------------------------------------------------------------------------- + + +class TestUnloadEntry: + """Unloading a config entry removes the coordinator from hass.data.""" + + async def test_unload_removes_coordinator(self, hass: HomeAssistant) -> None: + entry = _make_entry(entry_id="e1") + await _setup_entry(hass, entry) + assert "e1" in hass.data[DOMAIN] + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert "e1" not in hass.data.get(DOMAIN, {}) + + async def test_unload_returns_true(self, hass: HomeAssistant) -> None: + entry = _make_entry(entry_id="e1") + await _setup_entry(hass, entry) + + result = await hass.config_entries.async_unload(entry.entry_id) + assert result is True + + +# --------------------------------------------------------------------------- +# Config flow +# --------------------------------------------------------------------------- + + +class TestConfigFlow: + """The config flow creates a single entry.""" + + async def test_creates_entry_on_first_setup(self, hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "SSH Command" + assert result["data"] == {} + + async def test_aborts_on_second_setup(self, hass: HomeAssistant) -> None: + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +# --------------------------------------------------------------------------- +# Execute service — success cases +# --------------------------------------------------------------------------- + + +class TestExecuteServiceSuccess: + """The execute service returns stdout/stderr/exit_status on success.""" + + async def test_execute_returns_stdout(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn(stdout="hello\n", stderr="", exit_status=0) + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + result = await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert result[CONF_OUTPUT] == "hello\n" + assert result[CONF_ERROR] == "" + assert result[CONF_EXIT_STATUS] == 0 + + async def test_execute_returns_stderr(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn(stdout="", stderr="some error", exit_status=1) + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + result = await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert result[CONF_ERROR] == "some error" + assert result[CONF_EXIT_STATUS] == 1 + + async def test_execute_with_password_auth(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn(stdout="ok") + data = {**SERVICE_DATA_BASE, "password": "mysecret"} + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["password"] == "mysecret" + + async def test_execute_with_key_file_auth(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn(stdout="ok") + data = { + "host": "192.0.2.1", + "username": "user", + "key_file": "/home/user/.ssh/id_rsa", + "command": "echo hi", + "check_known_hosts": False, + } + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=True): + with patch("custom_components.ssh_command.exists", return_value=True): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["client_keys"] == "/home/user/.ssh/id_rsa" + + async def test_execute_with_inline_input(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn(stdout="ok") + data = {**SERVICE_DATA_BASE, "input": "inline input"} + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_conn.run.call_args[1] + assert call_kwargs["input"] == "inline input" + + async def test_execute_with_input_file(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf: + tf.write("file content\n") + tf_path = tf.name + + try: + mock_conn = _make_mock_conn(stdout="ok") + data = {**SERVICE_DATA_BASE, "command": "cat", "input": tf_path} + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=True): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_conn.run.call_args[1] + assert call_kwargs["input"] == "file content\n" + finally: + os.unlink(tf_path) + + async def test_execute_with_custom_timeout(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn() + data = {**SERVICE_DATA_BASE, "timeout": 60} + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_conn.run.call_args[1] + assert call_kwargs["timeout"] == 60 + + +# --------------------------------------------------------------------------- +# Execute service — known hosts settings +# --------------------------------------------------------------------------- + + +class TestExecuteServiceKnownHosts: + """Known hosts options are forwarded correctly to the SSH connection.""" + + async def test_check_known_hosts_false_passes_none(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn() + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, # has check_known_hosts: False + blocking=True, + return_response=True, + ) + + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["known_hosts"] is None + + async def test_check_known_hosts_true_with_custom_file(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn() + mock_known_hosts = MagicMock() + data = { + **SERVICE_DATA_BASE, + "check_known_hosts": True, + "known_hosts": "/etc/ssh/known_hosts", + } + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=True): + with patch("custom_components.ssh_command.coordinator.read_known_hosts", + return_value=mock_known_hosts) as mock_rkh: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + mock_rkh.assert_called_once_with("/etc/ssh/known_hosts") + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["known_hosts"] is mock_known_hosts + + async def test_check_known_hosts_true_with_missing_file(self, hass: HomeAssistant) -> None: + """If the known_hosts file does not exist the path is forwarded as-is.""" + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn() + data = { + **SERVICE_DATA_BASE, + "check_known_hosts": True, + "known_hosts": "/nonexistent/known_hosts", + } + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_connect.call_args[1] + assert call_kwargs["known_hosts"] == "/nonexistent/known_hosts" + + async def test_check_known_hosts_true_uses_default_path_when_missing( + self, hass: HomeAssistant + ) -> None: + """Without a known_hosts path, the default ~/.ssh/known_hosts path is used.""" + entry = _make_entry() + await _setup_entry(hass, entry) + + mock_conn = _make_mock_conn() + data = {**SERVICE_DATA_BASE, "check_known_hosts": True} + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnect(mock_conn)) as mock_connect: + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + data, + blocking=True, + return_response=True, + ) + + call_kwargs = mock_connect.call_args[1] + known_hosts = call_kwargs["known_hosts"] + assert isinstance(known_hosts, str) + assert ".ssh" in known_hosts + assert "known_hosts" in known_hosts + + +# --------------------------------------------------------------------------- +# Execute service — validation errors +# --------------------------------------------------------------------------- + + +class TestExecuteServiceValidation: + """Validation errors prevent execution and surface helpful translation keys.""" + + async def test_no_password_no_key_file_raises(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + {"host": "192.0.2.1", "username": "user", "command": "ls"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "password_or_key_file_required" + + async def test_no_command_no_input_raises(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + {"host": "192.0.2.1", "username": "user", "password": "secret"}, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "command_or_input" + + async def test_key_file_not_found_raises(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.exists", return_value=False): + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + { + "host": "192.0.2.1", + "username": "user", + "key_file": "/nonexistent/key", + "command": "ls", + "check_known_hosts": False, + }, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "key_file_not_found" + + async def test_known_hosts_with_check_disabled_raises(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + { + "host": "192.0.2.1", + "username": "user", + "password": "secret", + "command": "ls", + "known_hosts": "/etc/ssh/known_hosts", + "check_known_hosts": False, + }, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "known_hosts_with_check_disabled" + + async def test_integration_not_set_up_raises(self, hass: HomeAssistant) -> None: + """Without a config entry the coordinator is absent → service raises.""" + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "integration_not_set_up" + + +# --------------------------------------------------------------------------- +# Execute service — SSH error cases +# --------------------------------------------------------------------------- + + +class TestExecuteServiceErrors: + """SSH error conditions surface as ServiceValidationError.""" + + async def test_host_key_not_verifiable(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnectRaises(HostKeyNotVerifiable("test"))): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "host_key_not_verifiable" + + async def test_permission_denied(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnectRaises(PermissionDenied("auth failed"))): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "login_failed" + + async def test_timeout(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnectRaises(TimeoutError())): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "connection_timed_out" + + async def test_host_not_reachable(self, hass: HomeAssistant) -> None: + err = socket.gaierror("Name or service not known") + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnectRaises(err)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_key == "host_not_reachable" + + async def test_other_oserror_is_reraised(self, hass: HomeAssistant) -> None: + err = OSError("something else") + entry = _make_entry() + await _setup_entry(hass, entry) + + with patch("custom_components.ssh_command.coordinator.connect", + return_value=_MockConnectRaises(err)): + with patch("custom_components.ssh_command.coordinator.exists", return_value=False): + with pytest.raises(OSError): + await hass.services.async_call( + DOMAIN, + SERVICE_EXECUTE, + SERVICE_DATA_BASE, + blocking=True, + return_response=True, + ) diff --git a/test/homeassistant_mock/homeassistant/helpers/__init__.py b/tests/unit_tests/__init__.py similarity index 100% rename from test/homeassistant_mock/homeassistant/helpers/__init__.py rename to tests/unit_tests/__init__.py diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/__init__.py b/tests/unit_tests/homeassistant_mock/homeassistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/homeassistant_mock/homeassistant/config_entries.py b/tests/unit_tests/homeassistant_mock/homeassistant/config_entries.py similarity index 100% rename from test/homeassistant_mock/homeassistant/config_entries.py rename to tests/unit_tests/homeassistant_mock/homeassistant/config_entries.py diff --git a/test/homeassistant_mock/homeassistant/const.py b/tests/unit_tests/homeassistant_mock/homeassistant/const.py similarity index 100% rename from test/homeassistant_mock/homeassistant/const.py rename to tests/unit_tests/homeassistant_mock/homeassistant/const.py diff --git a/test/homeassistant_mock/homeassistant/core.py b/tests/unit_tests/homeassistant_mock/homeassistant/core.py similarity index 100% rename from test/homeassistant_mock/homeassistant/core.py rename to tests/unit_tests/homeassistant_mock/homeassistant/core.py diff --git a/test/homeassistant_mock/homeassistant/exceptions.py b/tests/unit_tests/homeassistant_mock/homeassistant/exceptions.py similarity index 100% rename from test/homeassistant_mock/homeassistant/exceptions.py rename to tests/unit_tests/homeassistant_mock/homeassistant/exceptions.py diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/helpers/__init__.py b/tests/unit_tests/homeassistant_mock/homeassistant/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/homeassistant_mock/homeassistant/helpers/config_validation.py b/tests/unit_tests/homeassistant_mock/homeassistant/helpers/config_validation.py similarity index 100% rename from test/homeassistant_mock/homeassistant/helpers/config_validation.py rename to tests/unit_tests/homeassistant_mock/homeassistant/helpers/config_validation.py diff --git a/test/homeassistant_mock/homeassistant/helpers/typing.py b/tests/unit_tests/homeassistant_mock/homeassistant/helpers/typing.py similarity index 100% rename from test/homeassistant_mock/homeassistant/helpers/typing.py rename to tests/unit_tests/homeassistant_mock/homeassistant/helpers/typing.py diff --git a/test/homeassistant_mock/voluptuous.py b/tests/unit_tests/homeassistant_mock/voluptuous.py similarity index 100% rename from test/homeassistant_mock/voluptuous.py rename to tests/unit_tests/homeassistant_mock/voluptuous.py diff --git a/test/test_async_execute.py b/tests/unit_tests/test_async_execute.py similarity index 99% rename from test/test_async_execute.py rename to tests/unit_tests/test_async_execute.py index bc280c6..b7993ad 100644 --- a/test/test_async_execute.py +++ b/tests/unit_tests/test_async_execute.py @@ -9,7 +9,7 @@ absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) -absolute_plugin_path = str(Path(__file__).parent.parent.parent.absolute()) +absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) from asyncssh import HostKeyNotVerifiable, PermissionDenied diff --git a/test/test_async_setup.py b/tests/unit_tests/test_async_setup.py similarity index 97% rename from test/test_async_setup.py rename to tests/unit_tests/test_async_setup.py index c044cfe..b5071b7 100644 --- a/test/test_async_setup.py +++ b/tests/unit_tests/test_async_setup.py @@ -6,7 +6,7 @@ absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) -absolute_plugin_path = str(Path(__file__).parent.parent.parent.absolute()) +absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) from ssh_command import async_setup, async_setup_entry, async_unload_entry diff --git a/test/test_config_flow.py b/tests/unit_tests/test_config_flow.py similarity index 94% rename from test/test_config_flow.py rename to tests/unit_tests/test_config_flow.py index eae9583..67bccf3 100644 --- a/test/test_config_flow.py +++ b/tests/unit_tests/test_config_flow.py @@ -6,7 +6,7 @@ absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) -absolute_plugin_path = str(Path(__file__).parent.parent.parent.absolute()) +absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) from ssh_command.config_flow import SshCommandConfigFlow diff --git a/test/test_coordinator.py b/tests/unit_tests/test_coordinator.py similarity index 98% rename from test/test_coordinator.py rename to tests/unit_tests/test_coordinator.py index b6d6cb8..9bd4c41 100644 --- a/test/test_coordinator.py +++ b/tests/unit_tests/test_coordinator.py @@ -7,7 +7,7 @@ absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) -absolute_plugin_path = str(Path(__file__).parent.parent.parent.absolute()) +absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) from asyncssh import HostKeyNotVerifiable, PermissionDenied diff --git a/test/test_validate_service_data.py b/tests/unit_tests/test_validate_service_data.py similarity index 96% rename from test/test_validate_service_data.py rename to tests/unit_tests/test_validate_service_data.py index 1ea438d..90b458d 100644 --- a/test/test_validate_service_data.py +++ b/tests/unit_tests/test_validate_service_data.py @@ -6,7 +6,7 @@ absolute_mock_path = str(Path(__file__).parent / "homeassistant_mock") sys.path.insert(0, absolute_mock_path) -absolute_plugin_path = str(Path(__file__).parent.parent.parent.absolute()) +absolute_plugin_path = str(Path(__file__).parent.parent.parent.parent.absolute()) sys.path.insert(0, absolute_plugin_path) from homeassistant.exceptions import ServiceValidationError