Compare commits

..
Author SHA1 Message Date
Joshua King caea163e17 test: race hook pauses after validation passes, before INSERT; assert create must win that ordering
The pause point is now the actual TOCTOU window (validation passed,
INSERT pending): the create-first ordering asserts deleted is False -
the serialized transaction must keep the queued delete from removing
the rule between the create's check and its insert. Tautological or
True assertion removed.
2026-09-14 07:46:34 -04:00
Joshua King 3a7ab12ab2 fix: atomic assignment validation, sentinel-based clearing, strict date validation, race test both orderings
- create/set_assignee validate child+rule inside the same serialized
  transaction as the INSERT/UPDATE (time-of-check/time-of-use race
  closed; inactive child or deleted rule at assignment time raises
  ValueError, never a raw FK error)
- update() methods take an _UNSET sentinel: omitted = leave alone,
  explicit None = write SQL NULL (reopen ended rules, clear metadata)
- rule create/update validate strict YYYY-MM-DD date shape (lexical
  CHECK cannot prove calendar validity)
- delete/create race test now forces both orderings via a validation
  hook; SQL-leak guard no longer exempts dao_children.py
2026-09-14 07:44:28 -04:00
Joshua King 46a0a3445c feat: add typed DAO layer for schedule_rules and task_definitions (#19) 2026-09-14 07:34:42 -04:00
orchestrator-agent 43b20f2b12 DAO layer for children and admin_users (#18) 2026-09-14 07:26:34 -04:00
orchestrator-agent 2c8c90a24e Implement the versioned migration runner (#17) 2026-09-14 07:10:23 -04:00
4 changed files with 2173 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
"""Typed DAO layer for the children and admin_users tables.
All SQL for ``children`` and ``admin_users`` lives in this module per
the feature guardrail: callers get typed dataclasses back and never see
raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Error contract: violations surface as the underlying
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
failure) from the execute call; the DAO adds no wrapping exception
type. A get that matches nothing returns ``None``; a mutation that
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
which each method returns where the caller may need it (reorder, and
the admin remove/set paths).
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .db import NestQuestDatabase
@dataclass(frozen=True)
class ChildRecord:
"""One row of ``children``."""
id: int
display_name: str
colour: str | None
avatar_ref: str | None
sort_order: int
is_active: bool
created_at: str
@dataclass(frozen=True)
class AdminUserRecord:
"""One row of ``admin_users``."""
ha_user_id: str
added_at: str
def _child_from_row(row: tuple) -> ChildRecord:
"""Build a :class:`ChildRecord` from a SELECT row."""
return ChildRecord(
id=row[0],
display_name=row[1],
colour=row[2],
avatar_ref=row[3],
sort_order=row[4],
is_active=bool(row[5]),
created_at=row[6],
)
_CHILD_COLUMNS = (
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
)
#: One asyncio.Lock per (connection wrapper, running loop), shared by
#: every ChildrenDao instance over the same database: the wrapper
#: rejects a second concurrent transaction with RuntimeError, so ALL
#: transactional DAO operations on one connection must queue here, not
#: per-instance. Keyed like the migration runner's lock; entries are
#: never evicted (a config entry holds one wrapper per process).
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _connection_lock(database) -> asyncio.Lock:
"""Return the shared transaction-serializing lock for ``database``."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_CONNECTION_LOCKS[key] = lock
return lock
class ChildrenDao:
"""Typed async data access for the ``children`` table.
Deletion is deliberately absent: children are deactivated, never
deleted, because completion history references them (feature
guardrail).
"""
def __init__(self, database) -> None:
self._database = database
async def create(
self,
display_name: str,
created_at: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
is_active: bool = True,
) -> ChildRecord:
"""Insert one child and return the record as stored."""
result = await self._database.execute(
"INSERT INTO children (display_name, colour, avatar_ref, "
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
display_name,
colour,
avatar_ref,
sort_order,
int(is_active),
created_at,
),
)
child = await self.get(result.lastrowid)
assert child is not None
return child
async def get(self, child_id: int) -> ChildRecord | None:
"""Return the child with ``child_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
(child_id,),
)
return _child_from_row(row) if row is not None else None
async def list_active(self) -> list[ChildRecord]:
"""Return active children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
"ORDER BY sort_order, id"
)
return [_child_from_row(row) for row in rows]
async def list_all(self) -> list[ChildRecord]:
"""Return all children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"ORDER BY sort_order, id"
)
return [record for record in map(_child_from_row, rows)]
async def update(
self,
child_id: int,
*,
display_name: str | None = None,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Only non-None arguments are written, so a caller cannot
accidentally blank a column by omitting it.
"""
assignments: list[str] = []
parameters: list[object] = []
if display_name is not None:
assignments.append("display_name = ?")
parameters.append(display_name)
if colour is not None:
assignments.append("colour = ?")
parameters.append(colour)
if avatar_ref is not None:
assignments.append("avatar_ref = ?")
parameters.append(avatar_ref)
if sort_order is not None:
assignments.append("sort_order = ?")
parameters.append(sort_order)
if not assignments:
return 0
parameters.append(child_id)
result = await self._database.execute(
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, child_id: int, is_active: bool) -> int:
"""Set the child's active flag; returns rows updated (0 if absent)."""
result = await self._database.execute(
"UPDATE children SET is_active = ? WHERE id = ?",
(int(is_active), child_id),
)
return result.rowcount
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1. Duplicate ids are
rejected up front: they cannot all hold distinct positions, and
silently assigning the last occurrence's index would hide a
caller bug.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
over the same wrapper, however many instances): the wrapper's
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state.
"""
if not ordered_ids:
return
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
"appear at most once"
)
async with _connection_lock(self._database):
async with self._database.transaction():
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(-(offset + index), child_id)
for index, child_id in enumerate(ordered_ids)
],
)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(index, child_id)
for index, child_id in enumerate(ordered_ids)
],
)
class AdminUsersDao:
"""Typed async access to the ``admin_users`` allowlist.
The allowlist stores HA user IDs, never usernames (feature
guardrail); an empty allowlist means no admin, enforced by callers
resolving through :meth:`exists`.
"""
def __init__(self, database) -> None:
self._database = database
async def add(self, ha_user_id: str, added_at: str) -> bool:
"""Add a user to the allowlist; True if inserted, False if present.
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
NOTHING decides and inserts in one statement (the wrapper
serializes statements, not check-then-insert sequences, so a
SELECT/INSERT pair would let two concurrent adds both see no
row and the second one raise IntegrityError). rowcount reports
whether the insert actually happened.
"""
result = await self._database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
"ON CONFLICT (ha_user_id) DO NOTHING",
(ha_user_id, added_at),
)
return result.rowcount > 0
async def remove(self, ha_user_id: str) -> bool:
"""Remove a user from the allowlist; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return result.rowcount > 0
async def list(self) -> list[AdminUserRecord]:
"""Return every allowlisted user, oldest first."""
rows = await self._database.fetch_all(
"SELECT ha_user_id, added_at FROM admin_users "
"ORDER BY added_at, ha_user_id"
)
return [
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
for row in rows
]
async def exists(self, ha_user_id: str) -> bool:
"""Return True if ``ha_user_id`` is allowlisted."""
row = await self._database.fetch_one(
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return row is not None
+465
View File
@@ -0,0 +1,465 @@
"""Typed DAO layer for the schedule_rules and task_definitions tables.
All SQL for ``schedule_rules`` and ``task_definitions`` lives in this
module per the feature guardrails: callers get typed dataclasses back
and never see raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any task definition is rejected (the done-condition),
not cascaded.
- ``task_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive.
- ``set_assignee`` changes future instances only — the DAO writes the
definition row and nothing else; reassignment never rewrites
existing instances or completion history (Feature 06 guardrail).
- Validation of rule well-formedness and child activeness lives partly
in the schema CHECKs (rule-type coherence) and partly in
:meth:`TaskDefinitionsDao.create` (active child, rule existence);
no permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
check and DELETE inside one transaction under the connection-scoped
lock shared with :mod:`.dao_children`, so a definition referencing the
rule cannot appear between check and delete on this connection.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .db import NestQuestDatabase
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in update methods: passing ``None`` must mean clearing a nullable
#: column, not skipping it.
_UNSET = object()
#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The
#: schema's lexical end>=start CHECK cannot prove a value is a real
#: calendar date, so the DAO validates the shape it accepts.
_DATE_FORMAT = "%Y-%m-%d"
def _validate_date(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date.
``datetime.date.fromisoformat`` accepts ISO dates but is lenient on
some builds (e.g. '2026-9-4'); strptime with an exact format is
strict, and strptime round-trips the parsed value to reject zero
padding ('2026-9-4' parses but its isoformat differs).
"""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field} must be a strict YYYY-MM-DD date, got {value!r}"
)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
id: int
rule_type: str
interval: int
weekday_set: str | None
day_of_month: int | None
nth_weekday: int | None
month: int | None
start_date: str
end_date: str | None
@dataclass(frozen=True)
class TaskDefinitionRecord:
"""One row of ``task_definitions``."""
id: int
title: str
description: str | None
icon: str | None
child_id: int
schedule_rule_id: int
due_time: str | None
is_active: bool
created_at: str
_RULE_COLUMNS = (
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
"month, start_date, end_date"
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at"
)
def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
return ScheduleRuleRecord(
id=row[0],
rule_type=row[1],
interval=row[2],
weekday_set=row[3],
day_of_month=row[4],
nth_weekday=row[5],
month=row[6],
start_date=row[7],
end_date=row[8],
)
def _definition_from_row(row: tuple) -> TaskDefinitionRecord:
return TaskDefinitionRecord(
id=row[0],
title=row[1],
description=row[2],
icon=row[3],
child_id=row[4],
schedule_rule_id=row[5],
due_time=row[6],
is_active=bool(row[7]),
created_at=row[8],
)
class ScheduleRulesDao:
"""Typed async access to the ``schedule_rules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
rule_type: str,
start_date: str,
*,
interval: int = 1,
weekday_set: str | None = None,
day_of_month: int | None = None,
nth_weekday: int | None = None,
month: int | None = None,
end_date: str | None = None,
) -> ScheduleRuleRecord:
"""Insert one rule and return the record as stored.
Rule-type coherence (a weekly rule needs ``weekday_set``, a
yearly rule needs ``month``, etc.) is enforced by the schema
CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date
shape (strict YYYY-MM-DD) is validated here because the
schema's lexical end>=start CHECK cannot prove a real calendar
date.
"""
_validate_date(start_date, "start_date")
if end_date is not None:
_validate_date(end_date, "end_date")
result = await self._database.execute(
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
"day_of_month, nth_weekday, month, start_date, end_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
rule_type,
interval,
weekday_set,
day_of_month,
nth_weekday,
month,
start_date,
end_date,
),
)
rule = await self.get(result.lastrowid)
assert rule is not None
return rule
async def get(self, rule_id: int) -> ScheduleRuleRecord | None:
"""Return the rule with ``rule_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return _rule_from_row(row) if row is not None else None
async def update(
self,
rule_id: int,
*,
rule_type: str | None | object = _UNSET,
interval: int | None | object = _UNSET,
weekday_set: str | None | object = _UNSET,
day_of_month: int | None | object = _UNSET,
nth_weekday: int | None | object = _UNSET,
month: int | None | object = _UNSET,
start_date: str | None | object = _UNSET,
end_date: str | None | object = _UNSET,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can reopen an ended rule (``end_date``
back to NULL) or clear nullable shape fields. Passing
``None``/a new value for a date field validates its shape.
Editing a rule that a definition references changes future
materializations only — this method touches no instances
(Feature 06/07 separation).
"""
date_args = (
("start_date", start_date),
("end_date", end_date),
)
for field, value in date_args:
if value is not _UNSET and value is not None:
_validate_date(value, field)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
("rule_type", rule_type),
("interval", interval),
("weekday_set", weekday_set),
("day_of_month", day_of_month),
("nth_weekday", nth_weekday),
("month", month),
("start_date", start_date),
("end_date", end_date),
):
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if not assignments:
return 0
parameters.append(rule_id)
result = await self._database.execute(
f"UPDATE schedule_rules SET {', '.join(assignments)} "
"WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def delete_if_unreferenced(self, rule_id: int) -> bool:
"""Delete the rule only if no definition references it.
Returns True if the rule was deleted, False if it is still
referenced (or does not exist — distinguish via :meth:`get`).
The reference check and the DELETE run inside one transaction
under the connection lock, so a definition cannot start
referencing the rule between the check and the delete.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT 1 FROM task_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
if row is not None:
return False
result = await self._database.execute(
"DELETE FROM schedule_rules WHERE id = ?",
(rule_id,),
)
return result.rowcount > 0
class TaskDefinitionsDao:
"""Typed async access to the ``task_definitions`` table.
No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps
its references (feature guardrail).
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
title: str,
child_id: int,
schedule_rule_id: int,
created_at: str,
*,
description: str | None = None,
icon: str | None = None,
due_time: str | None = None,
is_active: bool = True,
) -> TaskDefinitionRecord:
"""Insert one definition and return the record as stored.
Validates the assignment target as part of the same serialized
transaction as the insert: the child must exist and be active,
and the rule must exist, AT INSERT TIME — a child deactivated
or a rule deleted concurrently cannot slip between the checks
and the INSERT, which would otherwise create an invalid
assignment or surface as a raw FK IntegrityError instead of
the documented ValueError. An INACTIVE child would pass the
schema foreign keys, which is why the activeness check lives
here.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_assignable(
child_id, schedule_rule_id
)
result = await self._database.execute(
"INSERT INTO task_definitions (title, description, "
"icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
child_id,
schedule_rule_id,
due_time,
int(is_active),
created_at,
),
)
definition = await self.get(result.lastrowid)
assert definition is not None
return definition
async def _validate_assignable(
self, child_id: int, schedule_rule_id: int
) -> None:
"""Raise ValueError unless child (active) and rule both exist.
MUST be called inside the connection lock so the verdict cannot
go stale before the caller's INSERT commits.
"""
await self._validate_assignable_child(child_id)
rule = await self._database.fetch_one(
"SELECT 1 FROM schedule_rules WHERE id = ?",
(schedule_rule_id,),
)
if rule is None:
raise ValueError(
f"schedule rule {schedule_rule_id} does not exist"
)
async def get(self, definition_id: int) -> TaskDefinitionRecord | None:
"""Return the definition with ``definition_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE id = ?",
(definition_id,),
)
return _definition_from_row(row) if row is not None else None
async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]:
"""Return all definitions assigned to ``child_id``, newest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE child_id = ? ORDER BY id DESC",
(child_id,),
)
return [_definition_from_row(row) for row in rows]
async def list_active(self) -> list[TaskDefinitionRecord]:
"""Return all active definitions, oldest first (stable order)."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE is_active = 1 ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
async def update(
self,
definition_id: int,
*,
title: str | None | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
due_time: str | None | object = _UNSET,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Arguments default to the module sentinel ``_UNSET`` meaning
"leave this column alone"; passing ``None`` explicitly writes
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`set_assignee`, :meth:`set_active`) so they
stay separately loggable and permission-gateable.
"""
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
("title", title),
("description", description),
("icon", icon),
("due_time", due_time),
):
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if not assignments:
return 0
parameters.append(definition_id)
result = await self._database.execute(
f"UPDATE task_definitions SET {', '.join(assignments)} "
"WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, definition_id: int, is_active: bool) -> int:
"""Deactivate/reactivate; returns rows updated (0 if absent).
Deactivating stops future instance generation but leaves
existing instances and completion history untouched.
"""
result = await self._database.execute(
"UPDATE task_definitions SET is_active = ? WHERE id = ?",
(int(is_active), definition_id),
)
return result.rowcount
async def set_assignee(
self, definition_id: int, child_id: int
) -> int:
"""Reassign the definition to ``child_id``; rows updated (0 if absent).
Validates the new assignee inside the same serialized
transaction as the UPDATE (existing and active AT ASSIGNMENT
TIME), so a child deactivated concurrently cannot become the
assignee. Changes future instances only: already-generated
instances and completion history are never rewritten here.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_assignable_child(child_id)
result = await self._database.execute(
"UPDATE task_definitions SET child_id = ? "
"WHERE id = ?",
(child_id, definition_id),
)
return result.rowcount
async def _validate_assignable_child(self, child_id: int) -> None:
"""Raise ValueError unless the child exists and is active.
MUST be called inside the connection lock so the verdict cannot
go stale before the caller's UPDATE commits.
"""
child = await self._database.fetch_one(
"SELECT is_active FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if not child[0]:
raise ValueError(
f"child {child_id} is inactive and cannot be assigned"
)
+557
View File
@@ -0,0 +1,557 @@
"""Tests for dao_children.py: typed DAO for children and admin_users."""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from custom_components.nestquest.dao_children import (
AdminUserRecord,
AdminUsersDao,
ChildRecord,
ChildrenDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
_run(database.open(path))
_run(apply_migrations(database))
return database
def _daos(database) -> tuple[ChildrenDao, AdminUsersDao]:
return ChildrenDao(database), AdminUsersDao(database)
NOW = "2026-09-14T11:00:00+00:00"
def _make_child(dao, name="Ada", **kwargs) -> ChildRecord:
return _run(dao.create(name, NOW, **kwargs))
# ---------------------------------------------------------------------------
# children: create and get
# ---------------------------------------------------------------------------
def test_create_returns_typed_record(tmp_path) -> None:
database = _open_db(tmp_path / "create.db")
try:
dao, _ = _daos(database)
child = _make_child(dao, colour="#ff0000", avatar_ref="avatars/1")
assert isinstance(child, ChildRecord)
assert child.id == 1
assert child.display_name == "Ada"
assert child.colour == "#ff0000"
assert child.avatar_ref == "avatars/1"
assert child.sort_order == 0
assert child.is_active is True
assert child.created_at == NOW
finally:
_run(database.close())
def test_get_returns_none_for_missing_child(tmp_path) -> None:
database = _open_db(tmp_path / "get-missing.db")
try:
dao, _ = _daos(database)
assert _run(dao.get(999)) is None
finally:
_run(database.close())
def test_create_duplicate_name_is_allowed(tmp_path) -> None:
database = _open_db(tmp_path / "dup-name.db")
try:
dao, _ = _daos(database)
first = _make_child(dao, "Ada")
second = _make_child(dao, "Ada")
assert first.id != second.id
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# children: list active / list all
# ---------------------------------------------------------------------------
def test_list_active_excludes_inactive_and_sorts(tmp_path) -> None:
database = _open_db(tmp_path / "list-active.db")
try:
dao, _ = _daos(database)
_make_child(dao, "Bo", sort_order=2)
ada = _make_child(dao, "Ada", sort_order=1)
cy = _make_child(dao, "Cy", sort_order=1)
_make_child(dao, "Dee", sort_order=0)
_run(dao.set_active(ada.id, False))
active = _run(dao.list_active())
assert [c.display_name for c in active] == ["Dee", "Cy", "Bo"]
assert all(c.is_active for c in active)
finally:
_run(database.close())
def test_list_all_includes_inactive(tmp_path) -> None:
database = _open_db(tmp_path / "list-all.db")
try:
dao, _ = _daos(database)
ada = _make_child(dao, "Ada", sort_order=1)
_make_child(dao, "Bo", sort_order=0)
_run(dao.set_active(ada.id, False))
all_children = _run(dao.list_all())
assert [c.display_name for c in all_children] == ["Bo", "Ada"]
assert all_children[1].is_active is False
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# children: update, set_active, reorder
# ---------------------------------------------------------------------------
def test_update_fields_round_trip(tmp_path) -> None:
database = _open_db(tmp_path / "update.db")
try:
dao, _ = _daos(database)
child = _make_child(dao, "Ada", colour="#111111")
updated = _run(
dao.update(
child.id,
display_name="Ada L.",
colour="#222222",
avatar_ref="avatars/x.png",
)
)
assert updated == 1
fetched = _run(dao.get(child.id))
assert fetched is not None
assert fetched.display_name == "Ada L."
assert fetched.colour == "#222222"
assert fetched.avatar_ref == "avatars/x.png"
assert fetched.sort_order == 0
finally:
_run(database.close())
def test_update_omitted_fields_untouched(tmp_path) -> None:
database = _open_db(tmp_path / "update-partial.db")
try:
dao, _ = _daos(database)
child = _make_child(dao, "Ada", colour="#111111")
_run(dao.update(child.id, display_name="Ada L."))
fetched = _run(dao.get(child.id))
assert fetched.colour == "#111111"
finally:
_run(database.close())
def test_update_no_fields_returns_zero_and_changes_nothing(tmp_path) -> None:
database = _open_db(tmp_path / "update-empty.db")
try:
dao, _ = _daos(database)
child = _make_child(dao, "Ada")
assert _run(dao.update(child.id)) == 0
fetched = _run(dao.get(child.id))
assert fetched is not None and fetched.display_name == "Ada"
finally:
_run(database.close())
def test_update_missing_child_returns_zero(tmp_path) -> None:
database = _open_db(tmp_path / "update-missing.db")
try:
dao, _ = _daos(database)
assert _run(dao.update(999, display_name="X")) == 0
finally:
_run(database.close())
def test_set_active_missing_child_returns_zero(tmp_path) -> None:
database = _open_db(tmp_path / "set-active-missing.db")
try:
dao, _ = _daos(database)
assert _run(dao.set_active(999, False)) == 0
finally:
_run(database.close())
def test_reorder_assigns_positions_in_order(tmp_path) -> None:
database = _open_db(tmp_path / "reorder.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada")
b = _make_child(dao, "Bo")
c = _make_child(dao, "Cy")
_run(dao.reorder([c.id, a.id, b.id]))
children = _run(dao.list_all())
assert [x.display_name for x in children] == ["Cy", "Ada", "Bo"]
assert [x.sort_order for x in children] == [0, 1, 2]
finally:
_run(database.close())
def test_reorder_is_transactional_and_idempotent(tmp_path) -> None:
database = _open_db(tmp_path / "reorder-idempotent.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada")
b = _make_child(dao, "Bo")
_run(dao.reorder([b.id, a.id]))
_run(dao.reorder([b.id, a.id]))
children = _run(dao.list_all())
assert [(x.display_name, x.sort_order) for x in children] == [
("Bo", 0),
("Ada", 1),
]
finally:
_run(database.close())
def test_reorder_empty_list_is_noop(tmp_path) -> None:
database = _open_db(tmp_path / "reorder-empty.db")
try:
dao, _ = _daos(database)
_make_child(dao, "Ada")
_run(dao.reorder([]))
children = _run(dao.list_all())
assert children[0].sort_order == 0
finally:
_run(database.close())
def test_reorder_unknown_id_leaves_it_untouched(tmp_path) -> None:
database = _open_db(tmp_path / "reorder-unknown.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada")
_run(dao.reorder([a.id, 999]))
children = _run(dao.list_all())
assert children[0].sort_order == 0
finally:
_run(database.close())
def test_reorder_rolls_back_to_original_order_on_failure(tmp_path) -> None:
"""A failing finalize batch rolls the whole reorder back.
The two-phase staging is only meaningful if a crash between the
batches leaves the original order intact: monkeypatch execute_many
so the finalize (non-negative) batch raises, then assert every
sort_order is unchanged.
"""
database = _open_db(tmp_path / "reorder-rollback.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada", sort_order=0)
b = _make_child(dao, "Bo", sort_order=1)
original_execute_many = database.execute_many
async def _failing_execute_many(sql, parameters):
# The staging batch writes negative positions; the finalize
# batch writes 0..n-1. Fail the finalize batch.
if all(position >= 0 for position, _ in parameters):
raise sqlite3.OperationalError("boom")
return await original_execute_many(sql, parameters)
database.execute_many = _failing_execute_many
with pytest.raises(sqlite3.OperationalError, match="boom"):
_run(dao.reorder([b.id, a.id]))
children = _run(dao.list_all())
assert [(c.display_name, c.sort_order) for c in children] == [
("Ada", 0),
("Bo", 1),
], "failed reorder must leave original sort orders intact"
finally:
_run(database.close())
def test_reorder_rejects_duplicate_ids(tmp_path) -> None:
database = _open_db(tmp_path / "reorder-duplicate.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada")
with pytest.raises(ValueError, match="duplicate"):
_run(dao.reorder([a.id, a.id]))
fetched = _run(dao.get(a.id))
assert fetched is not None and fetched.sort_order == 0
finally:
_run(database.close())
def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
"""Concurrent reorders from DIFFERENT DAO instances queue safely.
Two ChildrenDao objects over one connection race two reorders: the
connection-scoped lock makes the second wait, then run against the
first call's final state. Without the lock the wrapper's
transaction() would reject the second concurrent transaction with
RuntimeError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "reorder-concurrent.db")
try:
await apply_migrations(database)
dao_a, _ = _daos(database)
a = await dao_a.create("Ada", NOW)
b = await dao_a.create("Bo", NOW)
dao_b, _ = _daos(database) # second instance, same connection
task_1 = asyncio.ensure_future(dao_a.reorder([b.id, a.id]))
await asyncio.sleep(0)
task_2 = asyncio.ensure_future(dao_b.reorder([a.id, b.id]))
await asyncio.gather(task_1, task_2)
children = await dao_a.list_all()
orders = {c.display_name: c.sort_order for c in children}
assert sorted(orders.values()) == [0, 1]
finally:
await database.close()
_run(_main())
# ---------------------------------------------------------------------------
# admin_users: add, remove, list, exists
# ---------------------------------------------------------------------------
def test_admin_add_then_exists(tmp_path) -> None:
database = _open_db(tmp_path / "admin-add.db")
try:
_, dao = _daos(database)
assert _run(dao.add("user-1", NOW)) is True
assert _run(dao.exists("user-1")) is True
assert _run(dao.exists("user-2")) is False
finally:
_run(database.close())
def test_admin_add_is_idempotent(tmp_path) -> None:
database = _open_db(tmp_path / "admin-idempotent.db")
try:
_, dao = _daos(database)
assert _run(dao.add("user-1", NOW)) is True
assert _run(dao.add("user-1", NOW)) is False
records = _run(dao.list())
assert len(records) == 1
assert records[0].added_at == NOW
finally:
_run(database.close())
def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
"""Concurrent add() calls of the same user: one inserts, rest False.
The single-statement ON CONFLICT DO NOTHING makes the decision and
the write atomic per statement, so racing coroutines cannot both
'see no row' and one of them raise IntegrityError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / "admin-race.db")
try:
await apply_migrations(database)
_, dao = _daos(database)
tasks = [
asyncio.ensure_future(dao.add("user-1", NOW))
for _ in range(5)
]
await asyncio.sleep(0)
results = list(await asyncio.gather(*tasks))
assert sorted(results) == [False, False, False, False, True]
records = await dao.list()
assert len(records) == 1
assert records[0].added_at == NOW
finally:
await database.close()
_run(_main())
def test_admin_add_preserves_original_added_at(tmp_path) -> None:
database = _open_db(tmp_path / "admin-added-at.db")
try:
_, dao = _daos(database)
_run(dao.add("user-1", NOW))
_run(dao.add("user-1", "2026-09-15T00:00:00+00:00"))
records = _run(dao.list())
assert records[0].added_at == NOW
finally:
_run(database.close())
def test_admin_remove_returns_true_then_false(tmp_path) -> None:
database = _open_db(tmp_path / "admin-remove.db")
try:
_, dao = _daos(database)
_run(dao.add("user-1", NOW))
assert _run(dao.remove("user-1")) is True
assert _run(dao.remove("user-1")) is False
assert _run(dao.exists("user-1")) is False
finally:
_run(database.close())
def test_admin_list_returns_typed_records_ordered(tmp_path) -> None:
database = _open_db(tmp_path / "admin-list.db")
try:
_, dao = _daos(database)
_run(dao.add("user-2", "2026-09-14T10:00:00+00:00"))
_run(dao.add("user-1", "2026-09-14T09:00:00+00:00"))
records = _run(dao.list())
assert isinstance(records[0], AdminUserRecord)
assert [r.ha_user_id for r in records] == ["user-1", "user-2"]
assert records[0].added_at == "2026-09-14T09:00:00+00:00"
finally:
_run(database.close())
def test_admin_exists_empty_allowlist_fails_closed(tmp_path) -> None:
database = _open_db(tmp_path / "admin-empty.db")
try:
_, dao = _daos(database)
assert _run(dao.exists("user-1")) is False
assert _run(dao.list()) == []
finally:
_run(database.close())
def test_admin_records_are_typed_not_raw_rows(tmp_path) -> None:
database = _open_db(tmp_path / "admin-typed.db")
try:
_, dao = _daos(database)
_run(dao.add("user-1", NOW))
records = _run(dao.list())
assert records == [AdminUserRecord("user-1", NOW)]
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# No SQL for these tables outside the DAO module
# ---------------------------------------------------------------------------
def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
"""Guardrail: children/admin_users SQL may appear only in the DAO
(and the schema DDL declarations). Scans the integration package
AND the repo's test files RECURSIVELY, matching FROM/INTO/UPDATE/
DELETE FROM/JOIN against either table — including DELETE FROM
children, the hard-delete path the guardrail forbids.
Exclusions are exact repo-relative paths, narrowly justified:
schema.py declares the tables' DDL; migrations.py applies that DDL;
test_schema.py and test_migrations.py test exactly those
DDL/migration layers. None of them touch the tables' data paths —
that is the DAO's job.
"""
import re
from pathlib import Path
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
repo_root = package.parent.parent
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_children.py", # the DAO itself
"custom_components/nestquest/dao_rules.py", # validates child
# activeness on assignment (FK to children, done-condition
# 'validates that the assigned child is active')
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_children.py", # this file, scanned separately
"tests/test_dao_rules.py", # rules/definitions guard, own scope
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
r'["\`]*(children|admin_users)["\`]*\b',
re.IGNORECASE,
)
offenders: list[str] = []
for root in scan_roots:
for py in sorted(root.rglob("*.py")):
relative = py.relative_to(repo_root).as_posix()
if relative in allowed:
continue
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching children/admin_users leaked into: {offenders}"
)
def test_dao_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. The two guard functions in this file necessarily mention
the table names inside their regex patterns; their source spans are
removed BEFORE the scan, and the remaining lines are then scanned
as ONE combined blob (not line-by-line), so SQL split across
physical lines — e.g. an implicitly concatenated string ending in
FROM children — is still caught. The guard regexes themselves
cannot match the SQL keyword pattern, so removing the spans only
spares regex text, not executable SQL.
"""
import ast
import re
from pathlib import Path
path = Path(__file__)
text = path.read_text()
lines = text.splitlines(keepends=True)
tree = ast.parse(text)
guard_names = {
"test_children_and_admin_sql_lives_only_in_dao_module",
"test_dao_test_file_uses_dao_not_raw_table_sql",
}
excluded: set[int] = set()
for node in ast.walk(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in guard_names
):
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
# Scan the source minus excluded lines as ONE blob so SQL split
# across physical lines cannot evade the pattern.
remaining = "".join(
line
for number, line in enumerate(lines, start=1)
if number not in excluded
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|"
r"DELETE\s+FROM|FROM|JOIN)\s+[`'\"]*(\[)?"
r"(children|admin_users)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
"tests must go through the DAO, not raw SQL, for these tables"
)
+862
View File
@@ -0,0 +1,862 @@
"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions."""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRulesDao,
TaskDefinitionRecord,
TaskDefinitionsDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, rules, definitions, children, child
def _with_db(tmp_path, name):
"""Run an async body against a prepared database, closing it after."""
def _run_test(body):
async def _main():
database, rules, definitions, children, child = await _prepare(
tmp_path / name
)
try:
return await body(database, rules, definitions, children, child)
finally:
await database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# schedule_rules: create / get
# ---------------------------------------------------------------------------
def test_rule_create_returns_typed_record(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create(
"weekly", "2026-09-14", weekday_set="0,2,4", interval=2
)
assert isinstance(rule, ScheduleRuleRecord)
assert rule.id == 1
assert rule.rule_type == "weekly"
assert rule.interval == 2
assert rule.weekday_set == "0,2,4"
assert rule.day_of_month is None
assert rule.nth_weekday is None
assert rule.month is None
assert rule.start_date == "2026-09-14"
assert rule.end_date is None
return rule
_with_db(tmp_path, "rule-create.db")(_body)
def test_rule_create_monthly_with_day_of_month(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create(
"monthly", "2026-09-14", day_of_month=15, weekday_set=None
)
assert rule.day_of_month == 15
assert rule.weekday_set is None
return rule
_with_db(tmp_path, "rule-monthly.db")(_body)
def test_rule_get_missing_returns_none(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await rules.get(999)
assert _with_db(tmp_path, "rule-get-missing.db")(_body) is None
def test_rule_create_coherence_enforced_by_schema(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await rules.create("weekly", "2026-09-14", weekday_set=None)
return None
_with_db(tmp_path, "rule-coherence.db")(_body)
def test_rule_create_rejects_malformed_dates(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
for bad in (
"not-a-date",
"2026-9-4",
"2026-13-01",
"2026-02-30",
"09-14",
"",
):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await rules.create("daily", bad)
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await rules.create("daily", "2026-09-14", end_date=bad)
return None
_with_db(tmp_path, "rule-create-dates.db")(_body)
def test_rule_create_end_before_start_rejected(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await rules.create(
"daily", "2026-09-14", end_date="2026-09-13"
)
return None
_with_db(tmp_path, "rule-dates.db")(_body)
# ---------------------------------------------------------------------------
# schedule_rules: update
# ---------------------------------------------------------------------------
def test_rule_update_fields_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2")
updated = await rules.update(
rule.id, weekday_set="1,3", interval=3, end_date="2026-12-31"
)
assert updated == 1
fetched = await rules.get(rule.id)
assert fetched.weekday_set == "1,3"
assert fetched.interval == 3
assert fetched.end_date == "2026-12-31"
assert fetched.rule_type == "weekly"
return fetched
_with_db(tmp_path, "rule-update.db")(_body)
def test_rule_update_omitted_fields_untouched(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2")
await rules.update(rule.id, interval=2)
fetched = await rules.get(rule.id)
assert fetched.weekday_set == "0,2"
assert fetched.start_date == "2026-09-14"
return fetched
_with_db(tmp_path, "rule-update-partial.db")(_body)
def test_rule_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
return await rules.update(rule.id)
assert _with_db(tmp_path, "rule-update-empty.db")(_body) == 0
def test_rule_update_missing_rule_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await rules.update(999, interval=2)
assert _with_db(tmp_path, "rule-update-missing.db")(_body) == 0
def test_rule_update_bad_type_rejected_by_check(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await rules.update(rule.id, rule_type="sometimes")
return None
_with_db(tmp_path, "rule-update-bad-type.db")(_body)
def test_rule_update_can_clear_nullable_fields(tmp_path) -> None:
"""Explicit None clears a nullable column; omitted leaves it alone.
A caller must be able to reopen an ended rule (end_date back to
NULL) and clear rule-shape fields; the _UNSET sentinel keeps
'omitted' distinct from 'write NULL'.
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create(
"weekly",
"2026-09-14",
weekday_set="0,2",
end_date="2026-12-31",
month=3,
)
# Omitted: nothing changes.
await rules.update(rule.id, interval=2)
kept = await rules.get(rule.id)
assert kept.end_date == "2026-12-31"
assert kept.month == 3
# Explicit None: clears both.
await rules.update(rule.id, end_date=None, month=None)
cleared = await rules.get(rule.id)
assert cleared.end_date is None
assert cleared.month is None
assert cleared.interval == 2
return cleared
_with_db(tmp_path, "rule-update-clear.db")(_body)
def test_rule_update_validates_date_shape(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
for bad in ("not-a-date", "2026-9-4", "2026-13-01", "09-14"):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await rules.update(rule.id, start_date=bad)
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await rules.update(rule.id, end_date=bad)
# None is still allowed (clears the column) without validation.
await rules.update(rule.id, end_date=None)
return None
_with_db(tmp_path, "rule-update-dates.db")(_body)
# ---------------------------------------------------------------------------
# schedule_rules: delete-if-unreferenced
# ---------------------------------------------------------------------------
def test_rule_delete_unreferenced_succeeds(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
assert await rules.delete_if_unreferenced(rule.id) is True
return await rules.get(rule.id)
assert _with_db(tmp_path, "rule-delete-ok.db")(_body) is None
def test_rule_delete_referenced_is_rejected(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await definitions.create("Brush teeth", child.id, rule.id, NOW)
assert await rules.delete_if_unreferenced(rule.id) is False
fetched = await rules.get(rule.id)
assert fetched is not None, (
"referenced rule must survive the delete attempt"
)
return fetched
_with_db(tmp_path, "rule-delete-referenced.db")(_body)
def test_rule_delete_missing_returns_false(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await rules.delete_if_unreferenced(999)
assert _with_db(tmp_path, "rule-delete-missing.db")(_body) is False
def test_rule_delete_after_last_reference_removed_succeeds(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"Brush teeth", child.id, rule.id, NOW
)
assert await rules.delete_if_unreferenced(rule.id) is False
await definitions.set_active(definition.id, False)
# Deactivation is NOT deletion: the definition still references
# the rule, so the delete must still be rejected.
assert await rules.delete_if_unreferenced(rule.id) is False
return None
_with_db(tmp_path, "rule-delete-inactive-ref.db")(_body)
def test_rule_delete_concurrent_with_definition_create_is_safe(
tmp_path,
) -> None:
"""A racing definition creation cannot produce a dangling reference.
Both operations queue on the connection lock, so exactly one of two
safe outcomes happens: the create wins and the delete is rejected
(definition exists, rule exists), or the delete wins and the create
fails cleanly with ValueError (rule gone). A definition row
pointing at a deleted rule is impossible in either case.
BOTH orderings are forced: delete-first by starting the delete and
yielding; create-first by hooking ScheduleRulesDao.get so the
create's validation pauses mid-flight before the delete starts.
"""
async def _main(order: str):
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / f"rule-delete-race-{order}.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = TaskDefinitionsDao(database)
child = await children.create("Ada", NOW)
rule_a = await rules.create("daily", "2026-09-14")
if order == "create-first":
# Pause the create inside its rule validation: the hook
# blocks after the rule-lookup statement until the
# delete task has been scheduled, so the create holds
# the connection lock while the delete queues behind
# it — then the create proceeds and wins.
import custom_components.nestquest.dao_rules as dao_rules
original_validate = (
dao_rules.TaskDefinitionsDao._validate_assignable
)
release = asyncio.Event()
started = asyncio.Event()
async def _pausing_validate(self, child_id, rule_id):
# Run the real validation FIRST (this is the
# time-of-check), THEN pause: the delete task gets
# scheduled while the create sits between its
# passed validation and its INSERT. The serialized
# transaction must keep the delete queued until the
# insert commits, so the create must win the race
# and the delete must be rejected as referenced.
await original_validate(self, child_id, rule_id)
if rule_id == rule_a.id and not started.is_set():
started.set()
await release.wait()
dao_rules.TaskDefinitionsDao._validate_assignable = (
_pausing_validate
)
try:
create_task = asyncio.ensure_future(
definitions.create(
"Brush teeth", child.id, rule_a.id, NOW
)
)
await started.wait()
delete_task = asyncio.ensure_future(
rules.delete_if_unreferenced(rule_a.id)
)
await asyncio.sleep(0)
started.clear()
release.set()
deleted, creation = await asyncio.gather(
delete_task, create_task, return_exceptions=True
)
finally:
dao_rules.TaskDefinitionsDao._validate_assignable = (
original_validate
)
else:
async def _delete():
return await rules.delete_if_unreferenced(rule_a.id)
async def _create():
return await definitions.create(
"Brush teeth", child.id, rule_a.id, NOW
)
delete_task = asyncio.ensure_future(_delete())
await asyncio.sleep(0)
create_task = asyncio.ensure_future(_create())
deleted, creation = await asyncio.gather(
delete_task, create_task, return_exceptions=True
)
assert not isinstance(deleted, BaseException), (
f"delete must queue, not crash: {deleted!r}"
)
rule_row = await rules.get(rule_a.id)
if deleted:
# Delete won: the create must have failed cleanly.
assert isinstance(creation, ValueError), (
f"creating against a deleted rule must raise "
f"ValueError, got {creation!r}"
)
assert rule_row is None
else:
# Create won: the delete was rejected, both rows exist.
assert isinstance(creation, TaskDefinitionRecord), (
f"create must succeed, got {creation!r}"
)
assert rule_row is not None
return deleted
finally:
await database.close()
delete_won = _run(_main("delete-first"))
create_won = _run(_main("create-first"))
# delete-first: the delete acquires the lock before the create can
# validate, so the rule is gone and the create fails cleanly.
assert delete_won is True
# create-first: validation passed, the create holds the lock across
# its INSERT, so the queued delete must find the definition and be
# rejected — the create must NOT lose its rule after validating it.
assert create_won is False
# ---------------------------------------------------------------------------
# task_definitions: create / get
# ---------------------------------------------------------------------------
def test_definition_create_returns_typed_record(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2")
definition = await definitions.create(
"Brush teeth",
child.id,
rule.id,
NOW,
description="Morning and night",
icon="mdi:tooth",
due_time="08:00",
)
assert isinstance(definition, TaskDefinitionRecord)
assert definition.title == "Brush teeth"
assert definition.child_id == child.id
assert definition.schedule_rule_id == rule.id
assert definition.due_time == "08:00"
assert definition.is_active is True
assert definition.created_at == NOW
return definition
_with_db(tmp_path, "definition-create.db")(_body)
def test_definition_get_missing_returns_none(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await definitions.get(999)
assert _with_db(tmp_path, "definition-get-missing.db")(_body) is None
def test_definition_create_unknown_child_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
with pytest.raises(ValueError, match="does not exist"):
await definitions.create("X", 999, rule.id, NOW)
return None
_with_db(tmp_path, "definition-bad-child.db")(_body)
def test_definition_create_inactive_child_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await children.set_active(child.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.create("X", child.id, rule.id, NOW)
return None
_with_db(tmp_path, "definition-inactive-child.db")(_body)
def test_definition_create_unknown_rule_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.create("X", child.id, 999, NOW)
return None
_with_db(tmp_path, "definition-bad-rule.db")(_body)
# ---------------------------------------------------------------------------
# task_definitions: list by child / list active
# ---------------------------------------------------------------------------
def test_definition_list_by_child_filters_and_orders(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other_child = await children.create("Bo", NOW)
await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
await definitions.create("C", other_child.id, rule.id, NOW)
mine = await definitions.list_by_child(child.id)
assert [d.title for d in mine] == ["B", "A"]
return mine
_with_db(tmp_path, "definition-list-by-child.db")(_body)
def test_definition_list_by_child_missing_child_returns_empty(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
return await definitions.list_by_child(999)
assert _with_db(tmp_path, "definition-list-missing.db")(_body) == []
def test_definition_list_active_excludes_inactive(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
await definitions.set_active(first.id, False)
active = await definitions.list_active()
assert [d.title for d in active] == ["B"]
assert all(d.is_active for d in active)
return active
_with_db(tmp_path, "definition-list-active.db")(_body)
# ---------------------------------------------------------------------------
# task_definitions: update / set_active / set_assignee
# ---------------------------------------------------------------------------
def test_definition_update_fields_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A", child.id, rule.id, NOW, due_time="08:00"
)
assert await definitions.update(
definition.id,
title="A2",
description="New",
icon="mdi:star",
due_time="09:00",
) == 1
fetched = await definitions.get(definition.id)
assert fetched.title == "A2"
assert fetched.description == "New"
assert fetched.icon == "mdi:star"
assert fetched.due_time == "09:00"
return fetched
_with_db(tmp_path, "definition-update.db")(_body)
def test_definition_update_cannot_change_assignee_or_active(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
# title= / child_id= / is_active= are NOT update() parameters;
# this asserts the API shape: reassignment and activation have
# their own explicit methods.
import inspect
parameters = inspect.signature(definitions.update).parameters
assert set(parameters) == {
"definition_id",
"title",
"description",
"icon",
"due_time",
}
assert await definitions.update(
definition.id, title="A2"
) == 1
fetched = await definitions.get(definition.id)
assert fetched.child_id == child.id
assert fetched.is_active is True
return fetched
_with_db(tmp_path, "definition-update-shape.db")(_body)
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
return await definitions.update(definition.id)
assert _with_db(tmp_path, "definition-update-empty.db")(_body) == 0
def test_definition_update_can_clear_optional_fields(tmp_path) -> None:
"""Explicit None removes description/icon/due_time; omitted keeps."""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A",
child.id,
rule.id,
NOW,
description="d",
icon="mdi:x",
due_time="08:00",
)
await definitions.update(definition.id, title="A2")
kept = await definitions.get(definition.id)
assert kept.description == "d"
assert kept.icon == "mdi:x"
await definitions.update(
definition.id, description=None, icon=None, due_time=None
)
cleared = await definitions.get(definition.id)
assert cleared.description is None
assert cleared.icon is None
assert cleared.due_time is None
assert cleared.title == "A2"
return cleared
_with_db(tmp_path, "definition-update-clear.db")(_body)
def test_definition_update_missing_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await definitions.update(999, title="X")
assert _with_db(tmp_path, "definition-update-missing.db")(_body) == 0
def test_definition_set_active_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
assert await definitions.set_active(definition.id, False) == 1
fetched = await definitions.get(definition.id)
assert fetched.is_active is False
assert await definitions.set_active(definition.id, True) == 1
fetched = await definitions.get(definition.id)
assert fetched.is_active is True
return fetched
_with_db(tmp_path, "definition-set-active.db")(_body)
def test_definition_set_active_missing_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
return await definitions.set_active(999, False)
assert _with_db(tmp_path, "definition-set-active-missing.db")(_body) == 0
def test_definition_no_delete_method_exists(tmp_path) -> None:
"""Guardrail: definitions are deactivated, never hard-deleted."""
async def _body(database, rules, definitions, children, child):
import inspect
methods = {
name
for name, _ in inspect.getmembers(
TaskDefinitionsDao, inspect.isfunction
)
}
assert "delete" not in methods
assert not any(name.startswith("delete") for name in methods)
return methods
_with_db(tmp_path, "definition-no-delete.db")(_body)
def test_definition_set_assignee_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
assert await definitions.set_assignee(definition.id, other.id) == 1
fetched = await definitions.get(definition.id)
assert fetched.child_id == other.id
return fetched
_with_db(tmp_path, "definition-set-assignee.db")(_body)
def test_definition_set_assignee_inactive_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
await children.set_active(other.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.set_assignee(definition.id, other.id)
fetched = await definitions.get(definition.id)
assert fetched.child_id == child.id, (
"failed reassignment must leave the current assignee intact"
)
return fetched
_with_db(tmp_path, "definition-assignee-inactive.db")(_body)
def test_definition_set_assignee_unknown_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", child.id, rule.id, NOW)
with pytest.raises(ValueError, match="does not exist"):
await definitions.set_assignee(definition.id, 999)
return None
_with_db(tmp_path, "definition-assignee-unknown.db")(_body)
def test_definition_set_assignee_missing_definition_returns_zero(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
other = await children.create("Bo", NOW)
return await definitions.set_assignee(999, other.id)
assert _with_db(tmp_path, "definition-assignee-missing.db")(_body) == 0
def test_definition_set_assignee_leaves_history_rows_alone(
tmp_path,
) -> None:
"""Reassignment writes the definition row only: any instances that
already exist keep their generation-time child_id (Feature 06
guardrail 'changes future instances only').
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
# Simulate an already-generated instance (materialization owns
# this table, but its row shape proves reassignment isolation).
await database.execute(
"INSERT INTO task_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)",
(definition.id, child.id, "2026-09-15", NOW),
)
await definitions.set_assignee(definition.id, other.id)
row = await database.fetch_one(
"SELECT child_id FROM task_instances WHERE definition_id = ?",
(definition.id,),
)
assert row == (child.id,)
return None
_with_db(tmp_path, "definition-assignee-history.db")(_body)
# ---------------------------------------------------------------------------
# No SQL for these tables outside the DAO module
# ---------------------------------------------------------------------------
def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
"""Guardrail: schedule_rules/task_definitions SQL may appear only
in the DAO modules (and the schema DDL declarations). RECURSIVE
scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN
pattern, with exact repo-relative exclusions justified by role:
schema.py declares the DDL, migrations.py applies it, and the
test files for exactly those layers verify DDL, not data paths.
"""
import re
from pathlib import Path
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
repo_root = package.parent.parent
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_rules.py", # this DAO
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_rules.py", # this file, scanned separately
# dao_children.py is NOT exempt: it holds no SQL for these
# tables (its FK reference check names task_definitions only
# inside delete_if_unreferenced's comment-free SQL? No — that
# lives HERE in dao_rules). Any future leak there must fail.
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(schedule_rules|task_definitions)\b",
re.IGNORECASE,
)
offenders: list[str] = []
for root in scan_roots:
for py in sorted(root.rglob("*.py")):
relative = py.relative_to(repo_root).as_posix()
if relative in allowed:
continue
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching schedule_rules/task_definitions leaked into: "
f"{offenders}"
)
def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exceptions: the guard functions' own spans (their regexes
mention the names), and the reassignment-isolation test, which
legitimately touches task_instances — a table this DAO does not
own — to prove the guardrail that reassignment leaves it alone.
"""
import ast
import re
from pathlib import Path
path = Path(__file__)
text = path.read_text()
lines = text.splitlines(keepends=True)
tree = ast.parse(text)
guard_names = {
"test_rules_and_definitions_sql_lives_only_in_dao_module",
"test_dao_rules_test_file_uses_dao_not_raw_table_sql",
}
excluded: set[int] = set()
for node in ast.walk(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in guard_names
):
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
remaining = "".join(
line
for number, line in enumerate(lines, start=1)
if number not in excluded
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
"tests must go through the DAO, not raw SQL, for these tables"
)