Files
NestQuest/tests/test_options_flow.py
T

562 lines
21 KiB
Python
Raw Normal View History

"""Tests for the options flow of NestQuest."""
from __future__ import annotations
import ast
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base
from custom_components.nestquest import config_flow
from custom_components.nestquest import options_flow
from custom_components.nestquest.const import (
CONF_API_BASE_URL,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
)
PKG_DIR = Path(options_flow.__file__).parent
STRINGS_PATH = PKG_DIR / "strings.json"
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
VALID_INPUT = {
CONF_PANEL_IDLE_TIMEOUT: 60,
}
#: What a schema-validated submit produces from a bare VALID_INPUT on a
#: fresh entry: the Feature 10 refresh interval, the Feature 18
#: staleness threshold, and the Feature 18 API connection fill from
#: their defaults.
FULL_INPUT = {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
}
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass():
return make_hass()
def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
return make_config_entry(entry_id=entry_id, options=options, data=data)
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
"""Create an options flow instance bound to a stubbed config entry."""
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
flow.hass = SimpleNamespace()
return flow
def _schema_values(schema) -> dict:
"""Extract the per-field default values from a real voluptuous schema."""
return {
marker.schema: marker.default()
for marker in schema.schema
if hasattr(marker, "schema")
}
def test_options_flow_reachable_via_configure_button() -> None:
"""The config flow exposes async_get_options_flow returning the options flow."""
entry = _make_entry()
handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry)
assert isinstance(handler, options_flow.NestQuestOptionsFlow)
assert handler.config_entry is entry
def test_options_flow_get_options_flow_is_ha_callback() -> None:
"""async_get_options_flow is wrapped by the homeassistant.core.callback decorator."""
assert getattr(
config_flow.NestQuestConfigFlow.async_get_options_flow, "__ha_callback__", False
) is True
def test_options_flow_constructs_through_2024_6_surface() -> None:
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
assert isinstance(flow, _options_flow_base())
assert flow.config_entry is entry
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
def test_options_flow_submits_through_2024_6_surface() -> None:
"""The handler can be submitted through the mocked 2024.6-shaped surface."""
entry = _make_entry()
handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry)
result = _run(handler.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert result["data"] == FULL_INPUT
def test_options_flow_shows_form_initially() -> None:
"""The init step shows a form."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(None))
assert result["type"] == "form"
assert result["step_id"] == "init"
def test_options_flow_schema_is_voluptuous() -> None:
"""The form's data_schema is a real voluptuous Schema (no dict fallback)."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(None))
import voluptuous as vol
assert isinstance(result["data_schema"], vol.Schema)
def test_options_flow_prefills_from_defaults() -> None:
"""Without stored options, the form pre-fills with the constant defaults."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL
assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
def test_options_flow_prefills_from_entry_options() -> None:
"""Stored entry options win over entry data and defaults."""
entry = _make_entry(
options={
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
},
data={
CONF_PANEL_IDLE_TIMEOUT: 999,
CONF_UPDATE_INTERVAL: 999,
CONF_SNAPSHOT_STALENESS: 999,
},
)
result = _run(_make_flow(entry).async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
assert values[CONF_UPDATE_INTERVAL] == 90
assert values[CONF_SNAPSHOT_STALENESS] == 450
def test_options_flow_falls_back_to_entry_data() -> None:
"""Pre-upgrade entries without options fall back to entry.data."""
entry = _make_entry(
data={
CONF_PANEL_IDLE_TIMEOUT: 90,
CONF_UPDATE_INTERVAL: 60,
CONF_SNAPSHOT_STALENESS: 240,
}
)
result = _run(_make_flow(entry).async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
assert values[CONF_UPDATE_INTERVAL] == 60
assert values[CONF_SNAPSHOT_STALENESS] == 240
def test_options_flow_schema_applies_stored_defaults() -> None:
"""A real voluptuous validate({}) fills every field from the stored defaults."""
entry = _make_entry(
options={
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
CONF_API_BASE_URL: "http://panel.lan:8443",
CONF_PANEL_TOKEN: "stored-token",
}
)
result = _run(_make_flow(entry).async_step_init(None))
assert result["data_schema"]({}) == {
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
CONF_API_BASE_URL: "http://panel.lan:8443",
CONF_PANEL_TOKEN: "stored-token",
}
def test_options_flow_schema_coerces_field_types() -> None:
"""The schema validates the expected field types via voluptuous."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(None))
schema = result["data_schema"]
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
assert validators[CONF_UPDATE_INTERVAL] is int
assert validators[CONF_SNAPSHOT_STALENESS] is int
# The Feature 18 API connection fields are free-text strings.
assert validators[CONF_API_BASE_URL] is str
assert validators[CONF_PANEL_TOKEN] is str
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
"""Non-numeric or too-low idle timeouts yield a field-level error."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_PANEL_IDLE_TIMEOUT: bad_timeout})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29])
def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None:
"""Non-numeric, boolean, and below-floor update intervals reject."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"}
def test_options_flow_creates_entry_with_validated_keys() -> None:
"""A valid submit creates an entry with exactly the validated keys
(the client-facing settings, filled from defaults where the raw
submit omitted them)."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert set(result["data"]) == set(FULL_INPUT)
assert result["data"] == FULL_INPUT
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
"""The flow's created data is written to entry.options and reloads exactly once."""
hass, registry = _make_hass()
entry = _make_entry()
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
# Simulate setup registering the lifecycle update listener.
from custom_components.nestquest import async_setup_entry
_run(async_setup_entry(hass, entry))
# Run the options flow and apply its created data to entry.options like HA does.
flow = _make_flow(entry)
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
entry.options = dict(result["data"])
_run(registry.dispatch_options_update(entry))
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
assert entry.options == FULL_INPUT
def test_options_flow_validation_never_raises() -> None:
"""Validation returns errors instead of raising for arbitrary junk input."""
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(junk))
assert result["type"] == "form"
assert result["errors"] == {
CONF_PANEL_IDLE_TIMEOUT: "invalid",
CONF_UPDATE_INTERVAL: "invalid",
}
@pytest.fixture
def strings() -> dict:
"""Load and parse strings.json."""
assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}"
with open(STRINGS_PATH, encoding="utf-8") as f:
return json.load(f)
@pytest.fixture
def translations_en() -> dict:
"""Load and parse translations/en.json."""
assert TRANSLATIONS_EN_PATH.exists(), (
f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}"
)
with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f:
return json.load(f)
def test_options_strings_present_and_matching(strings: dict, translations_en: dict) -> None:
"""Both string files carry identical options step/error strings."""
assert strings.get("options") == translations_en.get("options")
options = strings["options"]
init = options["step"]["init"]
assert init["title"]
assert init["description"]
data = init["data"]
for key in (CONF_PANEL_IDLE_TIMEOUT,):
assert data.get(key), f"Missing field name for '{key}'"
errors = options["errors"]
for key in ("invalid",):
assert errors.get(key), f"Missing error string for '{key}'"
def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> None:
"""No placeholder/raw keys appear in the options strings."""
def check(value) -> None:
if isinstance(value, dict):
for key, val in value.items():
assert val, f"Empty string value for key '{key}'"
check(val)
elif isinstance(value, str):
assert value.strip() != ""
check(strings["options"])
check(translations_en["options"])
def test_options_flow_no_plain_dict_schema_fallback() -> None:
"""No try/except ImportError fallback for voluptuous in the options flow module."""
py_file = Path(options_flow.__file__)
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Try):
handlers = node.handlers or []
has_import_error_handler = any(
getattr(handler.type, "id", getattr(handler.type, "attr", None))
== "ImportError"
for handler in handlers
)
assert not has_import_error_handler, (
f"Plain-dict schema fallback (try/except ImportError) found in {py_file.name}:{node.lineno}"
)
def test_options_flow_subclasses_options_flow_with_config_entry() -> None:
"""NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry."""
base = _options_flow_base()
assert issubclass(options_flow.NestQuestOptionsFlow, base)
assert options_flow.NestQuestOptionsFlow.__bases__ == (base,)
def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
"""The options flow module does not hardcode the domain or db filename."""
py_file = Path(options_flow.__file__)
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
assert node.value != DOMAIN, (
f"Hard-coded domain string in {py_file.name}:{node.lineno}"
)
assert node.value != "nestquest.db", (
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
)
# ---------------------------------------------------------------------------
# Panel-plane API connection section (Feature 18)
# ---------------------------------------------------------------------------
def test_options_flow_api_section_defaults_saved() -> None:
"""A bare submit saves the API connection defaults: the const base
URL and an EMPTY panel token (no token is ever invented)."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL
assert result["data"][CONF_PANEL_TOKEN] == ""
def test_options_flow_api_base_url_must_be_full_http_url() -> None:
"""Scheme-less, host-less, empty, and non-string base URLs reject
with a field error — the same rule the client's constructor
enforces, so a malformed URL fails at the form, not at the first
request."""
flow = _make_flow(_make_entry())
for bad_url in ("api.lan:8000", "http://", "", " ", 42):
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_API_BASE_URL: bad_url}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"}
def test_options_flow_panel_token_validation() -> None:
"""The EMPTY token is valid (the API connection is simply not
configured yet); padded and non-string values reject — a padded
paste would fail the token check at request time instead."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""})
)
assert result["type"] == "create_entry"
assert result["data"][CONF_PANEL_TOKEN] == ""
for bad_token in (" secret ", " ", 42):
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_PANEL_TOKEN: bad_token}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"}
def test_options_flow_api_section_stored_values_round_trip() -> None:
"""Stored API settings are the form's defaults and a raw legacy
submit without them keeps the stored values."""
entry = _make_entry(
options={
**VALID_INPUT,
CONF_API_BASE_URL: "http://panel.lan:8443",
CONF_PANEL_TOKEN: "stored-token",
}
)
flow = _make_flow(entry)
form = _run(flow.async_step_init(None))
schema_defaults = form["data_schema"]({})
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
# A raw submit carrying only the panel-idle key keeps the stored
# API connection settings.
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443"
assert result["data"][CONF_PANEL_TOKEN] == "stored-token"
def test_options_flow_strings_cover_api_section(
strings: dict, translations_en: dict
) -> None:
"""Both string files name the API fields and the URL error."""
data = strings["options"]["step"]["init"]["data"]
for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
assert data.get(key), f"Missing field name for '{key}'"
assert strings["options"]["errors"]["invalid_url"]
assert translations_en["options"] == strings["options"]
# ---------------------------------------------------------------------------
# Last-good snapshot staleness section (Feature 18)
# ---------------------------------------------------------------------------
def test_options_flow_staleness_below_update_interval_refused() -> None:
"""A staleness threshold shorter than the submit's update interval
is refused with the dedicated error: such a threshold could never
serve even one cached snapshot."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_UPDATE_INTERVAL: 300,
CONF_SNAPSHOT_STALENESS: 60,
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
def test_options_flow_staleness_below_stored_interval_refused() -> None:
"""A raw submit setting the staleness but omitting the update
interval is checked against the STORED interval, not the default."""
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
flow = _make_flow(entry)
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
def test_options_flow_staleness_must_be_positive_int() -> None:
"""Non-int, boolean, and non-positive staleness values reject with
the plain invalid error."""
flow = _make_flow(_make_entry())
for bad_staleness in ("900", True, 0, -10):
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
def test_options_flow_staleness_equal_to_interval_saves() -> None:
"""A threshold exactly as long as the update interval is valid (the
boundary is inclusive)."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_UPDATE_INTERVAL: 300,
CONF_SNAPSHOT_STALENESS: 300,
}
)
)
assert result["type"] == "create_entry"
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
def test_options_flow_staleness_defaults_saved() -> None:
"""A bare submit saves the staleness default (three update
intervals) alongside the other defaults."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
def test_options_flow_staleness_stored_values_round_trip() -> None:
"""A stored staleness is the form's default and a raw legacy submit
without it keeps the stored value."""
entry = _make_entry(
options={
**VALID_INPUT,
CONF_UPDATE_INTERVAL: 300,
CONF_SNAPSHOT_STALENESS: 1200,
}
)
flow = _make_flow(entry)
form = _run(flow.async_step_init(None))
schema_defaults = form["data_schema"]({})
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
# A raw submit carrying only the panel-idle key keeps the stored
# staleness (and never trips the interval cross-check).
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
def test_options_flow_strings_cover_staleness_section(
strings: dict, translations_en: dict
) -> None:
"""Both string files name the staleness field and its error."""
data = strings["options"]["step"]["init"]["data"]
assert data.get(CONF_SNAPSHOT_STALENESS), (
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
)
assert strings["options"]["errors"]["invalid_staleness"]
assert translations_en["options"] == strings["options"]