ha(H2): исправления по ревью — несколько устройств, suggested values, YAML-import, типы ошибок

- critical: listen_port по умолчанию 0 (эфемерный) — несколько ConfigEntry
  не конфликтуют; тест двух записей на дефолтном порту
- manifest: добавлен обязательный ключ codeowners
- unload: set_log_handler(None) вынесен в executor (не блокирует loop);
  рефкаунт лог-пампа без гонки (старт после успешного setup)
- config flow: возврат введённого в форму при ошибке
  (add_suggested_values_to_schema; секреты исключены), YAML-import подключён
  через async_setup + SOURCE_IMPORT, ProvisionError.kind вместо разбора
  русского текста, валидация device_port/key_id из JSON
- trial: требует получения всех базовых свойств шаблона (не пустой ok),
  безопасное создание Session
- py-ci.sh: отсутствие .venv-ha — ошибка (PY_CI_SKIP_HA=1 для пропуска);
  pytest.ini testpaths
This commit is contained in:
2026-09-29 12:20:37 +03:00
parent 26d1d6d64f
commit da01c607a6
16 changed files with 275 additions and 100 deletions
-4
View File
@@ -1,8 +1,6 @@
"""Фикстуры тестов HA-компонента fglair."""
from __future__ import annotations
import os
import pytest
from helpers import DSN, KEY_ID, LANIP_KEY
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -13,7 +11,6 @@ from custom_components.fglair.const import (
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_TEMPLATE,
DOMAIN,
)
@@ -33,7 +30,6 @@ def entry_data() -> dict:
CONF_LANIP_KEY: LANIP_KEY,
CONF_LANIP_KEY_ID: KEY_ID,
CONF_TEMPLATE: "A",
CONF_LISTEN_PORT: 20000 + (os.getpid() % 20000),
}
+99 -1
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair
@@ -40,6 +41,15 @@ async def _select(hass: HomeAssistant, flow_id: str, option: str):
)
def _suggested(schema, field: str):
for key in schema.schema:
if getattr(key, "schema", None) == field:
description = key.description
if isinstance(description, dict):
return description.get("suggested_value")
return None
async def test_user_menu(hass: HomeAssistant):
result = await _menu(hass)
assert result["type"] == "menu"
@@ -187,7 +197,9 @@ async def test_cloud_flow_success(hass: HomeAssistant):
async def test_cloud_auth_error(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(side_effect=pyfglair.ProvisionError("Ошибка входа (401)")),
AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
@@ -197,3 +209,89 @@ async def test_cloud_auth_error(hass: HomeAssistant):
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"}
async def test_manual_error_keeps_input(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MANUAL_INPUT
)
assert result["errors"] == {"base": "cannot_connect"}
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
assert _suggested(result["data_schema"], "dsn") == DSN
assert _suggested(result["data_schema"], "lanip_key") is None
async def test_cloud_no_devices(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError(
"У аккаунта нет устройств", "no_devices"
)
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "no_devices"}
async def test_yaml_import(hass: HomeAssistant):
payload = {
"ip_address": "192.168.1.70",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "AP-WF1E",
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70"
async def test_yaml_import_invalid(hass: HomeAssistant):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"})
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN) == []
assert not hass.config_entries.flow.async_progress()
async def test_import_json_null_device_port(hass: HomeAssistant):
payload = json.dumps(
{
"ip_address": "192.168.1.71",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": str(KEY_ID),
"device_port": None,
}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
) as trial:
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"config_json": payload}
)
assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80
+31 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import socket
from helpers import free_port
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -24,14 +25,41 @@ async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled):
assert client.cached(Prop.OPERATION_MODE) is None
async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled):
"""Две записи без явного listen_port не конфликтуют (порт 0 → эфемерный)."""
first = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="DSN-1")
second = MockConfigEntry(
domain=DOMAIN,
data={**entry_data, "dsn": "DSN-2"},
unique_id="DSN-2",
)
first.add_to_hass(hass)
assert await hass.config_entries.async_setup(first.entry_id)
await hass.async_block_till_done()
second.add_to_hass(hass)
assert await hass.config_entries.async_setup(second.entry_id)
await hass.async_block_till_done()
assert first.state.name == "LOADED"
assert second.state.name == "LOADED"
first_client = first.runtime_data
assert await hass.config_entries.async_unload(first.entry_id)
assert first_client.cached(Prop.OPERATION_MODE) is None
assert second.state.name == "LOADED"
assert await hass.config_entries.async_unload(second.entry_id)
async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled):
port = free_port()
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
sock.bind(("127.0.0.1", entry_data[CONF_LISTEN_PORT]))
sock.bind(("127.0.0.1", port))
sock.listen(1)
try:
entry = MockConfigEntry(
domain=DOMAIN, data=entry_data, unique_id=entry_data["dsn"]
domain=DOMAIN,
data={**entry_data, CONF_LISTEN_PORT: port},
unique_id=entry_data["dsn"],
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)