Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET, PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree with `ruff format`, and fixes the pre-existing lint and type-check debt those tools surfaced so both run clean. Production-code type fixes include: HA's ConfigFlowResult vs. the generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via cast() instead of an unchecked annotation, converting HA device_class strings to their proper enum types, a resolve_registry callback typed as `object` instead of `DeviceRegistry | None`, and a couple of other narrow correctness fixes (CA key type validation, an index-out-of-bounds false positive from an empty-tuple fallback, a bool/dict argument swap). Test-file fixes are mechanical: narrowing SamsungEntityDescription to the correct subclass via isinstance()/cast() before accessing subclass-only fields, and asserting Optional write_fn/unit_fn fields are set before calling them.
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""Tests for LocalThingsCoordinator.async_raw_write -- the debug-only
|
|
arbitrary-href write primitive backing the options-flow debug panel
|
|
(issue #54). It deliberately bypasses the remote-control block and every
|
|
write_fn/validate_fn, so this only exercises the primitive itself: it
|
|
POSTs exactly the caller's body, reads the href back, and validates its
|
|
inputs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import cbor2
|
|
import pytest
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.localthings.const import (
|
|
CONF_HOST,
|
|
CONF_LEAF_CERT_PEM,
|
|
CONF_LEAF_KEY_PEM,
|
|
CONF_PORT,
|
|
DOMAIN,
|
|
)
|
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
|
|
|
ENTRY_DATA = {
|
|
CONF_HOST: "10.0.0.199",
|
|
CONF_PORT: 49154,
|
|
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
|
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
|
}
|
|
|
|
|
|
class _FakeRawWriteSession:
|
|
"""Stand-in for DtlsCoapSession: records every POST verbatim and
|
|
answers the follow-up GET with a canned representation -- no real
|
|
DTLS/network involved."""
|
|
|
|
def __init__(self, post_code: int = 0x44, get_rep: dict | None = None):
|
|
self.post_calls: list[tuple[list[str], bytes]] = []
|
|
self.get_calls: list[list[str]] = []
|
|
self._post_code = post_code
|
|
self._get_rep = {} if get_rep is None else get_rep
|
|
|
|
def post(self, path_segs, payload, timeout=None):
|
|
self.post_calls.append((list(path_segs), payload))
|
|
return self._post_code, b""
|
|
|
|
def get(self, path_segs, timeout=None):
|
|
self.get_calls.append(list(path_segs))
|
|
return 0x45, cbor2.dumps(self._get_rep)
|
|
|
|
def pace(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data=ENTRY_DATA,
|
|
unique_id="localthings_RAWWRITE-TEST",
|
|
)
|
|
entry.add_to_hass(hass)
|
|
coord = LocalThingsCoordinator(hass, entry)
|
|
# async_raw_write kicks a full refresh after the write; a real refresh
|
|
# would try to poll a session that doesn't exist for this unit test, so
|
|
# replace it with a no-op the same way the reference test setup does.
|
|
coord.async_request_refresh = AsyncMock()
|
|
return coord
|
|
|
|
|
|
async def test_raw_write_splits_href_and_posts_exact_body(coordinator) -> None:
|
|
fake = _FakeRawWriteSession(post_code=0x44, get_rep={"x.field": "after"})
|
|
coordinator._session = fake
|
|
|
|
body = {"x.com.samsung.da.field": "value", "n": 1}
|
|
code, new_rep = await coordinator.async_raw_write("/course/vs/0", body)
|
|
|
|
assert len(fake.post_calls) == 1
|
|
posted_path, posted_bytes = fake.post_calls[0]
|
|
assert posted_path == ["course", "vs", "0"]
|
|
assert cbor2.loads(posted_bytes) == body
|
|
|
|
assert code == 0x44
|
|
assert new_rep == {"x.field": "after"}
|
|
coordinator.async_request_refresh.assert_awaited_once()
|
|
|
|
|
|
async def test_raw_write_reads_href_back_for_ground_truth(coordinator) -> None:
|
|
fake = _FakeRawWriteSession(post_code=0x45, get_rep={"value": "confirmed"})
|
|
coordinator._session = fake
|
|
|
|
code, new_rep = await coordinator.async_raw_write("/washer/vs/0", {"a": 1})
|
|
|
|
assert fake.get_calls == [["washer", "vs", "0"]]
|
|
assert code == 0x45
|
|
assert new_rep == {"value": "confirmed"}
|
|
|
|
|
|
async def test_raw_write_returns_coap_code_from_post_not_get(coordinator) -> None:
|
|
"""The returned code reflects the write's own response, even though a
|
|
follow-up GET (which could carry a different code in principle) runs
|
|
right after it."""
|
|
fake = _FakeRawWriteSession(post_code=0x80, get_rep={"value": 1})
|
|
coordinator._session = fake
|
|
|
|
code, _ = await coordinator.async_raw_write("/test/vs/0", {"a": 1})
|
|
|
|
assert code == 0x80
|
|
|
|
|
|
async def test_raw_write_rejects_empty_dict(coordinator) -> None:
|
|
with pytest.raises(ServiceValidationError):
|
|
await coordinator.async_raw_write("/washer/vs/0", {})
|
|
|
|
|
|
async def test_raw_write_rejects_non_dict_payload(coordinator) -> None:
|
|
with pytest.raises(ServiceValidationError):
|
|
await coordinator.async_raw_write("/washer/vs/0", ["not", "a", "dict"]) # type: ignore[arg-type]
|
|
|
|
|
|
async def test_raw_write_rejects_empty_href(coordinator) -> None:
|
|
with pytest.raises(ServiceValidationError):
|
|
await coordinator.async_raw_write("", {"a": 1})
|
|
|
|
|
|
async def test_raw_write_rejects_root_href(coordinator) -> None:
|
|
with pytest.raises(ServiceValidationError):
|
|
await coordinator.async_raw_write("/", {"a": 1})
|
|
|
|
|
|
async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) -> None:
|
|
"""A rejected call must fail before ever reaching the network -- no
|
|
session needs to be connected at all for validation to run."""
|
|
assert coordinator._session is None
|
|
|
|
with pytest.raises(ServiceValidationError):
|
|
await coordinator.async_raw_write("/washer/vs/0", {})
|
|
|
|
assert coordinator._session is None
|
|
coordinator.async_request_refresh.assert_not_awaited()
|