Files
ha-ninstrip/tests/test_config_flow.py
T
Michael Sauer 9eb7adcdd7 feat: nachgezogen, was ha-ninlight laengst hat
Michaels Befund: die Integration ist nicht dasselbe wie die von ninlight.
Verglichen und die Luecken geschlossen - alle Felder gibt es in der
Firmware schon, sie waren nur nicht bedienbar:

* number: fade_in, fade_out, transition_default in Millisekunden, mit den
  Grenzen der Firmware (10 s, 10 s, 60 s).
* select: Einschaltverhalten (last, on, off).
* switch: bei Aenderung einschalten.
* light: Uebergangszeit beim Ein- und Ausschalten. HA rechnet in Sekunden,
  das Geraet in Millisekunden; laengeres als 60 s wird gekappt, sonst
  wiese das Geraet die ganze Anfrage mit 400 ab. Ohne Angabe wird kein
  Feld geschickt, sonst ueberschriebe jeder Schaltbefehl die Vorgabe.
* sensor: letzter Update-Fehler und Startzeit (Zeitstempel statt einer
  Laufzeit, die jede Abfrage weiterlaeuft), beide ab Werk aus.
* diagnostics.py: Diagnosedaten zum Herunterladen, ohne Token, Adressen,
  MAC und SSID.
* config_flow: Neu-Konfigurieren von Adresse und Token, mit Gegenprobe der
  MAC - ein Tippfehler in der Adresse soll nicht still den falschen
  Eintrag umbiegen.
* Hinweis in den Reparaturen, wenn ein Geraet noch auf dem oeffentlichen
  Werks-Token steht.

Nicht uebernommen: die Auswahl sacn_layout. ninstrip ist immer rgb, ein
Strip hat keinen Masterkanal - eine Auswahl mit einer Option waere nur
Zierde.

241 Tests, 97 %. Die Kappung der Uebergangszeit per Mutation gegengeprueft.
2026-09-23 00:35:12 +02:00

299 lines
12 KiB
Python

"""Einrichtungsablauf: von Hand, ueber mDNS, erneute Anmeldung und Einstellungen."""
from __future__ import annotations
from ipaddress import ip_address
from typing import Any
from unittest.mock import MagicMock
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.ninstrip.api import (
NinstripAuthError,
NinstripConnectionError,
NinstripResponseError,
)
from custom_components.ninstrip.const import (
CONF_DEVICE_BASE_URL,
CONF_SCAN_INTERVAL,
DEFAULT_TOKEN,
DOMAIN,
)
from tests_unit.samples import HOST_K1Y, MAC_K1Y, TOKEN
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
NEUES_TOKEN = "b71e0c9d44a2f6e3"
def zeroconf_info(ip: str = HOST_K1Y, **properties: Any) -> ZeroconfServiceInfo:
props = {"mac": MAC_K1Y, "name": "Stehlampe"} | properties
return ZeroconfServiceInfo(
ip_address=ip_address(ip),
ip_addresses=[ip_address(ip)],
port=3333,
hostname="ninstrip-ad4c.local.",
type="_ninstrip._tcp.local.",
name="ninstrip-ad4c._ninstrip._tcp.local.",
properties={k: v for k, v in props.items() if v is not None},
)
def bestehender_eintrag(hass: HomeAssistant, host: str = HOST_K1Y) -> MockConfigEntry:
eintrag = MockConfigEntry(
domain=DOMAIN, title="Strip", unique_id=MAC_K1Y, data={CONF_HOST: host, CONF_TOKEN: TOKEN}
)
eintrag.add_to_hass(hass)
return eintrag
async def test_eintrag_von_hand(hass: HomeAssistant, mock_client: MagicMock) -> None:
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert ergebnis["type"] is FlowResultType.FORM
assert ergebnis["step_id"] == "user"
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
assert ergebnis["data"] == {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
assert ergebnis["result"].unique_id == MAC_K1Y
# system/info antwortet auch ohne Token - geprueft wird an system/settings.
mock_client.get_settings.assert_awaited_once()
async def test_das_werks_token_ist_vorbelegt(hass: HomeAssistant, mock_client: MagicMock) -> None:
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
schluessel = next(k for k in ergebnis["data_schema"].schema if k == CONF_TOKEN)
assert schluessel.default() == DEFAULT_TOKEN
async def test_der_titel_kommt_vom_geraetenamen(
hass: HomeAssistant, mock_client: MagicMock, strip_info: dict[str, Any]
) -> None:
strip_info["name"] = "Stehlampe"
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
)
assert ergebnis["title"] == "Stehlampe"
async def test_ohne_namen_zaehlen_produkt_und_mac(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
)
assert ergebnis["title"] == "ninstrip ad4c"
@pytest.mark.parametrize(
("methode", "fehler", "erwartet"),
[
("get_settings", NinstripAuthError("401"), "invalid_auth"),
("get_settings", NinstripConnectionError("weg"), "cannot_connect"),
("get_info", NinstripResponseError("kein JSON"), "not_ninstrip"),
("get_settings", RuntimeError("peng"), "unknown"),
],
)
async def test_fehler_und_zweiter_versuch(
hass: HomeAssistant,
mock_client: MagicMock,
methode: str,
fehler: Exception,
erwartet: str,
) -> None:
mock = getattr(mock_client, methode)
urspruenglich = mock.side_effect
mock.side_effect = fehler
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.FORM
assert ergebnis["errors"] == {"base": erwartet}
mock.side_effect = urspruenglich
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
async def test_dasselbe_geraet_unter_neuer_adresse_schreibt_den_host_um(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: "192.168.2.99", CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "already_configured"
assert eintrag.data[CONF_HOST] == "192.168.2.99"
async def test_mdns_fragt_nur_nach_dem_token(hass: HomeAssistant, mock_client: MagicMock) -> None:
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info()
)
assert ergebnis["type"] is FlowResultType.FORM
assert ergebnis["step_id"] == "discovery_confirm"
assert ergebnis["description_placeholders"] == {"name": "Stehlampe", "host": HOST_K1Y}
assert set(ergebnis["data_schema"].schema) == {CONF_TOKEN}
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
assert ergebnis["data"] == {CONF_HOST: HOST_K1Y, CONF_TOKEN: TOKEN}
async def test_mdns_ohne_namen_nimmt_den_dienstnamen(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(name=None)
)
assert ergebnis["description_placeholders"]["name"] == "ninstrip-ad4c"
async def test_mdns_eines_bekannten_geraets_schreibt_den_host_um(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass, "192.168.2.5")
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info()
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "already_configured"
assert eintrag.data[CONF_HOST] == HOST_K1Y
async def test_mdns_ohne_mac_faellt_auf_die_kennung_aus_info_zurueck(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
"""Ohne MAC in den mDNS-Eigenschaften entscheidet erst system/info."""
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(mac=None)
)
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
assert ergebnis["result"].unique_id == MAC_K1Y
async def test_erneute_anmeldung_ersetzt_das_token(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await eintrag.start_reauth_flow(hass)
assert ergebnis["step_id"] == "reauth_confirm"
# HA legt den Eintragstitel als "name" dazu.
assert ergebnis["description_placeholders"]["host"] == HOST_K1Y
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_TOKEN: NEUES_TOKEN}
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "reauth_successful"
assert eintrag.data[CONF_TOKEN] == NEUES_TOKEN
async def test_erneute_anmeldung_mit_falschem_token_bleibt_im_formular(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
mock_client.get_settings.side_effect = NinstripAuthError("401")
ergebnis = await eintrag.start_reauth_flow(hass)
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_TOKEN: "falsch"}
)
assert ergebnis["type"] is FlowResultType.FORM
assert ergebnis["errors"] == {"base": "invalid_auth"}
assert eintrag.data[CONF_TOKEN] == TOKEN
async def test_einstellungen_abfrageabstand_und_adresse(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await hass.config_entries.options.async_init(eintrag.entry_id)
ergebnis = await hass.config_entries.options.async_configure(
ergebnis["flow_id"],
{CONF_SCAN_INTERVAL: 45, CONF_DEVICE_BASE_URL: "http://192.168.1.50:8123/"},
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
assert eintrag.options == {
CONF_SCAN_INTERVAL: 45,
# Der abschliessende Schraegstrich faellt weg, sonst entstuende eine
# Adresse mit zwei Schraegstrichen.
CONF_DEVICE_BASE_URL: "http://192.168.1.50:8123",
}
async def test_einstellungen_ohne_adresse_lassen_sie_weg(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await hass.config_entries.options.async_init(eintrag.entry_id)
ergebnis = await hass.config_entries.options.async_configure(
ergebnis["flow_id"], {CONF_SCAN_INTERVAL: 30}
)
assert ergebnis["type"] is FlowResultType.CREATE_ENTRY
assert CONF_DEVICE_BASE_URL not in eintrag.options
@pytest.mark.parametrize(
("adresse", "fehler"),
[
("https://ha.local:8123", "invalid_base_url"),
("http://ha.local:8123/pfad", "invalid_base_url"),
("http://" + "a" * 183, "base_url_too_long"),
],
)
async def test_einstellungen_weisen_unbrauchbare_adressen_ab(
hass: HomeAssistant, mock_client: MagicMock, adresse: str, fehler: str
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await hass.config_entries.options.async_init(eintrag.entry_id)
ergebnis = await hass.config_entries.options.async_configure(
ergebnis["flow_id"], {CONF_SCAN_INTERVAL: 30, CONF_DEVICE_BASE_URL: adresse}
)
assert ergebnis["type"] is FlowResultType.FORM
assert ergebnis["errors"] == {CONF_DEVICE_BASE_URL: fehler}
async def test_neu_konfigurieren_aendert_adresse_und_token(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
eintrag = bestehender_eintrag(hass)
ergebnis = await eintrag.start_reconfigure_flow(hass)
assert ergebnis["step_id"] == "reconfigure"
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: " 192.168.2.99 ", CONF_TOKEN: NEUES_TOKEN}
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "reconfigure_successful"
assert eintrag.data == {CONF_HOST: "192.168.2.99", CONF_TOKEN: NEUES_TOKEN}
async def test_neu_konfigurieren_lehnt_ein_anderes_geraet_ab(
hass: HomeAssistant, mock_client: MagicMock, strip_info: dict[str, Any]
) -> None:
"""Sonst biegt ein Tippfehler in der Adresse still den falschen Eintrag um."""
eintrag = bestehender_eintrag(hass)
strip_info["mac"] = "b0:81:84:49:ff:ff"
ergebnis = await eintrag.start_reconfigure_flow(hass)
ergebnis = await hass.config_entries.flow.async_configure(
ergebnis["flow_id"], {CONF_HOST: "192.168.2.99", CONF_TOKEN: TOKEN}
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "wrong_device"
assert eintrag.data[CONF_HOST] == HOST_K1Y