mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.12.0: Quality Scale conformance (phases 7-8)
- entry.runtime_data (HouseplanData in store.py) instead of hass.data; WS answers not_ready without a loaded entry - test-before-setup (ConfigEntryNotReady), async_unload_entry, async_remove_entry (Lovelace resource cleanup), single_config_entry in manifest - Store minor_version + migration hook; diagnostics.py (redacted); repairs (broken_plan issues, en/ru); system_health.py; strings.json - quality_scale.yaml self-assessment; HA-harness tests (config flow, WS, upload) in CI on py3.13; CI backend job updated
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""Shared fixtures. HA-harness tests are skipped when homeassistant is not installed
|
||||
(the local sandbox has Python 3.10; CI runs them on 3.13 with
|
||||
pytest-homeassistant-custom-component)."""
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import homeassistant # noqa: F401
|
||||
HAS_HA = True
|
||||
except ImportError:
|
||||
HAS_HA = False
|
||||
|
||||
collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"]
|
||||
|
||||
|
||||
if HAS_HA:
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||
"""Allow loading custom_components in the test hass."""
|
||||
yield
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Config flow tests (run in CI with pytest-homeassistant-custom-component)."""
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from custom_components.houseplan.const import CONF_ADMIN_ONLY, DOMAIN
|
||||
|
||||
|
||||
async def test_user_flow_creates_entry(hass: HomeAssistant) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_ADMIN_ONLY: True}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "House Plan"
|
||||
assert result["options"] == {CONF_ADMIN_ONLY: True}
|
||||
|
||||
|
||||
async def test_single_instance(hass: HomeAssistant) -> None:
|
||||
"""manifest single_config_entry must prevent a second entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await hass.config_entries.flow.async_configure(result["flow_id"], user_input={})
|
||||
result2 = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_options_flow(hass: HomeAssistant) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={})
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={CONF_ADMIN_ONLY: True}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert entry.options == {CONF_ADMIN_ONLY: True}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Entry setup/unload tests (CI)."""
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN
|
||||
|
||||
|
||||
async def _setup(hass: HomeAssistant) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def test_setup_creates_runtime_data(hass: HomeAssistant) -> None:
|
||||
entry = await _setup(hass)
|
||||
assert entry.state.value == "loaded"
|
||||
assert entry.runtime_data is not None
|
||||
assert entry.runtime_data.store is not None
|
||||
assert entry.runtime_data.config_store is not None
|
||||
|
||||
|
||||
async def test_unload(hass: HomeAssistant) -> None:
|
||||
entry = await _setup(hass)
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state.value == "not_loaded"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""HTTP upload endpoint tests (CI)."""
|
||||
from aiohttp import FormData
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN
|
||||
|
||||
|
||||
async def _setup(hass: HomeAssistant) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_client()
|
||||
fd = FormData()
|
||||
fd.add_field("marker_id", "m1")
|
||||
fd.add_field("file", b"%PDF-1.4 test", filename="manual.pdf", content_type="application/pdf")
|
||||
resp = await client.post("/api/houseplan/upload", data=fd)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["ok"] and body["url"].startswith("/houseplan_files/files/m1/manual.pdf?v=")
|
||||
|
||||
|
||||
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_client()
|
||||
fd = FormData()
|
||||
fd.add_field("file", b"MZ", filename="evil.exe")
|
||||
resp = await client.post("/api/houseplan/upload", data=fd)
|
||||
assert resp.status == 400
|
||||
assert (await resp.json())["error"] == "bad_ext"
|
||||
|
||||
|
||||
async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_client()
|
||||
fd = FormData()
|
||||
fd.add_field("marker_id", "../../etc")
|
||||
fd.add_field("file", b"x", filename="../..//passwd.txt")
|
||||
resp = await client.post("/api/houseplan/upload", data=fd)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
# both the marker dir and the filename must be flattened to safe names
|
||||
assert ".." not in body["url"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""WebSocket API tests (CI): layout ops, config rev conflict, not_ready gate."""
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from pytest_homeassistant_custom_component.typing import WebSocketGenerator
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN
|
||||
|
||||
|
||||
async def _setup(hass: HomeAssistant) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(domain=DOMAIN, title="House Plan", data={}, options={})
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def test_layout_roundtrip(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["layout"] == {}
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/layout/set", "layout": {"dev1": {"s": "f1", "x": 0.5, "y": 0.5}}}
|
||||
)
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/layout/update", "device_id": "dev2", "pos": {"s": "f1", "x": 0.1, "y": 0.2}}
|
||||
)
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/delete", "device_id": "dev1"})
|
||||
assert (await client.receive_json())["success"]
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
resp = await client.receive_json()
|
||||
assert set(resp["result"]["layout"]) == {"dev2"}
|
||||
|
||||
|
||||
async def test_config_rev_conflict(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
cfg = {"spaces": [], "markers": [], "settings": {}}
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/config/set", "config": cfg, "expected_rev": 0})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["rev"] == 1
|
||||
|
||||
# stale expected_rev must be rejected with `conflict`
|
||||
await client.send_json_auto_id({"type": "houseplan/config/set", "config": cfg, "expected_rev": 0})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "conflict"
|
||||
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
resp = await client.receive_json()
|
||||
assert resp["result"]["rev"] == 1
|
||||
|
||||
|
||||
async def test_not_ready_without_entry(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
|
||||
"""WS commands answer not_ready when the integration has no loaded entry."""
|
||||
# register commands without setting up an entry
|
||||
from custom_components.houseplan import async_setup
|
||||
|
||||
assert await async_setup(hass, {})
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "houseplan/layout/get"})
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "not_ready"
|
||||
|
||||
|
||||
async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None:
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/set", "space_id": "../evil", "ext": "png", "data": "aGk="}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "invalid_space_id"
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "%%%not-base64%%%"}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert not resp["success"] and resp["error"]["code"] == "invalid_data"
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"] and resp["result"]["url"].startswith("/houseplan_files/plans/s1.png?v=")
|
||||
Reference in New Issue
Block a user