Files
ha-ninstrip/tests/test_config_flow.py
T
Michael Sauer d1db07f522 fix(config_flow): bekanntes Geraet auch an der verkuerzten mDNS-MAC erkennen
Die Firmware wirbt im TXT-Satz nur mit den letzten vier Hex-Stellen der
MAC. Daraus wurde bisher direkt eine Kennung gebaut - die auf keinen
Eintrag passte. Folge: Home Assistant bot nach jedem Neustart dieselben,
laengst eingerichteten Strips erneut an, und beim Hinzufuegen kam
'bereits konfiguriert'.

Vier Stellen sind keine Kennung, aber ein Hinweis: endet genau ein
Eintrag so, ist er gemeint. Bei mehreren bleibt es unentschieden und der
Ablauf fragt nach - vier Stellen sind nicht eindeutig genug, um einen
fremden Eintrag stillschweigend umzuschreiben.

Die Tests sahen das nicht, weil ihr mDNS-Fixture die volle MAC eintrug -
eine Welt, die es am Geraet nicht gibt.
2026-09-26 01:05:25 +02:00

342 lines
13 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
async def test_mdns_mit_verkuerzter_mac_erkennt_das_bekannte_geraet(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
"""Die Firmware wirbt nur mit den letzten vier Hex-Stellen der MAC.
Genau so steht es im TXT-Satz am Geraet (`mac=ad4c`). Wird daraus eine
Kennung gebaut, passt sie auf keinen Eintrag - und Home Assistant bietet
nach jedem Neustart dasselbe, laengst eingerichtete Geraet erneut an.
"""
eintrag = bestehender_eintrag(hass)
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(mac="ad4c")
)
assert ergebnis["type"] is FlowResultType.ABORT
assert ergebnis["reason"] == "already_configured"
assert eintrag.unique_id == MAC_K1Y
async def test_mdns_mit_verkuerzter_mac_bleibt_bei_zwei_treffern_unentschieden(
hass: HomeAssistant, mock_client: MagicMock
) -> None:
"""Vier Hex-Stellen sind nicht eindeutig.
Enden zwei eingerichtete Geraete gleich, darf keines davon stillschweigend
auf die neue Adresse umgeschrieben werden - dann wird gefragt.
"""
erster = bestehender_eintrag(hass, "192.168.2.5")
zweiter = MockConfigEntry(
domain=DOMAIN,
title="Zwilling",
unique_id="aa:bb:cc:dd:ad:4c",
data={CONF_HOST: "192.168.2.6", CONF_TOKEN: TOKEN},
)
zweiter.add_to_hass(hass)
ergebnis = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(mac="ad4c")
)
assert ergebnis["type"] is FlowResultType.FORM
assert erster.data[CONF_HOST] == "192.168.2.5"
assert zweiter.data[CONF_HOST] == "192.168.2.6"