2026-09-18 15:59:02 -04:00
|
|
|
"""Tests for NestQuest service declarations and domain-global handlers."""
|
2026-09-17 15:42:10 -04:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-09-18 15:59:02 -04:00
|
|
|
import pytest
|
|
|
|
|
import voluptuous as vol
|
2026-09-19 08:36:14 -04:00
|
|
|
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
2026-09-18 15:59:02 -04:00
|
|
|
|
|
|
|
|
from conftest import wire_entry_to_registry
|
|
|
|
|
|
|
|
|
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
|
|
|
|
from custom_components.nestquest.const import (
|
2026-09-19 08:36:14 -04:00
|
|
|
CONF_ADMIN_USER_IDS,
|
2026-09-18 15:59:02 -04:00
|
|
|
DOMAIN,
|
|
|
|
|
DOMAIN_SERVICES,
|
|
|
|
|
SERVICE_COMPLETE_QUEST,
|
2026-09-18 16:03:42 -04:00
|
|
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
2026-09-18 15:59:02 -04:00
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
2026-09-18 16:03:42 -04:00
|
|
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
2026-09-18 15:59:02 -04:00
|
|
|
SERVICE_EXPORT_HISTORY_CSV,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
SERVICE_REGENERATE,
|
2026-09-18 16:03:42 -04:00
|
|
|
SERVICE_SET_PRESENCE_PATTERN,
|
2026-09-18 15:59:02 -04:00
|
|
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
|
|
|
SERVICE_UNCOMPLETE_QUEST,
|
|
|
|
|
)
|
2026-09-19 08:38:24 -04:00
|
|
|
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
|
2026-09-18 15:59:02 -04:00
|
|
|
|
2026-09-17 15:42:10 -04:00
|
|
|
SERVICES_PATH = (
|
|
|
|
|
Path(__file__).parent.parent
|
|
|
|
|
/ "custom_components"
|
|
|
|
|
/ "nestquest"
|
|
|
|
|
/ "services.yaml"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 15:59:02 -04:00
|
|
|
def _wire(entry, registry):
|
|
|
|
|
return wire_entry_to_registry(entry, registry)
|
|
|
|
|
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
ADMIN_ID = "admin-1"
|
|
|
|
|
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_admin_entry(make_entry):
|
|
|
|
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
|
|
|
|
|
|
|
|
|
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 15:59:02 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 15:42:10 -04:00
|
|
|
def test_services_yaml_exists() -> None:
|
|
|
|
|
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 15:59:02 -04:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 15:42:10 -04:00
|
|
|
def test_services_yaml_declares_regenerate() -> None:
|
|
|
|
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
|
|
|
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
|
|
|
|
"services.yaml must declare the 'regenerate' service"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|
|
|
|
"""The regenerate service is documented with a clear description and no
|
|
|
|
|
required fields (there is no ``fields`` block under it)."""
|
|
|
|
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
2026-09-18 15:59:02 -04:00
|
|
|
section = _service_section(text, "regenerate")
|
2026-09-17 15:42:10 -04:00
|
|
|
assert re.search(r"description:", section), (
|
|
|
|
|
"regenerate must carry a description"
|
|
|
|
|
)
|
|
|
|
|
assert "fields:" not in section, (
|
|
|
|
|
"regenerate declares no required fields"
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_services_yaml_other_services_declare_fields() -> None:
|
|
|
|
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
|
|
|
|
for name in DOMAIN_SERVICES:
|
|
|
|
|
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
|
|
|
|
continue
|
|
|
|
|
section = _service_section(text, name)
|
|
|
|
|
assert "fields:" in section, f"{name} must declare fields"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_all_canonical_services_registered_after_setup(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_manage_child_create_persists_child(hass, make_entry) -> None:
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 15:59:02 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
children = await list_children(entry.runtime_data.database)
|
|
|
|
|
assert [child.display_name for child in children] == ["Ada"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_create_and_set_quest_definition_active(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
from custom_components.nestquest.quest_definitions import (
|
|
|
|
|
list_active_definitions,
|
|
|
|
|
)
|
|
|
|
|
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 15:59:02 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
child = (await list_children(entry.runtime_data.database))[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
|
|
|
|
{
|
|
|
|
|
"title": "Brush teeth",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [child.id],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
active = await list_active_definitions(entry.runtime_data.database)
|
|
|
|
|
assert len(active) == 1
|
|
|
|
|
definition_id = active[0].definition.id
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
|
|
|
{"definition_id": definition_id, "is_active": False},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
assert await list_active_definitions(entry.runtime_data.database) == []
|
|
|
|
|
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
|
|
|
|
|
definition_id
|
|
|
|
|
)
|
|
|
|
|
assert stored is not None
|
|
|
|
|
assert stored.is_active is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_complete_and_uncomplete_quest_call_business_layer(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
from custom_components.nestquest.completion import instance_state
|
|
|
|
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
|
|
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 15:59:02 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
child = (await list_children(entry.runtime_data.database))[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
|
|
|
|
{
|
|
|
|
|
"title": "Brush teeth",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [child.id],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
2026-09-19 08:36:14 -04:00
|
|
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
2026-09-18 15:59:02 -04:00
|
|
|
today = datetime.date.today().isoformat()
|
|
|
|
|
end = (
|
|
|
|
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
|
|
|
).isoformat()
|
|
|
|
|
records = await QuestInstancesDao(
|
|
|
|
|
entry.runtime_data.database
|
|
|
|
|
).list_by_date_range(child.id, today, end)
|
|
|
|
|
assert records
|
|
|
|
|
instance_id = records[0].id
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_COMPLETE_QUEST,
|
|
|
|
|
{
|
|
|
|
|
"instance_id": instance_id,
|
|
|
|
|
"actor": "panel",
|
|
|
|
|
"actor_child_id": child.id,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await instance_state(entry.runtime_data.database, instance_id)
|
|
|
|
|
== "done"
|
|
|
|
|
)
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_UNCOMPLETE_QUEST,
|
|
|
|
|
{
|
|
|
|
|
"instance_id": instance_id,
|
|
|
|
|
"actor": "user",
|
2026-09-19 08:36:14 -04:00
|
|
|
"actor_user_id": ADMIN_ID,
|
2026-09-18 15:59:02 -04:00
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 15:59:02 -04:00
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await instance_state(entry.runtime_data.database, instance_id)
|
|
|
|
|
== "open"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 16:03:42 -04:00
|
|
|
async def test_set_presence_pattern_regenerates_child_instances(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
|
|
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 16:03:42 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
|
|
|
|
child = (await list_children(entry.runtime_data.database))[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
|
|
|
|
{
|
|
|
|
|
"title": "Brush teeth",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [child.id],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
2026-09-19 08:36:14 -04:00
|
|
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
2026-09-18 16:03:42 -04:00
|
|
|
today = datetime.date.today().isoformat()
|
|
|
|
|
end = (
|
|
|
|
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
|
|
|
).isoformat()
|
|
|
|
|
dao = QuestInstancesDao(entry.runtime_data.database)
|
|
|
|
|
assert await dao.list_by_date_range(child.id, today, end)
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_SET_PRESENCE_PATTERN,
|
|
|
|
|
{
|
|
|
|
|
"child_id": child.id,
|
|
|
|
|
"cycle_length_weeks": 1,
|
|
|
|
|
"anchor_date": today,
|
|
|
|
|
"pattern": {0: []},
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
|
|
|
|
assert await dao.list_by_date_range(child.id, today, end) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_presence_override_create_and_delete_regenerate(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
|
|
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
|
|
|
from custom_components.nestquest.dao_presence import PresenceOverridesDao
|
|
|
|
|
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 16:03:42 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
|
|
|
|
child = (await list_children(entry.runtime_data.database))[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
|
|
|
|
{
|
|
|
|
|
"title": "Brush teeth",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [child.id],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
2026-09-19 08:36:14 -04:00
|
|
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
2026-09-18 16:03:42 -04:00
|
|
|
today = datetime.date.today()
|
|
|
|
|
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
|
|
|
dao = QuestInstancesDao(entry.runtime_data.database)
|
|
|
|
|
before = await dao.list_by_date_range(
|
|
|
|
|
child.id, today.isoformat(), end.isoformat()
|
|
|
|
|
)
|
|
|
|
|
assert before
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
|
|
|
{
|
|
|
|
|
"child_id": child.id,
|
|
|
|
|
"start_date": today.isoformat(),
|
|
|
|
|
"end_date": end.isoformat(),
|
|
|
|
|
"is_present": False,
|
|
|
|
|
},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await dao.list_by_date_range(
|
|
|
|
|
child.id, today.isoformat(), end.isoformat()
|
|
|
|
|
)
|
|
|
|
|
== []
|
|
|
|
|
)
|
|
|
|
|
override = (
|
|
|
|
|
await PresenceOverridesDao(
|
|
|
|
|
entry.runtime_data.database
|
|
|
|
|
).list_by_child_and_range(
|
|
|
|
|
child.id, today.isoformat(), end.isoformat()
|
|
|
|
|
)
|
|
|
|
|
)[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
|
|
|
{"override_id": override.id},
|
2026-09-19 08:36:14 -04:00
|
|
|
context=ADMIN_CTX,
|
2026-09-18 16:03:42 -04:00
|
|
|
)
|
|
|
|
|
after = await dao.list_by_date_range(
|
|
|
|
|
child.id, today.isoformat(), end.isoformat()
|
|
|
|
|
)
|
|
|
|
|
assert len(after) == len(before)
|
|
|
|
|
|
|
|
|
|
|
2026-09-18 15:59:02 -04:00
|
|
|
async def test_export_history_csv_raises_home_assistant_error(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
2026-09-19 08:36:14 -04:00
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
2026-09-18 15:59:02 -04:00
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
2026-09-19 08:36:14 -04:00
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
|
|
|
|
|
)
|
2026-09-18 15:59:02 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create"},
|
|
|
|
|
)
|
2026-09-19 08:36:14 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
|
|
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(Unauthorized):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
|
|
|
|
context={"user_id": "kiosk-panel"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_admin_only_service_denies_missing_user_context(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(Unauthorized):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_admin_only_service_denies_admin_on_empty_allowlist(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Even the allowlisted id is denied when the allowlist is empty."""
|
|
|
|
|
entry = _wire(make_entry(), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(Unauthorized):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
|
|
|
|
context=ADMIN_CTX,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_unauthorized_is_raised_before_handler_runs(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
|
|
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(Unauthorized):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
|
|
|
|
context={"user_id": "kiosk-panel"},
|
|
|
|
|
)
|
|
|
|
|
assert await list_children(entry.runtime_data.database) == []
|
2026-09-19 08:38:24 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_MINIMAL_PAYLOADS = {
|
|
|
|
|
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
|
|
|
|
|
"create_quest_definition": {
|
|
|
|
|
"title": "T",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [1],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
|
|
|
|
"update_quest_definition": {"definition_id": 1},
|
|
|
|
|
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
|
|
|
|
|
"set_presence_pattern": {
|
|
|
|
|
"child_id": 1,
|
|
|
|
|
"cycle_length_weeks": 1,
|
|
|
|
|
"anchor_date": "2026-09-19",
|
|
|
|
|
"pattern": {0: [0]},
|
|
|
|
|
},
|
|
|
|
|
"create_presence_override": {
|
|
|
|
|
"child_id": 1,
|
|
|
|
|
"start_date": "2026-09-19",
|
|
|
|
|
"end_date": "2026-09-19",
|
|
|
|
|
"is_present": False,
|
|
|
|
|
},
|
|
|
|
|
"delete_presence_override": {"override_id": 1},
|
|
|
|
|
"export_history_csv": {},
|
|
|
|
|
"manage_child": {"action": "create", "display_name": "Ada"},
|
|
|
|
|
"regenerate": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ADMIN_ONLY_SERVICES = [
|
|
|
|
|
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
|
|
|
|
|
async def test_admin_only_service_fails_closed_without_user_context(
|
|
|
|
|
hass, make_entry, service
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A call with no user_id is denied on every admin-only service."""
|
|
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
with pytest.raises(Unauthorized):
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_complete_quest_proceeds_without_user_context(
|
|
|
|
|
hass, make_entry
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The open service needs no user context: the kiosk panel taps a
|
|
|
|
|
child profile and the call goes through with no HA user at all."""
|
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
|
|
from custom_components.nestquest.children import list_children
|
|
|
|
|
from custom_components.nestquest.completion import instance_state
|
|
|
|
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
|
|
|
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
|
|
|
|
|
|
|
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
|
|
|
|
assert await async_setup_entry(hass, entry) is True
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_MANAGE_CHILD,
|
|
|
|
|
{"action": "create", "display_name": "Ada"},
|
|
|
|
|
context=ADMIN_CTX,
|
|
|
|
|
)
|
|
|
|
|
child = (await list_children(entry.runtime_data.database))[0]
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_CREATE_QUEST_DEFINITION,
|
|
|
|
|
{
|
|
|
|
|
"title": "Brush teeth",
|
|
|
|
|
"rule": {"rule_type": "daily"},
|
|
|
|
|
"assignee_child_ids": [child.id],
|
|
|
|
|
"windows": ["morning"],
|
|
|
|
|
},
|
|
|
|
|
context=ADMIN_CTX,
|
|
|
|
|
)
|
|
|
|
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
|
|
|
today = datetime.date.today().isoformat()
|
|
|
|
|
end = (
|
|
|
|
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
|
|
|
).isoformat()
|
|
|
|
|
instance_id = (
|
|
|
|
|
await QuestInstancesDao(
|
|
|
|
|
entry.runtime_data.database
|
|
|
|
|
).list_by_date_range(child.id, today, end)
|
|
|
|
|
)[0].id
|
|
|
|
|
await hass.services.call(
|
|
|
|
|
DOMAIN,
|
|
|
|
|
SERVICE_COMPLETE_QUEST,
|
|
|
|
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
await instance_state(entry.runtime_data.database, instance_id)
|
|
|
|
|
== "done"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|