562 lines
18 KiB
Python
562 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.sws12500.const import (
|
|
API_ID,
|
|
API_KEY,
|
|
DEV_DBG,
|
|
DOMAIN,
|
|
ECOWITT_ENABLED,
|
|
ECOWITT_WEBHOOK_ID,
|
|
INVALID_CREDENTIALS,
|
|
LEGACY_ENABLED,
|
|
POCASI_CZ_API_ID,
|
|
POCASI_CZ_API_KEY,
|
|
POCASI_CZ_ENABLED,
|
|
POCASI_CZ_LOGGER_ENABLED,
|
|
POCASI_CZ_SEND_INTERVAL,
|
|
POCASI_CZ_SEND_MINIMUM,
|
|
SENSORS_TO_LOAD,
|
|
WINDY_ENABLED,
|
|
WINDY_LOGGER_ENABLED,
|
|
WINDY_STATION_ID,
|
|
WINDY_STATION_PW,
|
|
WSLINK,
|
|
WSLINK_ADDON_PORT,
|
|
)
|
|
from homeassistant import config_entries
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_user_form_then_create_entry(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
"""Online HA: config flow shows form then creates entry and options."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
assert result["type"] == "menu"
|
|
assert result["step_id"] == "user"
|
|
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "pws"
|
|
|
|
user_input = {
|
|
API_ID: "my_id",
|
|
API_KEY: "my_key",
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
}
|
|
result2 = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"], user_input=user_input
|
|
)
|
|
assert result2["type"] == "create_entry"
|
|
assert result2["title"] == DOMAIN
|
|
# The PWS step augments user input with legacy/ecowitt flags.
|
|
assert result2["data"][API_ID] == "my_id"
|
|
assert result2["data"][API_KEY] == "my_key"
|
|
assert result2["data"][LEGACY_ENABLED] is True
|
|
assert result2["data"][ECOWITT_ENABLED] is False
|
|
assert result2["options"] == result2["data"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_user_invalid_credentials_api_id(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
"""API_ID in INVALID_CREDENTIALS -> error on API_ID."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
assert result["type"] == "menu"
|
|
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "pws"
|
|
|
|
user_input = {
|
|
API_ID: INVALID_CREDENTIALS[0],
|
|
API_KEY: "ok_key",
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
}
|
|
result2 = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"], user_input=user_input
|
|
)
|
|
assert result2["type"] == "form"
|
|
assert result2["step_id"] == "pws"
|
|
assert result2["errors"][API_ID] == "valid_credentials_api"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_user_invalid_credentials_api_key(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
"""API_KEY in INVALID_CREDENTIALS -> error on API_KEY."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
assert result["type"] == "menu"
|
|
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "pws"
|
|
|
|
user_input = {
|
|
API_ID: "ok_id",
|
|
API_KEY: INVALID_CREDENTIALS[0],
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
}
|
|
result2 = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"], user_input=user_input
|
|
)
|
|
assert result2["type"] == "form"
|
|
assert result2["step_id"] == "pws"
|
|
assert result2["errors"][API_KEY] == "valid_credentials_key"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_user_invalid_credentials_match(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
"""API_KEY == API_ID -> base error."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
assert result["type"] == "menu"
|
|
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "pws"
|
|
|
|
user_input = {
|
|
API_ID: "same",
|
|
API_KEY: "same",
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
}
|
|
result2 = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"], user_input=user_input
|
|
)
|
|
assert result2["type"] == "form"
|
|
assert result2["step_id"] == "pws"
|
|
assert result2["errors"]["base"] == "valid_credentials_match"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("user_input", "field", "error"),
|
|
[
|
|
({API_ID: "", API_KEY: "ok_key", WSLINK: False, DEV_DBG: False}, API_ID, "valid_credentials_api"),
|
|
({API_ID: "ok_id", API_KEY: "", WSLINK: False, DEV_DBG: False}, API_KEY, "valid_credentials_key"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_user_rejects_empty_pws_credentials(
|
|
hass,
|
|
enable_custom_integrations,
|
|
user_input,
|
|
field,
|
|
error,
|
|
) -> None:
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "pws"}
|
|
)
|
|
|
|
result2 = await hass.config_entries.flow.async_configure(form["flow_id"], user_input=user_input)
|
|
|
|
assert result2["type"] == "form"
|
|
assert result2["step_id"] == "pws"
|
|
assert result2["errors"][field] == error
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
|
|
"""Options flow shows menu with expected steps."""
|
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options={})
|
|
entry.add_to_hass(hass)
|
|
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert result["type"] == "menu"
|
|
assert result["step_id"] == "init"
|
|
assert set(result["menu_options"]) == {"basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_basic_validation_and_create_entry(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={},
|
|
options={
|
|
API_ID: "old",
|
|
API_KEY: "oldkey",
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert init["type"] == "menu"
|
|
|
|
form = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={"next_step_id": "basic"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "basic"
|
|
|
|
# Cover invalid API_ID branch in options flow basic step.
|
|
bad_api_id = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
API_ID: INVALID_CREDENTIALS[0],
|
|
API_KEY: "ok_key",
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
},
|
|
)
|
|
assert bad_api_id["type"] == "form"
|
|
assert bad_api_id["step_id"] == "basic"
|
|
assert bad_api_id["errors"][API_ID] == "valid_credentials_api"
|
|
|
|
# Cover invalid API_KEY branch in options flow basic step.
|
|
bad_api_key = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
API_ID: "ok_id",
|
|
API_KEY: INVALID_CREDENTIALS[0],
|
|
WSLINK: False,
|
|
DEV_DBG: False,
|
|
},
|
|
)
|
|
assert bad_api_key["type"] == "form"
|
|
assert bad_api_key["step_id"] == "basic"
|
|
assert bad_api_key["errors"][API_KEY] == "valid_credentials_key"
|
|
|
|
bad = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={API_ID: "same", API_KEY: "same", WSLINK: False, DEV_DBG: False},
|
|
)
|
|
assert bad["type"] == "form"
|
|
assert bad["step_id"] == "basic"
|
|
assert bad["errors"]["base"] == "valid_credentials_match"
|
|
|
|
good = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={API_ID: "new", API_KEY: "newkey", WSLINK: True, DEV_DBG: True},
|
|
)
|
|
assert good["type"] == "create_entry"
|
|
assert good["title"] == DOMAIN
|
|
assert good["data"][API_ID] == "new"
|
|
assert good["data"][API_KEY] == "newkey"
|
|
assert good["data"][WSLINK] is True
|
|
assert good["data"][DEV_DBG] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_windy_requires_keys_when_enabled(
|
|
hass, enable_custom_integrations
|
|
) -> None:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={},
|
|
options={
|
|
WINDY_ENABLED: False,
|
|
WINDY_LOGGER_ENABLED: False,
|
|
WINDY_STATION_ID: "",
|
|
WINDY_STATION_PW: "",
|
|
},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
form = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={"next_step_id": "windy"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "windy"
|
|
|
|
bad = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
WINDY_ENABLED: True,
|
|
WINDY_LOGGER_ENABLED: False,
|
|
WINDY_STATION_ID: "",
|
|
WINDY_STATION_PW: "",
|
|
},
|
|
)
|
|
assert bad["type"] == "form"
|
|
assert bad["step_id"] == "windy"
|
|
assert bad["errors"][WINDY_STATION_ID] == "windy_key_required"
|
|
|
|
good = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
WINDY_ENABLED: True,
|
|
WINDY_LOGGER_ENABLED: True,
|
|
WINDY_STATION_ID: "sid",
|
|
WINDY_STATION_PW: "spw",
|
|
},
|
|
)
|
|
assert good["type"] == "create_entry"
|
|
assert good["data"][WINDY_ENABLED] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_pocasi_validation_minimum_interval_and_required_keys(
|
|
hass,
|
|
enable_custom_integrations,
|
|
) -> None:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={},
|
|
options={
|
|
POCASI_CZ_API_ID: "",
|
|
POCASI_CZ_API_KEY: "",
|
|
POCASI_CZ_ENABLED: False,
|
|
POCASI_CZ_LOGGER_ENABLED: False,
|
|
POCASI_CZ_SEND_INTERVAL: 30,
|
|
},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
form = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={"next_step_id": "pocasi"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "pocasi"
|
|
|
|
bad = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
POCASI_CZ_API_ID: "",
|
|
POCASI_CZ_API_KEY: "",
|
|
POCASI_CZ_ENABLED: True,
|
|
POCASI_CZ_LOGGER_ENABLED: False,
|
|
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM - 1,
|
|
},
|
|
)
|
|
assert bad["type"] == "form"
|
|
assert bad["step_id"] == "pocasi"
|
|
assert bad["errors"][POCASI_CZ_SEND_INTERVAL] == "pocasi_send_minimum"
|
|
assert bad["errors"][POCASI_CZ_API_ID] == "pocasi_id_required"
|
|
assert bad["errors"][POCASI_CZ_API_KEY] == "pocasi_key_required"
|
|
|
|
good = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
POCASI_CZ_API_ID: "pid",
|
|
POCASI_CZ_API_KEY: "pkey",
|
|
POCASI_CZ_ENABLED: True,
|
|
POCASI_CZ_LOGGER_ENABLED: True,
|
|
POCASI_CZ_SEND_INTERVAL: POCASI_CZ_SEND_MINIMUM,
|
|
},
|
|
)
|
|
assert good["type"] == "create_entry"
|
|
assert good["data"][POCASI_CZ_ENABLED] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_default(
|
|
hass,
|
|
enable_custom_integrations,
|
|
) -> None:
|
|
"""Online HA: ecowitt step uses get_url() placeholders and secrets token when webhook id missing."""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={},
|
|
options={
|
|
ECOWITT_WEBHOOK_ID: "",
|
|
ECOWITT_ENABLED: False,
|
|
# Ecowitt can only be turned on when the legacy endpoint is off.
|
|
LEGACY_ENABLED: False,
|
|
},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
# A URL without a host degrades to the "UNKNOWN" placeholder rather than raising.
|
|
# (`yarl.URL.host` is a read-only cached property, so the fallback has to build a
|
|
# new value instead of assigning to it.) This runs in its own flow because showing
|
|
# the form advances past the menu.
|
|
hostless_init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert hostless_init["type"] == "menu"
|
|
|
|
with patch(
|
|
"custom_components.sws12500.config_flow.get_url",
|
|
return_value="http://",
|
|
):
|
|
hostless = await hass.config_entries.options.async_configure(
|
|
hostless_init["flow_id"], user_input={"next_step_id": "ecowitt"}
|
|
)
|
|
assert hostless["type"] == "form"
|
|
assert (hostless.get("description_placeholders") or {})["url"] == "UNKNOWN"
|
|
|
|
# A normal URL fills real placeholders and completes the flow.
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert init["type"] == "menu"
|
|
|
|
with patch(
|
|
"custom_components.sws12500.config_flow.get_url",
|
|
return_value="http://example.local:8123",
|
|
):
|
|
form = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={"next_step_id": "ecowitt"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "ecowitt"
|
|
placeholders = form.get("description_placeholders") or {}
|
|
assert placeholders["url"] == "example.local"
|
|
assert placeholders["port"] == "8123"
|
|
assert placeholders["webhook_id"] # generated
|
|
|
|
bad = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
ECOWITT_WEBHOOK_ID: "",
|
|
ECOWITT_ENABLED: True,
|
|
},
|
|
)
|
|
assert bad["type"] == "form"
|
|
assert bad["errors"][ECOWITT_WEBHOOK_ID] == "ecowitt_webhook_required"
|
|
|
|
done = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
|
|
ECOWITT_ENABLED: True,
|
|
},
|
|
)
|
|
assert done["type"] == "create_entry"
|
|
assert done["data"][ECOWITT_ENABLED] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_wslink_port_setup(hass, enable_custom_integrations) -> None:
|
|
"""The WSLink add-on port step shows a form and stores the port."""
|
|
# A falsy stored port exercises the 443 default fallback.
|
|
entry = MockConfigEntry(domain=DOMAIN, data={}, options={WSLINK_ADDON_PORT: 0})
|
|
entry.add_to_hass(hass)
|
|
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert init["type"] == "menu"
|
|
|
|
form = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={"next_step_id": "wslink_port_setup"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "wslink_port_setup"
|
|
|
|
done = await hass.config_entries.options.async_configure(
|
|
init["flow_id"], user_input={WSLINK_ADDON_PORT: 8443}
|
|
)
|
|
assert done["type"] == "create_entry"
|
|
assert done["data"][WSLINK_ADDON_PORT] == 8443
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integrations) -> None:
|
|
"""Initial config flow: user menu -> ecowitt step creates an Ecowitt-only entry."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
|
)
|
|
assert result["type"] == "menu"
|
|
|
|
with patch(
|
|
"custom_components.sws12500.config_flow.get_url",
|
|
return_value="http://example.local:8123",
|
|
):
|
|
form = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={"next_step_id": "ecowitt"}
|
|
)
|
|
assert form["type"] == "form"
|
|
assert form["step_id"] == "ecowitt"
|
|
placeholders = form.get("description_placeholders") or {}
|
|
assert placeholders["url"] == "example.local"
|
|
assert placeholders["webhook_id"]
|
|
|
|
bad = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"],
|
|
user_input={
|
|
ECOWITT_WEBHOOK_ID: "",
|
|
ECOWITT_ENABLED: True,
|
|
},
|
|
)
|
|
assert bad["type"] == "form"
|
|
assert bad["errors"][ECOWITT_WEBHOOK_ID] == "ecowitt_webhook_required"
|
|
|
|
done = await hass.config_entries.flow.async_configure(
|
|
form["flow_id"],
|
|
user_input={
|
|
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
|
|
ECOWITT_ENABLED: True,
|
|
},
|
|
)
|
|
assert done["type"] == "create_entry"
|
|
assert done["data"][ECOWITT_ENABLED] is True
|
|
assert done["data"][LEGACY_ENABLED] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_flow_does_not_roll_back_concurrent_autodiscovery(
|
|
hass,
|
|
enable_custom_integrations,
|
|
) -> None:
|
|
"""Auto-discovery that lands while the dialog is open must survive the submit.
|
|
|
|
The options flow snapshots the entry when a step opens, but the webhook handler
|
|
appends to SENSORS_TO_LOAD independently. Writing back the snapshot would silently
|
|
drop any sensor discovered in between.
|
|
"""
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={},
|
|
options={
|
|
API_ID: "station",
|
|
API_KEY: "secret",
|
|
LEGACY_ENABLED: True,
|
|
SENSORS_TO_LOAD: ["outside_temp"],
|
|
},
|
|
)
|
|
entry.add_to_hass(hass)
|
|
|
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
|
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
|
|
|
|
# The station starts reporting a new field while the form is on screen.
|
|
hass.config_entries.async_update_entry(
|
|
entry,
|
|
options={**entry.options, SENSORS_TO_LOAD: ["outside_temp", "wind_gust"]},
|
|
)
|
|
|
|
done = await hass.config_entries.options.async_configure(
|
|
init["flow_id"],
|
|
user_input={
|
|
API_ID: "station",
|
|
API_KEY: "secret",
|
|
WSLINK: False,
|
|
LEGACY_ENABLED: True,
|
|
},
|
|
)
|
|
|
|
assert done["type"] == "create_entry"
|
|
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]
|