Files

161 lines
5.9 KiB
Python

"""Tests for NestQuest service declarations and domain-global handlers.
Since Feature 18 the integration is a client of the NestQuest API
service: the only HA service left is the panel completion path
(``nestquest.complete_quest``, a proxy to the API's panel complete
route). Every admin service was removed — the admin surface is the
PWA and the API service's admin routes — so these tests pin the
shrunk surface: services.yaml declares nothing else, setup registers
nothing else, and no removed admin service name may come back.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
DOMAIN,
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
)
SERVICES_PATH = (
Path(__file__).parent.parent
/ "custom_components"
/ "nestquest"
/ "services.yaml"
)
#: The admin services removed in Feature 18. None of these names may
#: be registered as an HA service again — the admin surface is the PWA
#: and the API service's admin routes.
REMOVED_ADMIN_SERVICES = (
"uncomplete_quest",
"create_quest_definition",
"update_quest_definition",
"set_quest_definition_active",
"set_presence_pattern",
"create_presence_override",
"delete_presence_override",
"export_history_csv",
"manage_child",
"regenerate",
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
def _service_section(text: str, name: str) -> str:
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
assert match is not None, f"services.yaml must declare {name!r}"
section = text[match.end() :]
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
if next_service is not None:
section = section[: next_service.start()]
return section
def test_services_yaml_exists() -> None:
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
def test_services_yaml_declares_every_canonical_service() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE)
assert declared == list(DOMAIN_SERVICES) or set(declared) == set(
DOMAIN_SERVICES
)
for name in DOMAIN_SERVICES:
section = _service_section(text, name)
assert re.search(r"description:", section), (
f"{name} must carry a description"
)
def test_services_yaml_no_longer_declares_removed_admin_services() -> None:
"""The admin services are gone from the HA service surface."""
text = SERVICES_PATH.read_text(encoding="utf-8")
for name in REMOVED_ADMIN_SERVICES:
assert not re.search(rf"^{name}:\s*$", text, re.MULTILINE), (
f"removed admin service {name!r} must not be declared"
)
def test_services_yaml_canonical_services_declare_fields() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
for name in DOMAIN_SERVICES:
section = _service_section(text, name)
assert "fields:" in section, f"{name} must declare fields"
async def test_only_complete_quest_registered_after_setup(
hass, make_entry
) -> None:
"""Setup registers exactly the panel completion service — and no
removed admin service under any name."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
for service in DOMAIN_SERVICES:
assert hass.services.has_service(DOMAIN, service), service
for service in REMOVED_ADMIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
for service in DOMAIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_unload_one_of_two_entries_keeps_services(
hass, make_entry
) -> None:
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert await async_unload_entry(hass, entry_a) is True
for service in DOMAIN_SERVICES:
assert hass.services.has_service(DOMAIN, service), service
assert await async_unload_entry(hass, entry_b) is True
for service in DOMAIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {})
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
)
async def test_scheduled_internals_never_call_the_service_layer(
hass, make_entry
) -> None:
"""The daily rollover and startup backfill run the materialization
as direct function calls; they never widen the service gate."""
import custom_components.nestquest as nestquest
assert not hasattr(nestquest, "_run_horizon_materialization_service")
source = Path(nestquest.__file__).read_text(encoding="utf-8")
assert "hass.services.call" not in source, (
"scheduled internals must call the materialization directly, "
"never through hass.services.call"
)