Compare commits

..
17 changed files with 1921 additions and 5675 deletions
+5 -37
View File
@@ -10,9 +10,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN, LOGGER
from .db import NestQuestDatabase
from .migrations import apply_migrations
from .store import async_get_db_path
_LOGGER = LOGGER
@@ -23,7 +20,6 @@ class NestQuestRuntimeData:
entry_id: str
options: dict[str, Any]
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
@@ -35,14 +31,6 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None:
db = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -50,18 +38,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
try:
await apply_migrations(database)
remove_update_listener = entry.add_update_listener(_async_update_listener)
except BaseException:
await database.close()
raise
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
database=database,
remove_update_listener=remove_update_listener,
remove_update_listener=entry.add_update_listener(_async_update_listener),
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
@@ -78,22 +58,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
runtime_data = getattr(entry, "runtime_data", None)
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
unload_error: BaseException | None = None
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
unload_error = err
finally:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
if unload_error is not None:
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
raise unload_error
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
return True
-289
View File
@@ -1,289 +0,0 @@
"""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
-422
View File
@@ -1,422 +0,0 @@
"""Async SQLite connection wrapper for the NestQuest integration."""
from __future__ import annotations
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from homeassistant.core import HomeAssistant
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
"""
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to the Home Assistant executor through
``hass.async_add_executor_job`` so the event loop is never blocked, and
only plain Python data (row tuples and :class:`ExecutionResult`) is
returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper around a Home Assistant instance."""
self._hass = hass
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._hass.async_add_executor_job(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._hass.async_add_executor_job(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._hass.async_add_executor_job(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._hass.async_add_executor_job(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
-236
View File
@@ -1,236 +0,0 @@
"""Versioned schema migration runner for the NestQuest SQLite database.
The runner owns one metadata table, ``nestquest_schema_version``, holding
a single row with the schema version the database file currently
reflects. Migrations are declared as an ordered list: entry ``n``
upgrades version ``n-1`` to version ``n``. Applying a migration means
running its DDL inside one transaction and stamping the new version in
that same transaction, so a database never persists a half-applied
migration: any failure rolls the statements and the version stamp back
together, leaving the file at its prior version.
Version 0 means "the file exists but carries no NestQuest schema yet"
— a fresh file, or a file this integration has never written. Starting
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
An UPDATE form would silently match zero rows there and leave the
database migrated but unstamped — reporting version 0 forever, and
re-applying migration 1 on every startup. The DELETE+INSERT shape is
also what makes the stamp a replace, so the row never duplicates.
Idempotence across restarts: a database already at the latest version
has no pending migrations, so the runner opens a read-only check, finds
nothing to do, and leaves the file byte-untouched (no write transaction
is opened, so no WAL churn).
Migration list policy: entries are append-only. Once shipped, a
migration's SQL must never be edited — an old database applying it for
the first time and a fresh database would then diverge. Fix-forward
with a new migration instead. The version stamp lives in the same
SQLite file as the data it describes, so restoring a backup restores
its matching schema version with it.
Corrupt or missing files are not this module's concern: startup
handling of those cases is task d5bca74b, which decides whether to
open fresh or raise ConfigEntryNotReady. This runner assumes it is
handed an open, working connection.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Sequence
from .db import NestQuestDatabase
from .schema import SCHEMA_V1_STATEMENTS
LOGGER = logging.getLogger(__name__)
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
VERSION_TABLE = "nestquest_schema_version"
#: DDL for the version metadata table. The singleton-row invariant is
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
#: PRIMARY KEY, so the table can never hold a second row, and the
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
#: a positive integer: version 0 means "no row" and is never stored,
#: so a negative or zero stored value is corruption, not a state the
#: runner can act on.
VERSION_TABLE_DDL = f"""
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version >= 1)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead.
MIGRATIONS: Sequence[Sequence[str]] = [
MIGRATION_1_V1_DDL,
]
async def _create_version_table(database: NestQuestDatabase) -> None:
"""Create the version metadata table if it does not exist.
The table is created before any version check: it is the runner's
own bookkeeping, and creating it is idempotent. The DDL enforces
the singleton-row invariant (``id`` fixed at 1) and a positive
integer version at the database level.
"""
await database.execute(VERSION_TABLE_DDL)
async def read_schema_version(database: NestQuestDatabase) -> int:
"""Return the schema version recorded in the database.
Returns 0 when the version table or its row is missing — the
"fresh file" case. A stored version higher than the runner knows
is surfaced as-is; the caller decides how to treat a future version.
Reading is deliberately passive: a missing table raises, it is not
swallowed into 0, because :func:`apply_migrations` always creates
the table before reading and a direct reader hitting "no such
table" is a caller bug worth surfacing, not a fresh file.
"""
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
)
if row is None:
return 0
version = row[0]
if not isinstance(version, int) or isinstance(version, bool):
raise RuntimeError(
f"Schema version metadata is not an integer: {version!r}"
)
if version < 1:
raise RuntimeError(
f"Schema version metadata is invalid (must be >= 1): {version!r}"
)
return version
#: One asyncio.Lock per connection wrapper, created lazily and bound to
#: the running loop, serializing whole apply_migrations runs so two
#: concurrent callers on one connection queue instead of colliding on
#: the wrapper's single-transaction rule.
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the migration lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _MIGRATION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_MIGRATION_LOCKS[key] = lock
return lock
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[Sequence[str]] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
Reads the current version, applies every pending migration in order
— each inside one transaction that also stamps the new version —
and returns the resulting version. Applying zero pending
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Runners on one connection are serialized by an asyncio lock held
for the whole decide-and-apply span: NestQuestDatabase rejects a
second transaction while one is active (RuntimeError), so
concurrent callers must queue, and the lock makes that queueing
explicit. The loser of the race re-reads the version once it
acquires the lock, sees the winner's stamp, and applies nothing.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
async with _migration_lock(database):
await _create_version_table(database)
current = await read_schema_version(database)
latest = len(migrations)
if current > latest:
raise RuntimeError(
f"Database schema version {current} is newer than this "
f"integration understands (latest known: {latest}); "
"downgrades are not supported"
)
if current == latest:
LOGGER.debug(
"NestQuest schema already at version %d; "
"no migrations to apply",
current,
)
return current
for target in range(current + 1, latest + 1):
applied = False
async with database.transaction():
# Re-read the version INSIDE the write transaction: the
# outer read was advisory, and the wrapper serializes
# individual statements, not the read-decide-apply
# decision. Under the transaction's lock the version
# cannot change between the read and the stamp, and the
# loser of an interleaved race (a queued second caller
# re-running apply_migrations after the winner already
# migrated) re-reads here, sees the winner's stamp, and
# skips instead of re-applying a non-idempotent
# migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
for sql in migrations[target - 1]:
await database.execute(sql)
# INSERT keyed on the singleton row (id = 1): a
# fresh file has no row yet, and migration 1 is the
# only migration a version-0 database can run, so
# the row is seeded by the same statement batch
# when needed. UPDATE alone would silently match
# zero rows there.
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = "
"excluded.version",
(target,),
)
applied = True
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d "
"(%d statement%s)",
target,
len(migrations[target - 1]),
"s" if len(migrations[target - 1]) != 1 else "",
)
return latest
+538
View File
@@ -0,0 +1,538 @@
"""The ScheduleRule model: rule types, validation, and serialization.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
"""
from __future__ import annotations
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
else: # YEARLY
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
-336
View File
@@ -1,336 +0,0 @@
"""Declarative schema DDL for the NestQuest SQLite database.
Every v1 table is declared here as plain SQL statements so later schema
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
touching this module's tables. All statements use
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
migration runner is a separate later concern.
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
caller/DAO rather than a DB default, per the feature guardrails. No
``length(created_at) = 25`` CHECK is added: the exact string shape
produced by callers is not fixed yet (``datetime.isoformat()`` may
include microseconds, changing the length), so enforcing a fixed length
here would reject valid values. Only non-null is enforced for now.
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
no time component) compared lexically by the ``end_date >= start_date``
CHECK; due_date/due_time string shapes are caller-validated by the rule
engine and DAO layers, not by DDL.
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
because it sorts, compares and parses trivially in SQL. The format is
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
repetition quantifier, so the element count is enumerated; more than 7
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
This keeps the schema layer storage-only — the DAO layer (tasks
8c72300c+) does not need its own validator, it can rely on the CHECK.
Type affinity: SQLite applies column affinity on insert, but CHECKs
still observe text and fractional values ('abc' stays text, '1.5'
becomes REAL), so every INTEGER column carrying rule semantics carries
a ``typeof(...) = 'integer'`` guard in its CHECK.
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
carry none of the month-specific fields. Relaxed deliberately: the DDL
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
separated digits, which caps a segment at 7 elements — the GLOB cannot
match across a ``|``, so cross-segment boundaries stay safe; and the
segment count is pinned to ``cycle_length_weeks`` by
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
weekday_set this is not full CSV-set normalisation (duplicates like
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
pattern human-readable. Revisit the cap — and re-run the pattern
validation matrix in tests — if a longer cycle is ever needed.
``anchor_date`` is an ISO date string pinning the cycle: the week index
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
computed by the presence engine (Feature 05) from anchor-date
arithmetic, never ISO week parity. Like other date columns it carries
no shape CHECK, matching the established date policy.
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
unlike ``schedule_rules.end_date`` — an override is always a concrete
date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Task instances: ``task_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``,
``due_date`` and ``child_id`` snapshot the materialization-time facts
("why this instance exists"): reassignment and rule edits change future
instances only and never rewrite these rows. ``due_time`` snapshots
the definition's optional due time at generation for the same reason —
the instance keeps its generation-time value even if the definition is
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp
per the module timestamp policy (no DB default, so a generation batch
stamps one coherent time).
Uniqueness: the (definition_id, due_date) pair is declared as a
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
index (``sqlite_autoindex_task_instances_1``) — the required "unique
index prevents duplicate instances" — and the constraint form cannot be
silently dropped the way a standalone index can, so the duplicate door
stays shut. Idempotent materialization keys on exactly this pair. No
secondary indexes beyond it are declared yet: history and board queries
(Features 08/10/13) should justify their indexes as a later migration
once their query shapes exist.
No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a
deletion that would orphan instances or events is a bug and must fail
loudly rather than cascade.
Completion events: ``completion_events`` is strictly append-only. That
property is enforced at the DAO layer — the completion-events DAO
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
by database triggers, matching the module's no-trigger policy: a RAISE
trigger would be the one exception to "pure CHECK, no triggers",
complicate the later migration runner, and still prove nothing (any
writer can run raw SQL). ``event_type`` is constrained to
``completed``/``uncompleted``; a ``missed`` row is intentionally not
representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``task_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is already ``child_id``). A coherence
CHECK makes the pair shape non-negotiable: ``'user'`` requires
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
rejected; if an unattended system actor is ever needed, that is a
schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
flag is decidable. For ``uncompleted`` events the flag is optional:
Feature 08 decides whether a reversal carries the reversed completion's
on-time flag or NULL, and the DDL deliberately accepts both rather than
guessing.
"""
from __future__ import annotations
from .db import NestQuestDatabase
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
#: Each element is a single digit 0-6, separated by a literal comma.
#: GLOB has no repetition quantifier, so the element count is enumerated.
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
#: because weekly rules need at least one weekday.
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
"[0-6]",
"[0-6],[0-6]",
"[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
]
SCHEMA_V1_CHILDREN_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
]
_WEEKDAY_SET_CHECK = " OR ".join(
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
)
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
start_date TEXT NOT NULL,
end_date TEXT,
CHECK (end_date IS NULL OR end_date >= start_date),
CHECK (
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
OR (rule_type = 'yearly' AND month IS NOT NULL)
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
)
)
""",
]
SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS task_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
""",
]
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
note TEXT,
CHECK (end_date >= start_date)
)
""",
]
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS task_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
""",
]
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
]
async def async_apply_ddl(
database: NestQuestDatabase, statements: list[str]
) -> None:
"""Apply each DDL statement inside one transaction on ``database``.
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
applying the same list twice leaves the schema and its rows intact.
"""
async with database.transaction():
for sql in statements:
await database.execute(sql)
+1 -26
View File
@@ -25,9 +25,6 @@ import asyncio
import importlib.util
import inspect
import sys
import tempfile
import weakref
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -221,35 +218,13 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
return entry
class _HassNamespace(SimpleNamespace):
"""SimpleNamespace subclass so the hass stub can hold weak references."""
def make_hass() -> tuple:
"""Create a hass stub plus its listener registry, mirroring HA's shape."""
config_dir = Path(tempfile.mkdtemp(prefix="nestquest-hass-"))
hass = _HassNamespace(
hass = SimpleNamespace(
data={},
config=MagicMock(),
config_entries=SimpleNamespace(async_reload=None),
)
def _cleanup_config_dir():
import shutil
shutil.rmtree(config_dir, ignore_errors=True)
weakref.finalize(hass, _cleanup_config_dir)
hass.config.path = MagicMock(
side_effect=lambda name: str(config_dir / (name or "config"))
)
def _run_executor_job(fn, *args, **kwargs):
return asyncio.get_running_loop().run_in_executor(
None, lambda: fn(*args, **kwargs)
)
hass.async_add_executor_job = _run_executor_job
registry = ListenerRegistry(hass)
async def _async_reload(entry_id: str) -> None:
-553
View File
@@ -1,553 +0,0 @@
"""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/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
}
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"
)
-998
View File
@@ -1,998 +0,0 @@
"""Tests for db.py, the async SQLite connection wrapper of NestQuest."""
from __future__ import annotations
import ast
import asyncio
import sqlite3
import threading
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import make_hass
from custom_components.nestquest import db as db_module
from custom_components.nestquest.db import NestQuestDatabase
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
_run(database.open(path))
return database, hass
# ---------------------------------------------------------------------------
# open(): pragmas, executor delegation, idempotence
# ---------------------------------------------------------------------------
def test_open_enables_wal_journal_mode(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "wal.db")
row = _run(database.fetch_one("PRAGMA journal_mode"))
assert row is not None and str(row[0]).lower() == "wal"
_run(database.close())
def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "fk.db")
row = _run(database.fetch_one("PRAGMA foreign_keys"))
assert row is not None and int(row[0]) == 1
_run(database.close())
def test_open_delegates_connection_to_executor(tmp_path) -> None:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
_run(database.open(tmp_path / "exec.db"))
first_call = hass.async_add_executor_job.call_args_list[0]
assert callable(first_call.args[0])
_run(database.close())
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
path = tmp_path / "once.db"
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
first = _run(database.open(path))
second = _run(database.open(path))
assert first is database
assert second is database
assert database.connected is True
opens = [
c
for c in hass.async_add_executor_job.call_args_list
if getattr(c.args[0], "__name__", "") == "_open"
]
assert len(opens) == 1
_run(database.close())
def test_open_accepts_str_path(tmp_path) -> None:
database, _hass = _open_db(str(tmp_path / "str.db"))
assert database.connected is True
_run(database.close())
# ---------------------------------------------------------------------------
# Executor delegation: every sqlite3 call happens off the event loop
# ---------------------------------------------------------------------------
def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
"""Each wrapper call must go via hass.async_add_executor_job."""
database, hass = _open_db(tmp_path / "route.db")
calls_before = hass.async_add_executor_job.await_count
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("a",)))
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]))
row = _run(database.fetch_one("SELECT name FROM t WHERE id = 1"))
rows = _run(database.fetch_all("SELECT name FROM t ORDER BY id"))
assert row == ("a",)
assert rows == [("a",), ("b",), ("c",)]
assert hass.async_add_executor_job.await_count == calls_before + 5
_run(database.close())
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
"""The executor surface is awaited per wrapper call on a live loop."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "loop.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.fetch_one("SELECT 1")
await database.fetch_all("SELECT 1")
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(1,)])
async with database.transaction():
pass
assert hass.async_add_executor_job.await_count == 7
assert all(callable(c.args[0]) for c in hass.async_add_executor_job.call_args_list)
await database.close()
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "plain.db")
result = await database.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
)
assert not isinstance(result, (sqlite3.Connection, sqlite3.Cursor))
insert = await database.execute("INSERT INTO t (name) VALUES (?)", ("a",))
assert not isinstance(insert, (sqlite3.Connection, sqlite3.Cursor))
assert insert.rowcount == 1
assert insert.lastrowid == 1
many = await database.execute_many(
"INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]
)
assert not isinstance(many, (sqlite3.Connection, sqlite3.Cursor))
assert many.rowcount == 2
row = await database.fetch_one("SELECT name FROM t WHERE id = 1")
assert row == ("a",)
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
assert rows == [("a",), ("b",), ("c",)]
for value in (*rows, row):
assert not isinstance(value, sqlite3.Cursor)
await database.close()
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
"""With the conftest hass (real run_in_executor), queries still succeed."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "real.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
assert await database.fetch_one("SELECT 1") == (1,)
await database.close()
async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) -> None:
"""Every real sqlite3 call must run on a non-event-loop thread.
sqlite3.connect is instrumented so a proxy wraps the connection and
every cursor it hands out; each proxied call records the thread it ran
on. A recorded thread id equal to the event-loop thread would mean a
sqlite3 call crossed the executor boundary.
"""
loop_thread = threading.get_ident()
recorded: list[int] = []
guard = threading.Lock()
def _note() -> None:
with guard:
recorded.append(threading.get_ident())
class _Proxy:
"""Generic proxy recording the thread of every attribute call."""
def __init__(self, target: Any) -> None:
object.__setattr__(self, "_target", target)
def __getattr__(self, name: str):
attr = getattr(object.__getattribute__(self, "_target"), name)
if not callable(attr):
return attr
def _wrapped(*args, **kwargs):
_note()
result = attr(*args, **kwargs)
if isinstance(result, sqlite3.Cursor):
return _Proxy(result)
return result
return _wrapped
real_connect = sqlite3.connect
connect_threads: list[int] = []
def _traced_connect(*args, **kwargs):
_note()
connect_threads.append(threading.get_ident())
return _Proxy(real_connect(*args, **kwargs))
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "threads.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(2,), (3,)])
assert await database.fetch_one("SELECT id FROM t WHERE id = 1") == (1,)
assert await database.fetch_all("SELECT id FROM t") == [(1,), (2,), (3,)]
async with database.transaction():
await database.execute("INSERT INTO t (id) VALUES (?)", (4,))
await database.close()
assert recorded, "expected at least one recorded sqlite3 call"
assert len(connect_threads) == 1, (
f"expected exactly one traced sqlite3.connect, got {len(connect_threads)}"
)
assert loop_thread not in recorded, (
f"sqlite3 call ran on the event-loop thread {loop_thread}; "
f"recorded threads: {sorted(set(recorded))}"
)
# ---------------------------------------------------------------------------
# Source scan: sqlite3.* only inside executor-delegated functions
# ---------------------------------------------------------------------------
def _iter_with_scope(node, chain):
"""Yield (node, enclosing-function chain), depth-first."""
for child in ast.iter_child_nodes(node):
yield child, chain
next_chain = chain + (child,) if isinstance(
child, (ast.FunctionDef, ast.AsyncFunctionDef)
) else chain
yield from _iter_with_scope(child, next_chain)
def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
A function is executor-delegating when it (or, for nested closures, any of
its enclosing functions) passes work to ``hass.async_add_executor_job``.
"""
source = Path(db_module.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(db_module.__file__))
delegated = {
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "async_add_executor_job"
for sub in ast.walk(node)
)
}
assert delegated, "expected executor-delegated functions in db.py"
violations = []
for node, chain in _iter_with_scope(tree, ()):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "sqlite3"
and not any(f.name in delegated for f in chain)
):
violations.append((node.lineno, node.func.attr))
assert violations == [], (
f"Direct sqlite3 calls outside executor-delegated functions: {violations}"
)
def test_package_wide_sqlite3_scan_excludes_only_db_module() -> None:
"""The package-wide store scan exempts exactly the db module."""
pkg_dir = Path(db_module.__file__).parent
scanned = [p.name for p in pkg_dir.glob("*.py") if p.name != "db.py"]
assert scanned, "expected other package modules to be scanned"
assert "db.py" not in scanned
# ---------------------------------------------------------------------------
# close(): idempotence and post-close errors
# ---------------------------------------------------------------------------
def test_close_is_idempotent(tmp_path) -> None:
database, hass = _open_db(tmp_path / "idem.db")
_run(database.close())
assert database.connected is False
_run(database.close())
assert database.connected is False
assert hass.async_add_executor_job.await_count == 2
assert database.rowcount == -1
assert database.lastrowid is None
def test_operations_after_close_raise_clear_error(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "closed.db")
_run(database.close())
async def _txn():
async with database.transaction():
pass
with pytest.raises(RuntimeError, match="closed"):
_run(database.execute("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.fetch_one("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.fetch_all("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.execute_many("SELECT 1", []))
with pytest.raises(RuntimeError, match="closed"):
_run(_txn())
# ---------------------------------------------------------------------------
# Transactions: commit on success, rollback on exception
# ---------------------------------------------------------------------------
def test_transaction_commits_on_success(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "commit.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
async def _block():
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
_run(_block())
assert _run(database.fetch_all("SELECT name FROM t")) == [("kept",)]
_run(database.close())
def test_transaction_rolls_back_on_exception(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "rollback.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("before",)))
async def _block():
with pytest.raises(ValueError, match="boom"):
async with database.transaction():
await database.execute(
"INSERT INTO t (name) VALUES (?)", ("dropped",)
)
raise ValueError("boom")
_run(_block())
assert _run(database.fetch_all("SELECT name FROM t")) == [("before",)]
_run(database.close())
async def test_commit_failure_rolls_back_and_next_transaction_works(
tmp_path,
) -> None:
"""A COMMIT failure rolls back and leaves the wrapper reusable.
With PRAGMA defer_foreign_keys=ON a violating insert succeeds inside
the transaction and the foreign-key violation only surfaces at COMMIT.
The commit error must propagate, the real connection must end up
outside any transaction, and the next transaction() plus statement
must run cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "deferred.db")
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
await database.execute(
"CREATE TABLE child ("
"id INTEGER PRIMARY KEY, "
"parent_id INTEGER NOT NULL REFERENCES parent(id))"
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
async with database.transaction():
await database.execute("PRAGMA defer_foreign_keys=ON")
await database.execute(
"INSERT INTO child (parent_id) VALUES (?)", (999,)
)
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
assert await database.fetch_all("SELECT COUNT(*) FROM child") == [(0,)]
async with database.transaction():
await database.execute("INSERT INTO parent (id) VALUES (?)", (1,))
await database.execute(
"INSERT INTO child (parent_id) VALUES (?)", (1,)
)
assert await database.fetch_all("SELECT COUNT(*) FROM child") == [(1,)]
await database.close()
async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
"""NQ-PR12-010: cancel during open() must close the settled connection.
The _open executor job is gated: the open task is cancelled while the
connect is still in flight, then the worker is allowed to complete it
anyway. The settled connection must never be adopted, must be closed
through a settled executor-backed close before the cancellation
propagates, and a follow-up open() must succeed cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
open_started = asyncio.Event()
release_open = asyncio.Event()
jobs: list[str] = []
captured: dict[str, Any] = {}
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
name = getattr(fn, "__name__", "")
jobs.append(name)
if name == "_open":
open_started.set()
await release_open.wait()
result = await real_add(fn, *args, **kwargs)
if name == "_open":
captured["conn"] = result
return result
hass.async_add_executor_job = _gated_add_executor_job
open_task = asyncio.create_task(database.open(tmp_path / "cancel-open.db"))
await open_started.wait()
open_task.cancel()
await asyncio.sleep(0.05)
assert database._conn is None, (
"the cancelled open adopted a connection that was still settling"
)
release_open.set()
with pytest.raises(asyncio.CancelledError):
await open_task
assert database.connected is False
assert "_close_abandoned" in jobs, (
"the abandoned connection was not closed via an executor job"
)
conn = captured.get("conn")
assert conn is not None
def _probe() -> str:
try:
conn.execute("SELECT 1")
except sqlite3.ProgrammingError:
return "closed"
return "open"
assert await real_add(_probe) == "closed"
hass.async_add_executor_job = real_add
await database.open(tmp_path / "cancel-open.db")
assert database.connected is True
assert await database.fetch_one("SELECT 1") == (1,)
await database.close()
async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
"""NQ-PR12-007: cancel during BEGIN must not leak a stray transaction.
The BEGIN executor job is gated: the transaction task is cancelled
while BEGIN is still in flight, then the worker is allowed to
complete it anyway. The wrapper must keep transaction ownership
until the submitted work settles, roll the completed BEGIN back,
clear its state, and stay usable for the next transaction.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-begin.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
begin_started = asyncio.Event()
release_begin = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_begin":
begin_started.set()
await release_begin.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
txn_task = asyncio.create_task(_txn())
await begin_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled BEGIN was still in flight"
)
release_begin.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",)
]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",),
("kept",),
]
await database.close()
async def test_cancel_during_commit_settles_then_propagates_cancellation(
tmp_path,
) -> None:
"""NQ-PR12-007: cancel during COMMIT must not mask the cancellation.
COMMIT is gated; the task is cancelled while it is in flight and the
worker then completes it anyway. The cancellation must propagate as
CancelledError (not be converted by a doomed ROLLBACK of an already
committed transaction), the connection must end outside any
transaction, and the wrapper must stay usable.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-commit.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
commit_started = asyncio.Event()
release_commit = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_commit":
commit_started.set()
await release_commit.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
txn_task = asyncio.create_task(_txn())
await commit_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled COMMIT was still in flight"
)
release_commit.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t") == [("kept",)]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("again",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("kept",),
("again",),
]
await database.close()
async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
tmp_path,
) -> None:
"""NQ-PR12-009: cancel during an in-transaction statement must not race ROLLBACK.
The in-transaction INSERT executor job is gated: the transaction task
is cancelled while the statement is still in flight, then the worker
is allowed to complete it anyway. The ROLLBACK must only run after
the statement has settled — while the statement is in flight the
transaction is still open — and the cancelled statement's write must
not persist.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-execute.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
insert_started = asyncio.Event()
release_insert = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_execute":
insert_started.set()
await release_insert.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("lost",))
txn_task = asyncio.create_task(_txn())
await insert_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled statement was in flight"
)
assert database._conn is not None
assert database._conn.in_transaction is True, (
"ROLLBACK ran before the cancelled statement settled"
)
release_insert.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",)
]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",),
("kept",),
]
await database.close()
async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None:
"""NQ-PR12-008: both errors survive when COMMIT and ROLLBACK both fail.
The COMMIT executor call is forced to fail, and the follow-up
cleanup ROLLBACK is forced to fail as well. The raised error must
keep both original exceptions inspectable instead of discarding the
commit failure behind the rollback failure.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "both-fail.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
commit_failure = sqlite3.OperationalError("commit exploded")
rollback_failure = sqlite3.OperationalError("rollback exploded")
real_add = hass.async_add_executor_job
async def _failing_add_executor_job(fn, *args, **kwargs):
name = getattr(fn, "__name__", "")
if name == "_commit":
raise commit_failure
if name == "_cleanup":
raise rollback_failure
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _failing_add_executor_job
with pytest.raises(BaseExceptionGroup) as exc_info:
async with database.transaction():
pass
assert commit_failure in exc_info.value.exceptions
assert rollback_failure in exc_info.value.exceptions
assert database._in_transaction is False
assert database._tx_task is None
await database.close()
async def test_transaction_persists_across_connections(tmp_path) -> None:
"""A committed transaction is visible to a brand-new connection."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "persist.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
await database.close()
other = NestQuestDatabase(hass)
await other.open(tmp_path / "persist.db")
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
await other.close()
# ---------------------------------------------------------------------------
# Serialization: one lock per wrapper, no cross-commit interleaving
# ---------------------------------------------------------------------------
async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
"""Real-executor proof: nothing commits inside another transaction's span.
A transaction runner repeatedly counts rows, inserts, and counts again
inside BEGIN..COMMIT; the count must grow by exactly one, proving no
other writer committed in between even though several writer tasks fire
statements concurrently on real executor threads. Statements issued
while a transaction is open queue behind it on the wrapper lock.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "serial.db")
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
tx_rounds, writers, writer_rounds = 6, 5, 8
total = tx_rounds + writers * writer_rounds
async def _tx_runner() -> None:
for rnd in range(tx_rounds):
async with database.transaction():
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
seen = rows[0][0] if rows else -1
await database.execute(
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("tx", rnd)
)
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
grown = rows[0][0] if rows else -1
if grown != seen + 1:
raise AssertionError(
f"interleaved commit: count {seen} -> {grown} "
f"in tx round {rnd}"
)
async def _writer(worker: int) -> None:
for rnd in range(writer_rounds):
await database.execute(
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("w", worker)
)
await asyncio.gather(
_tx_runner(), *(_writer(w) for w in range(writers))
)
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
assert rows[0][0] == total, f"expected {total} rows, got {rows[0][0]}"
await database.close()
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
"""A second transaction caller is rejected with a clear error."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "reject.db")
await database.execute("CREATE TABLE t (id INTEGER)")
started = asyncio.Event()
async def _first() -> None:
async with database.transaction():
started.set()
await asyncio.sleep(0.05)
first = asyncio.create_task(_first())
await started.wait()
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
pass
await first
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
assert await database.fetch_one("SELECT id FROM t") == (1,)
await database.close()
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
"""A statement issued during an open transaction queues behind it."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "block.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
order: list[str] = []
started = asyncio.Event()
release = asyncio.Event()
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("in_tx",))
order.append("tx-start")
started.set()
await release.wait()
order.append("tx-end")
async def _outsider() -> None:
await started.wait()
await database.execute("INSERT INTO t (name) VALUES (?)", ("after",))
order.append("outside")
txn_task = asyncio.create_task(_txn())
await started.wait()
outsider_task = asyncio.create_task(_outsider())
await asyncio.sleep(0.05)
assert order == ["tx-start"], f"outsider ran inside transaction: {order}"
release.set()
await asyncio.gather(txn_task, outsider_task)
assert order == ["tx-start", "tx-end", "outside"]
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
assert rows == [("in_tx",), ("after",)]
await database.close()
async def test_concurrent_transaction_is_rejected_with_clear_error(
tmp_path,
) -> None:
"""A second concurrent transaction() gets a clear RuntimeError."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "concurrent-tx.db")
await database.execute("CREATE TABLE t (id INTEGER)")
started = asyncio.Event()
async def _first() -> None:
async with database.transaction():
started.set()
await asyncio.sleep(0.05)
first = asyncio.create_task(_first())
await started.wait()
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
pass
await first
await database.close()
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
"""close() serializes: an open transaction finishes before shutdown."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "closerace.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
started = asyncio.Event()
release = asyncio.Event()
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
started.set()
await release.wait()
txn_task = asyncio.create_task(_txn())
await started.wait()
close_task = asyncio.create_task(database.close())
await asyncio.sleep(0.05)
assert database.connected is True, "close() raced an open transaction"
release.set()
await asyncio.gather(txn_task, close_task)
assert database.connected is False
witness = NestQuestDatabase(hass)
await witness.open(tmp_path / "closerace.db")
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
await witness.close()
async def test_close_during_transaction_from_same_task_is_rejected(
tmp_path,
) -> None:
"""close() inside its own transaction body raises instead of deadlocking."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "selfclose.db")
await database.execute("CREATE TABLE t (id INTEGER)")
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
await database.close()
assert database.connected is True
await database.close()
# ---------------------------------------------------------------------------
# rowcount / lastrowid surface
# ---------------------------------------------------------------------------
def test_rowcount_and_lastrowid_before_any_operation(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "unset.db")
assert database.rowcount == -1
assert database.lastrowid is None
_run(database.close())
def test_execute_exposes_rowcount_and_lastrowid(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "counts.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("x",)))
assert database.rowcount == 1
assert database.lastrowid == 1
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("y",)))
assert database.rowcount == 1
assert database.lastrowid == 2
_run(database.close())
def test_execute_many_exposes_rowcount(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "many.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("a",), ("b",)]))
assert database.rowcount == 2
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("c",)))
assert database.lastrowid == 3
_run(database.close())
# ---------------------------------------------------------------------------
# Lifecycle wiring: opened once per entry, closed on unload
# ---------------------------------------------------------------------------
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
runtime = entry.runtime_data
assert runtime.database is not None
assert runtime.database.connected is True
await async_unload_entry(hass, entry)
async def test_unload_entry_closes_database(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
assert database.connected is True
assert await async_unload_entry(hass, entry) is True
assert database.connected is False
with pytest.raises(RuntimeError, match="closed"):
await database.fetch_one("SELECT 1")
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
first = entry.runtime_data.database
await async_setup_entry(hass, entry)
second = entry.runtime_data.database
assert first is second
assert first.connected is True
await async_unload_entry(hass, entry)
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
seen: list[NestQuestDatabase] = []
for _ in range(3):
await async_setup_entry(hass, entry)
runtime = entry.runtime_data
assert runtime.database.connected is True
seen.append(runtime.database)
await async_unload_entry(hass, entry)
assert runtime.database.connected is False
assert len(set(seen)) == 3
assert hass.data == {}
+1 -60
View File
@@ -3,8 +3,6 @@ from __future__ import annotations
import asyncio
import pytest
from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
@@ -198,61 +196,4 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
assert result is True
assert removed == [True]
assert hass.data == {}
assert entry.runtime_data is None
async def test_setup_closes_database_when_listener_registration_fails() -> None:
"""A listener-registration failure must not leak the opened database."""
from custom_components.nestquest.db import NestQuestDatabase
class _Boom(Exception):
pass
entry = make_config_entry(entry_id="listener-boom")
entry.add_update_listener = lambda listener: (_ for _ in ()).throw(_Boom())
hass, _registry = make_hass()
with pytest.raises(_Boom):
await async_setup_entry(hass, entry)
assert hass.data.get(DOMAIN, {}) == {}
assert not hasattr(entry, "runtime_data")
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
"""Unload removes the listener, closes the DB in a finally, then raises."""
from custom_components.nestquest.db import NestQuestDatabase
class _RemoveBoom(Exception):
pass
def _remover():
raise _RemoveBoom()
entry = make_config_entry(entry_id="remove-boom")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert database.connected is False, "database leaked despite remover error"
assert entry.runtime_data is None
assert DOMAIN not in hass.data
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
rows = await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name IN ('children', 'admin_users', 'schedule_rules', "
"'task_definitions') ORDER BY name"
)
assert [row[0] for row in rows] == [
"admin_users",
"children",
"schedule_rules",
"task_definitions",
]
await async_unload_entry(hass, entry)
assert entry.runtime_data is None
-677
View File
@@ -1,677 +0,0 @@
"""Tests for migrations.py: the versioned schema migration runner."""
from __future__ import annotations
import asyncio
import hashlib
import logging
import sqlite3
from unittest.mock import AsyncMock
import pytest
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import (
MIGRATIONS,
VERSION_TABLE,
VERSION_TABLE_DDL,
apply_migrations,
read_schema_version,
)
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
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))
return database
def _migrate(database, migrations=MIGRATIONS):
return _run(apply_migrations(database, migrations))
def _version(database) -> int:
return _run(read_schema_version(database))
def _tables(database) -> set[str]:
rows = _run(
database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%'"
)
)
return {row[0] for row in rows}
def _counts(database) -> dict[str, int]:
"""Row counts for every v1 data table plus the version table."""
tables = (
"children",
"admin_users",
"schedule_rules",
"task_definitions",
"presence_schedules",
"presence_overrides",
"task_instances",
"completion_events",
)
counts = {}
for table in tables:
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
counts[table] = row[0]
return counts
# ---------------------------------------------------------------------------
# Fresh file: version 0 -> version 1
# ---------------------------------------------------------------------------
def test_fresh_file_migrates_to_version_1(tmp_path) -> None:
database = _open_db(tmp_path / "fresh.db")
try:
final = _migrate(database)
assert final == 1
assert _version(database) == 1
finally:
_run(database.close())
def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
database = _open_db(tmp_path / "fresh-tables.db")
try:
_migrate(database)
expected = {
"children",
"admin_users",
"schedule_rules",
"task_definitions",
"presence_schedules",
"presence_overrides",
"task_instances",
"completion_events",
VERSION_TABLE,
}
assert _tables(database) == expected
finally:
_run(database.close())
def test_missing_version_table_raises_on_direct_read(tmp_path) -> None:
database = _open_db(tmp_path / "no-table.db")
try:
with pytest.raises(sqlite3.OperationalError, match="no such table"):
_run(read_schema_version(database))
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
f"AND name = '{VERSION_TABLE}'"
)
)
assert row is None, "read_schema_version must not create the table"
finally:
_run(database.close())
def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
database = _open_db(tmp_path / "empty-table.db")
try:
_run(database.execute(VERSION_TABLE_DDL))
assert _version(database) == 0
finally:
_run(database.close())
def test_migration_1_is_the_full_v1_ddl() -> None:
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert len(MIGRATIONS) == 1
# ---------------------------------------------------------------------------
# Idempotence: second start is a no-op
# ---------------------------------------------------------------------------
def test_second_run_applies_nothing_and_keeps_rows(tmp_path) -> None:
database = _open_db(tmp_path / "idempotent.db")
try:
_migrate(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
_run(
database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?)",
("user-1", "2026-09-13T00:00:00+00:00"),
)
)
final = _migrate(database)
assert final == 1
assert _version(database) == 1
assert _counts(database)["children"] == 1
assert _counts(database)["admin_users"] == 1
finally:
_run(database.close())
def test_second_run_writes_nothing_to_the_file(tmp_path, caplog) -> None:
database = _open_db(tmp_path / "noop.db")
try:
_migrate(database)
_run(database.execute("PRAGMA wal_checkpoint(TRUNCATE)"))
db_file = tmp_path / "noop.db"
wal_file = tmp_path / "noop.db-wal"
digest_before = hashlib.sha256(db_file.read_bytes()).hexdigest()
mtime_before = db_file.stat().st_mtime_ns
wal_digest_before = (
hashlib.sha256(wal_file.read_bytes()).hexdigest()
if wal_file.exists()
else None
)
_migrate(database)
# A no-op run must not open a write transaction: the main
# database file is byte-identical (content hash, not just size)
# and untouched (mtime too), and the WAL gained no frames.
assert db_file.stat().st_mtime_ns == mtime_before
assert (
hashlib.sha256(db_file.read_bytes()).hexdigest() == digest_before
)
if wal_digest_before is None:
assert not wal_file.exists()
else:
assert (
hashlib.sha256(wal_file.read_bytes()).hexdigest()
== wal_digest_before
)
finally:
_run(database.close())
def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
database = _open_db(tmp_path / "noop-log.db")
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"):
_migrate(database)
migrated = [
r for r in caplog.records if "migrated" in r.getMessage().lower()
]
assert migrated == []
finally:
_run(database.close())
def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
database = _open_db(tmp_path / "noop-debug.db")
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
_migrate(database)
assert any(
"already at version 1" in r.getMessage()
for r in caplog.records
)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Failure atomicity: a failing migration leaves the prior version intact
# ---------------------------------------------------------------------------
def _failing_migrations(prior_statements: list[str]) -> list[list[str]]:
"""Two-migration list where migration 2 fails mid-application."""
return [
prior_statements,
[
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)",
"CREATE TABLE broken (",
],
]
def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
database = _open_db(tmp_path / "rollback.db")
try:
# Manually simulate a database at version 1: apply migration 1's
# statements directly and stamp version 1 through the runner.
_migrate(database)
version_row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
)
assert version_row == (1,)
with pytest.raises(sqlite3.OperationalError):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
# Version stamp unchanged, statements from the failed migration
# absent, and the v1 tables untouched.
assert _version(database) == 1
assert "later_table" not in _tables(database)
assert _counts(database)["children"] == 0
assert _counts(database)["completion_events"] == 0
finally:
_run(database.close())
def test_failing_migration_on_fresh_file_leaves_version_0(tmp_path) -> None:
database = _open_db(tmp_path / "fresh-rollback.db")
try:
# The fresh file still reaches migration 2 (the failing one):
# migration 1 commits, stamping version 1, before migration 2
# fails and rolls back. The run aborts at version 1 — the last
# fully-applied migration — not at version 0.
with pytest.raises(sqlite3.OperationalError):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
assert _version(database) == 1
# Migration 2's tables must not survive the failed attempt.
assert "later_table" not in _tables(database)
# Migration 1's tables do: it committed before the failure.
assert "children" in _tables(database)
assert "task_instances" in _tables(database)
finally:
_run(database.close())
def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None:
database = _open_db(tmp_path / "retry.db")
try:
_migrate(database)
broken = _failing_migrations(SCHEMA_V1_STATEMENTS)
with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken)
# "Fix" the migration and retry: it now applies cleanly.
broken[1] = [
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)"
]
final = _migrate(database, broken)
assert final == 2
assert _version(database) == 2
assert "later_table" in _tables(database)
finally:
_run(database.close())
def test_failing_migration_error_is_preserved(tmp_path) -> None:
database = _open_db(tmp_path / "error-preserved.db")
try:
with pytest.raises(sqlite3.OperationalError, match="incomplete input"):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Version guard rails
# ---------------------------------------------------------------------------
def test_future_version_is_rejected(tmp_path) -> None:
database = _open_db(tmp_path / "future.db")
try:
_migrate(database)
_run(
database.execute(
f"UPDATE {VERSION_TABLE} SET version = ?", (99,)
)
)
with pytest.raises(RuntimeError, match="newer than"):
_migrate(database)
# Nothing was modified by the rejected run.
assert _version(database) == 99
finally:
_run(database.close())
def test_non_integer_version_raises_runtime_error(tmp_path) -> None:
database = _open_db(tmp_path / "string-version.db")
try:
_run(database.execute(VERSION_TABLE_DDL))
# The CHECK enforces version >= 1 but SQLite's loose typing
# stores TEXT under an INTEGER-affinity column when it cannot
# be losslessly coerced; guard the read path against it.
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, ?)",
("one",),
)
)
with pytest.raises(RuntimeError, match="not an integer"):
_run(read_schema_version(database))
finally:
_run(database.close())
def test_negative_version_read_guard_rejects_below_one() -> None:
"""The read path rejects sub-1 versions even if storage missed them.
The VERSION_TABLE CHECK already makes negative/zero unstorable; this
exercises the defense-in-depth guard in read_schema_version itself
against a database stub returning such a row (e.g. a future schema
relaxing the CHECK, or a hand-edited file).
"""
from unittest.mock import AsyncMock, MagicMock
for bad in (-1, 0):
database = MagicMock()
database.fetch_one = AsyncMock(return_value=(bad,))
with pytest.raises(RuntimeError, match="must be >= 1"):
_run(read_schema_version(database))
def test_non_integer_version_read_guard_rejects_text() -> None:
from unittest.mock import AsyncMock, MagicMock
for bad in ("one", 1.5, True):
database = MagicMock()
database.fetch_one = AsyncMock(return_value=(bad,))
with pytest.raises(RuntimeError, match="not an integer"):
_run(read_schema_version(database))
def test_zero_version_row_is_unstorable(tmp_path) -> None:
"""The CHECK makes sub-1 versions impossible through SQL.
PRAGMA writable_schema does not bypass CHECKs on ordinary tables,
so the negative/zero read guard above is pure defense-in-depth;
here we prove the storage-level rule the normal path relies on.
"""
database = _open_db(tmp_path / "zero-version.db")
try:
_run(database.execute(VERSION_TABLE_DDL))
for bad in (0, -1):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?)",
(bad,),
)
)
finally:
_run(database.close())
def test_version_table_enforces_singleton_row(tmp_path) -> None:
database = _open_db(tmp_path / "singleton.db")
try:
_run(database.execute(VERSION_TABLE_DDL))
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 1)"
)
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (2, 1)"
)
)
finally:
_run(database.close())
def test_version_table_check_rejects_version_zero_and_negative(
tmp_path,
) -> None:
database = _open_db(tmp_path / "version-check.db")
try:
_run(database.execute(VERSION_TABLE_DDL))
for bad in (0, -1):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?)",
(bad,),
)
)
finally:
_run(database.close())
def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None:
"""Two concurrent runners on one connection apply each step once.
Deterministic race: runner B starts while runner A is queued into
its migration. A applies everything and stamps; B then acquires
the migration lock, re-reads the already-stamped version, and
applies nothing. Without serialization B's transaction() would
raise RuntimeError (the wrapper forbids concurrent transactions),
so this also proves the queueing works.
"""
migrations = [
["CREATE TABLE IF NOT EXISTS step_a (id INTEGER PRIMARY KEY)"],
["CREATE TABLE IF NOT EXISTS step_b (id INTEGER PRIMARY KEY)"],
]
results: dict[str, int] = {}
async def _main(tmp_name: str) -> None:
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_name)
try:
# Start A, yield so it acquires the lock and enters its
# first transaction, then start B while A is mid-apply.
task_a = asyncio.ensure_future(
apply_migrations(database, migrations)
)
await asyncio.sleep(0)
task_b = asyncio.ensure_future(
apply_migrations(database, migrations)
)
results["a"], results["b"] = await asyncio.gather(
task_a, task_b
)
tables = {
row[0]
for row in await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%'"
)
}
assert {"step_a", "step_b"} <= tables
# Each migration's statements ran exactly once: the CREATE
# IF NOT EXISTS would hide double-runs, so count via sqlite
# sequence/DDL fingerprint instead — re-check version and
# that both runners reported the same final version.
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE}"
)
assert row == (2,)
finally:
await database.close()
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_main(str(tmp_path / "concurrent.db")))
finally:
loop.close()
assert results["a"] == 2
assert results["b"] == 2
def test_multiple_pending_migrations_apply_in_order(tmp_path) -> None:
database = _open_db(tmp_path / "multi-step.db")
try:
migrations = [
["CREATE TABLE IF NOT EXISTS step_a (id INTEGER PRIMARY KEY)"],
["CREATE TABLE IF NOT EXISTS step_b (id INTEGER PRIMARY KEY)"],
["CREATE TABLE IF NOT EXISTS step_c (id INTEGER PRIMARY KEY)"],
]
final = _run(apply_migrations(database, migrations))
assert final == 3
assert _version(database) == 3
assert {"step_a", "step_b", "step_c"} <= _tables(database)
finally:
_run(database.close())
def test_partial_pending_migrations_resume_from_current_version(
tmp_path,
) -> None:
database = _open_db(tmp_path / "resume.db")
try:
migrations = [
["CREATE TABLE IF NOT EXISTS step_a (id INTEGER PRIMARY KEY)"],
["CREATE TABLE IF NOT EXISTS step_b (id INTEGER PRIMARY KEY)"],
]
# Advance to version 1 only, by applying a truncated list of the
# same migrations: patch apply_migrations' latest by passing a
# single-migration list first, then the full list.
_run(apply_migrations(database, migrations[:1]))
assert _version(database) == 1
final = _run(apply_migrations(database, migrations))
assert final == 2
assert _version(database) == 2
assert "step_b" in _tables(database)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Startup wiring: setup runs the migration runner, not raw DDL
# ---------------------------------------------------------------------------
async def _setup_entry(hass, entry, registry) -> object:
from tests.test_lifecycle import _wire # reuse the established wiring
entry = _wire(entry, registry)
from custom_components.nestquest import async_setup_entry
await async_setup_entry(hass, entry)
return entry
async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
import sqlite3 as sqlite3_mod
from custom_components.nestquest.migrations import read_schema_version
from custom_components.nestquest.store import async_get_db_path
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
assert await read_schema_version(database) == 1
# The v1 tables exist because the runner applied migration 1.
names = {
row[0]
for row in await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%'"
)
}
assert "children" in names and "completion_events" in names
# The version table exists with its singleton row stamped.
columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})")
assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)]
db_path = await async_get_db_path(hass)
conn = sqlite3_mod.connect(db_path)
try:
conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)")
violated = False
except sqlite3_mod.IntegrityError:
violated = True
finally:
conn.close()
assert violated, "singleton-row CHECK must be live in the setup-created DB"
await database.close()
async def test_options_reload_reopens_and_rechecks_version(
hass, make_entry
) -> None:
"""A listener-triggered reload re-runs setup against the same file.
Unload then setup is the reload lifecycle: the old connection is
closed, apply_migrations runs again on the persisted file, keeps
version and rows intact, and the reopened connection is live.
"""
from custom_components.nestquest import async_unload_entry
from custom_components.nestquest.migrations import read_schema_version
entry = await _setup_entry(hass, make_entry(), hass.registry)
database_a = entry.runtime_data.database
await database_a.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
assert await async_unload_entry(hass, entry) is True
assert not database_a.connected
entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry)
database_b = entry_reloaded.runtime_data.database
assert database_b is not database_a
try:
assert await read_schema_version(database_b) == 1
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
assert row == (1,)
finally:
await database_b.close()
async def test_setup_entry_failure_closes_database(
hass, make_entry, monkeypatch
) -> None:
"""A failing migration closes the opened connection before re-raising.
Spies on the real wrapper instance: records whether close() ran and
whether the connection is left disconnected, rather than inferring
from hass.data (a failed setup never stores a runtime record).
"""
import pytest as pytest_module
import custom_components.nestquest as nestquest_module
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import (
MIGRATIONS as _MIGRATIONS,
)
async def _failing(database, migrations=_MIGRATIONS):
raise RuntimeError("boom")
monkeypatch.setattr(
nestquest_module, "apply_migrations", _failing, raising=True
)
close_calls: list[bool] = []
original_close = NestQuestDatabase.close
async def _spy_close(self):
close_calls.append(True)
return await original_close(self)
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
entry = make_entry()
with pytest_module.raises(RuntimeError, match="boom"):
await _setup_entry(hass, entry, hass.registry)
assert close_calls == [True], (
"failed setup must close the connection it opened"
)
record = hass.data["nestquest"].get(entry.entry_id)
assert record is None, "failed setup must not store runtime data"
+295
View File
@@ -0,0 +1,295 @@
"""Tests for the daily recurrence evaluation (occurs_on/occurrences_between)."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def test_daily_fires_every_day_from_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert occurs_on(rule, _d("2026-09-01")) is True
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-09-30")) is True
def test_daily_never_fires_before_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert occurs_on(rule, _d("2026-08-31")) is False
def test_daily_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-10",
)
assert occurs_on(rule, _d("2026-09-10")) is True
assert occurs_on(rule, _d("2026-09-11")) is False
def test_daily_interval_2_fires_every_second_day() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
# Odd offsets fire (0, 2, 4...); odd offsets don't.
assert occurs_on(rule, _d("2026-09-01")) is True # offset 0
assert occurs_on(rule, _d("2026-09-02")) is False # offset 1
assert occurs_on(rule, _d("2026-09-03")) is True # offset 2
assert occurs_on(rule, _d("2026-09-04")) is False # offset 3
assert occurs_on(rule, _d("2026-09-05")) is True # offset 4
def test_daily_interval_3_fires_every_third_day() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
)
assert occurs_on(rule, _d("2026-09-01")) is True # 0
assert occurs_on(rule, _d("2026-09-02")) is False # 1
assert occurs_on(rule, _d("2026-09-03")) is False # 2
assert occurs_on(rule, _d("2026-09-04")) is True # 3
assert occurs_on(rule, _d("2026-09-07")) is True # 6
def test_hand_checked_30_day_sequence_interval_1() -> None:
"""The done-condition's hand-checked sequence: 30 days, all fire."""
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
expected = [
(2026, 9, day) for day in range(1, 31)
]
for (year, month, day) in expected:
assert occurs_on(rule, datetime.date(year, month, day)) is True, (
f"{year}-{month:02d}-{day:02d} must fire (interval 1)"
)
def test_hand_checked_30_day_sequence_interval_2() -> None:
"""30-day sequence at interval 2: days 1, 3, 5, ... (odd days)."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
for day in range(1, 31):
fired = occurs_on(rule, datetime.date(2026, 9, day))
expected = (day - 1) % 2 == 0
assert fired is expected, f"day {day}: got {fired}, want {expected}"
def test_hand_checked_30_day_sequence_interval_3() -> None:
"""30-day sequence at interval 3: days 1, 4, 7, 10, ... 28."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
)
fired_days = [
day
for day in range(1, 31)
if occurs_on(rule, datetime.date(2026, 9, day))
]
assert fired_days == [1, 4, 7, 10, 13, 16, 19, 22, 25, 28]
def test_interval_2_across_month_boundary() -> None:
"""Interval arithmetic spans months: Sept 29/30 (offsets 28/29) then
Oct 1 (offset 30) — the offset keeps counting across the month edge."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
assert occurs_on(rule, _d("2026-09-29")) is True # offset 28
assert occurs_on(rule, _d("2026-09-30")) is False # offset 29
assert occurs_on(rule, _d("2026-10-01")) is True # offset 30
assert occurs_on(rule, _d("2026-10-02")) is False # offset 31
def test_interval_2_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
)
assert occurs_on(rule, _d("2026-12-30")) is True # 0
assert occurs_on(rule, _d("2026-12-31")) is False # 1
assert occurs_on(rule, _d("2027-01-01")) is True # 2
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_occurrences_between_daily_interval_1() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
result = occurrences_between(rule, "2026-09-05", "2026-09-08")
assert result == [
"2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08",
]
def test_occurrences_between_daily_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
result = occurrences_between(rule, "2026-09-01", "2026-09-10")
assert result == [
"2026-09-01", "2026-09-03", "2026-09-05", "2026-09-07", "2026-09-09",
]
def test_occurrences_between_clamps_to_rule_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-05")
result = occurrences_between(rule, "2026-09-01", "2026-09-08")
assert result == ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"]
def test_occurrences_between_clamps_to_rule_end() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-06",
)
result = occurrences_between(rule, "2026-09-01", "2026-09-20")
assert result == [
f"2026-09-{day:02d}" for day in range(1, 7)
]
def test_occurrences_between_disjoint_range_is_empty() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-15",
)
# The rule ends Sept 15: October is entirely outside its window.
assert occurrences_between(rule, "2026-10-01", "2026-10-05") == []
assert occurrences_between(rule, "2026-08-01", "2026-08-05") == []
def test_occurrences_between_inclusive_bounds() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
# Both boundary dates fire at interval 2 (offsets 0 and 6).
result = occurrences_between(rule, "2026-09-01", "2026-09-07")
assert result[0] == "2026-09-01"
assert result[-1] == "2026-09-07"
def test_occurrences_between_rejects_inverted_range() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-09-10", "2026-09-05")
def test_occurrences_between_rejects_malformed_dates() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
occurrences_between(rule, "oops", "2026-09-05")
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
occurrences_between(rule, "2026-09-05", "2026-9-5")
def test_occurrences_between_leap_day_fires() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-02-27")
# 2028 is a leap year: Feb 29 exists and is 2 days after Feb 27.
result = occurrences_between(rule, "2028-02-28", "2028-03-01")
assert result == ["2028-02-28", "2028-02-29", "2028-03-01"]
def test_occurrences_between_year_boundary() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-12-30")
result = occurrences_between(rule, "2026-12-30", "2027-01-02")
assert result == [
"2026-12-30", "2026-12-31", "2027-01-01", "2027-01-02",
]
# ---------------------------------------------------------------------------
# Purity: the engine raises on unknown shapes rather than guessing
# ---------------------------------------------------------------------------
def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None:
"""Monthly/yearly land with their own engine tasks; a loud
NotImplementedError beats a silent wrong answer."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6,
start_date="2026-09-01",
)
with pytest.raises(NotImplementedError):
occurs_on(rule, _d("2026-09-15"))
def test_interval_7_preserves_weekday_alignment_across_months() -> None:
"""Weekly-by-interval semantics: every firing lands on the SAME
weekday as start_date, no matter how many month edges intervene."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01"
)
# 2026-09-01 is a Tuesday; every firing must be a Tuesday too.
assert _d("2026-09-01").weekday() == 1
for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"):
assert occurs_on(rule, _d(day)) is True, day
assert _d(day).weekday() == 1, f"{day} must be a Tuesday"
# The day after a firing is never a firing.
assert occurs_on(rule, _d("2026-10-07")) is False
def test_interval_2_across_leap_day() -> None:
"""Interval > 1 arithmetic across a leap day: the extra day shifts
subsequent offsets by one, and the mod arithmetic reflects it."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
)
assert occurs_on(rule, _d("2028-02-27")) is True # 0
assert occurs_on(rule, _d("2028-02-28")) is False # 1
assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day)
assert occurs_on(rule, _d("2028-03-01")) is False # 3
assert occurs_on(rule, _d("2028-03-02")) is True # 4
def test_occurrences_between_leap_day_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
)
result = occurrences_between(rule, "2028-02-27", "2028-03-03")
assert result == ["2028-02-27", "2028-02-29", "2028-03-02"]
def test_occurrences_between_year_boundary_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
)
result = occurrences_between(rule, "2026-12-30", "2027-01-04")
# Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are
# Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets:
assert result == ["2026-12-30", "2027-01-01", "2027-01-03"]
def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None:
"""An unimplemented shape must raise whether the date is inside or
outside the window — silent False would hide a missing engine."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6,
start_date="2026-09-01", end_date="2026-09-10",
)
# Before start, inside window, after end: all must raise.
for day in ("2026-08-31", "2026-09-07", "2026-09-20"):
with pytest.raises(NotImplementedError):
occurs_on(rule, _d(day))
def test_occurrences_between_unimplemented_shape_raises_even_disjoint(
tmp_path,
) -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6,
start_date="2026-09-01"
)
# A range entirely before the rule's start still raises: the shape
# is unimplemented, and silence would hide it.
with pytest.raises(NotImplementedError):
occurrences_between(rule, "2026-08-01", "2026-08-10")
+480
View File
@@ -0,0 +1,480 @@
"""Tests for recurrence.py: the ScheduleRule model and serialization."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
)
def _rule(**kwargs) -> ScheduleRule:
return ScheduleRule(**kwargs)
# ---------------------------------------------------------------------------
# Valid construction for every rule type
# ---------------------------------------------------------------------------
def test_daily_rule_constructs_with_defaults() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert rule.rule_type is RuleType.DAILY
assert rule.interval == 1
assert rule.weekday_set is None
assert rule.day_of_month is None
assert rule.nth_weekday is None
assert rule.month is None
assert rule.end_date is None
def test_string_rule_type_coerces() -> None:
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
assert rule.rule_type is RuleType.WEEKLY
def test_weekly_rule_requires_and_freezes_weekday_set() -> None:
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01")
assert rule.weekday_set == frozenset({0, 2, 4})
# A mutable set passed in is frozen on construction.
source = {1, 3}
rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01")
source.add(5)
assert rule2.weekday_set == frozenset({1, 3})
def test_monthly_day_rule_fields() -> None:
rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01")
assert rule.day_of_month == 15
assert rule.nth_weekday is None
def test_monthly_weekday_rule_fields() -> None:
rule = _rule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=2,
nth_weekday_weekday=1,
start_date="2026-09-01",
)
assert rule.nth_weekday == 2
assert rule.nth_weekday_weekday == 1
def test_monthly_weekday_last_friday_allowed() -> None:
rule = _rule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=-1,
nth_weekday_weekday=4,
start_date="2026-09-01",
)
assert rule.nth_weekday == -1
def test_yearly_rule_fields() -> None:
rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01")
assert rule.month == 6
def test_custom_days_rule_fields() -> None:
rule = _rule(
rule_type=RuleType.CUSTOM_DAYS,
weekday_set={1, 3, 5},
interval=2,
start_date="2026-09-01",
)
assert rule.weekday_set == frozenset({1, 3, 5})
assert rule.interval == 2
def test_end_date_round_trips(tmp_path) -> None:
rule = _rule(
rule_type=RuleType.DAILY,
start_date="2026-09-01",
end_date="2026-12-31",
)
assert rule.end_date == "2026-12-31"
# ---------------------------------------------------------------------------
# Invalid combinations are rejected at construction
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
# weekly without weekday_set
{"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"},
# daily carrying a weekday_set
{"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"},
# monthly_day without day_of_month
{"rule_type": "monthly_day", "start_date": "2026-09-01"},
# monthly_day out of range
{"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"},
# monthly_day carrying weekday_set
{"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0},
"start_date": "2026-09-01"},
# yearly without month
{"rule_type": "yearly", "start_date": "2026-09-01"},
# yearly out of range
{"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"},
# unknown type
{"rule_type": "bogus", "start_date": "2026-09-01"},
# interval zero
{"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"},
# interval non-integer
{"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"},
# end before start
{"rule_type": "daily", "start_date": "2026-09-01",
"end_date": "2026-08-31"},
# malformed dates
{"rule_type": "daily", "start_date": "not-a-date"},
{"rule_type": "daily", "start_date": "2026-9-1"},
# weekly weekday out of range
{"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"},
# weekly empty set
{"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"},
# custom_days carrying month-specific fields
{"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5,
"start_date": "2026-09-01"},
{"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
{"rule_type": "custom_days", "weekday_set": {0}, "month": 3,
"start_date": "2026-09-01"},
# monthly_weekday position invalid
{"rule_type": "monthly_weekday", "nth_weekday": 0,
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 6,
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 2,
"nth_weekday_weekday": None, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 2,
"nth_weekday_weekday": 7, "start_date": "2026-09-01"},
# monthly_day carrying nth fields
{"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
],
)
def test_invalid_combinations_raise(kwargs) -> None:
with pytest.raises(RuleValidationError):
_rule(**kwargs)
# ---------------------------------------------------------------------------
# Immutability
# ---------------------------------------------------------------------------
def test_rule_is_immutable() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(AttributeError):
rule.interval = 2
with pytest.raises(AttributeError):
del rule.interval
# ---------------------------------------------------------------------------
# Serialization round-trip for every rule type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"rule",
[
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01",
end_date="2026-12-31"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=4, interval=2,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None,
interval=2, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
interval=2, start_date="2026-09-01"),
],
)
def test_to_dict_from_dict_round_trips_losslessly(rule) -> None:
rebuilt = ScheduleRule.from_dict(rule.to_dict())
assert rebuilt == rule
# Round-tripping twice is stable.
assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule
def test_from_dict_rejects_unknown_fields() -> None:
with pytest.raises(RuleValidationError, match="unknown rule fields"):
ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1})
def test_from_dict_rejects_missing_rule_type() -> None:
with pytest.raises(RuleValidationError, match="missing required"):
ScheduleRule.from_dict({"interval": 1})
def test_from_dict_rejects_non_dict() -> None:
with pytest.raises(RuleValidationError):
ScheduleRule.from_dict("daily")
def test_from_dict_defaults_start_date_when_absent() -> None:
rule = ScheduleRule.from_dict({"rule_type": "daily"})
assert rule.start_date == "1970-01-01"
def test_from_dict_accepts_list_weekday_set() -> None:
rule = ScheduleRule.from_dict(
{"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]}
)
assert rule.weekday_set == frozenset({0, 2, 4})
# ---------------------------------------------------------------------------
# Equality and hashing
# ---------------------------------------------------------------------------
def test_equal_rules_compare_equal_and_hash_equal() -> None:
a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01")
assert a == b
assert hash(a) == hash(b)
def test_different_rules_compare_unequal() -> None:
a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01")
assert a != b
def test_rule_not_equal_to_other_types() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert rule != "daily"
assert (rule == 42) is False
# ---------------------------------------------------------------------------
# Purity guards: no HA imports, no database access
# ---------------------------------------------------------------------------
def test_recurrence_module_imports_only_stdlib() -> None:
"""The model must carry no Home Assistant and no DB imports."""
from pathlib import Path
source = Path(
__import__("custom_components.nestquest.recurrence",
fromlist=["__file__"]).__file__
).read_text()
offenders = _scan_forbidden_imports(source)
assert offenders == [], (
f"recurrence.py must not hold forbidden imports: {offenders}"
)
def _scan_forbidden_imports(source: str) -> list[str]:
"""The ACTUAL purity-guard logic, shared by the model scan and the
probe tests below: returns human-readable offenders (empty = clean).
Forbidden, at any relative level, for both Import and ImportFrom:
- modules containing 'homeassistant' or 'sqlite'
- modules whose LEAF name is a DB/store/schema/DAO module
- aliases importing a DB/store/schema/DAO module by leaf name
('from . import db', 'from custom_components.nestquest import db')
"""
import ast
forbidden_targets = {
"db", "schema", "dao_children", "dao_rules", "dao_presence",
"dao_instances", "migrations", "store",
}
offenders: list[str] = []
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
lowered = alias.name.lower()
leaf = lowered.split(".")[-1]
if (
"homeassistant" in lowered
or "sqlite" in lowered
or leaf in forbidden_targets
):
offenders.append(f"import {alias.name}")
elif isinstance(node, ast.ImportFrom):
module = (node.module or "").lower()
module_leaf = module.split(".")[-1] if module else ""
if (
"homeassistant" in module
or "sqlite" in module
or (module and module_leaf in forbidden_targets)
or (node.level and module_leaf in forbidden_targets)
):
offenders.append(
f"from {node.module!r} import ... (level={node.level})"
)
for alias in node.names:
alias_leaf = alias.name.split(".")[-1].lower()
if alias_leaf in forbidden_targets:
offenders.append(
f"from {node.module!r} import {alias.name} "
f"(level={node.level})"
)
return offenders
def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
"""The shared scan function catches every forbidden import shape,
including level-0 absolute package DB imports and 'from . import db'.
"""
probes = [
"from homeassistant.core import HomeAssistant",
"import sqlite3",
"from .db import NestQuestDatabase",
"from custom_components.nestquest.db import NestQuestDatabase",
"from custom_components.nestquest import db",
"import custom_components.nestquest.dao_rules",
"from . import db",
"from . import schema",
"from custom_components.nestquest.migrations import apply_migrations",
]
for probe in probes:
offenders = _scan_forbidden_imports(probe)
assert offenders, f"probe must be caught: {probe!r}"
def test_non_string_rule_type_rejected() -> None:
"""Only RuleType members and their string values construct."""
for bad in (123, True, None, 3.5, b"daily"):
with pytest.raises(RuleValidationError):
ScheduleRule(rule_type=bad, start_date="2026-09-01")
def test_weekly_rejects_month_and_nth_fields() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, day_of_month=5,
start_date="2026-09-01",
)
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, month=3,
start_date="2026-09-01",
)
def test_daily_rejects_dangling_nth_weekday_weekday() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.DAILY, nth_weekday_weekday=0,
start_date="2026-09-01",
)
def test_monthly_day_rejects_month() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=5, month=3,
start_date="2026-09-01",
)
def test_yearly_rejects_day_and_nth_fields() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-09-01",
)
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, nth_weekday=1,
nth_weekday_weekday=0, start_date="2026-09-01",
)
def test_storage_value_mapping() -> None:
"""The enum exposes the schema's CHECK values via storage_value."""
assert RuleType.DAILY.storage_value == "daily"
assert RuleType.WEEKLY.storage_value == "weekly"
assert RuleType.MONTHLY_DAY.storage_value == "monthly"
assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly"
assert RuleType.YEARLY.storage_value == "yearly"
assert RuleType.CUSTOM_DAYS.storage_value == "custom"
def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None:
"""[0, False] must NOT collapse False into 0 via set() coercion."""
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule.from_dict(
{"rule_type": "weekly", "weekday_set": [0, False]}
)
def test_direct_set_with_int_subclass_entry_rejected() -> None:
"""A hashable-but-non-plain int subclass is still not a plain int."""
class IntChild(int):
pass
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={IntChild(1)},
start_date="2026-09-01",
)
def test_from_dict_with_int_subclass_entry_rejected() -> None:
class IntChild(int):
pass
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule.from_dict(
{"rule_type": "weekly", "weekday_set": [IntChild(1)]}
)
def test_from_dict_unknown_key_types_never_leak_typeerror() -> None:
"""Mixed unhashable unknown keys must surface as
RuleValidationError, not a raw TypeError from sorting."""
with pytest.raises(RuleValidationError, match="unknown rule fields"):
ScheduleRule.from_dict({"rule_type": "daily", 42: "value"})
def test_storage_aliases_and_whitespace_are_not_model_names() -> None:
"""The model boundary is exact names only: storage strings, padded
and case variants are all rejected."""
for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"):
with pytest.raises(RuleValidationError):
ScheduleRule(rule_type=bad, start_date="2026-09-01")
def test_unhashable_int_subclass_interval_rejected() -> None:
class UnhashableInt(int):
__hash__ = None
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.DAILY,
interval=UnhashableInt(1),
start_date="2026-09-01",
)
def test_rule_type_is_a_dataclass() -> None:
import dataclasses
assert dataclasses.is_dataclass(ScheduleRule)
+301
View File
@@ -0,0 +1,301 @@
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_month_end,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings
# ---------------------------------------------------------------------------
def test_monthly_day_15_fires_on_the_15th() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
for month in range(1, 13):
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
def test_monthly_day_never_fires_on_other_days() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
start_date="2026-01-10",
)
# Every non-10th day of a short month must be False.
for day in range(1, 30):
if day != 10:
assert occurs_on(
rule, datetime.date(2026, 1, day)
) is False, f"Jan {day}"
def test_monthly_day_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
# Feb 15 precedes the start: no firing even though the day matches.
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
def test_monthly_day_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15", end_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
# ---------------------------------------------------------------------------
# Month-end clamp policy
# ---------------------------------------------------------------------------
def test_month_end_helper_is_leap_aware() -> None:
assert _month_end(2026, 1) == 31
assert _month_end(2026, 4) == 30
assert _month_end(2026, 2) == 28 # non-leap
assert _month_end(2028, 2) == 29 # leap
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
assert _month_end(2026, 12) == 31
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Months with a 31st: fire on the 31st.
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
(10, 31), (12, 31)):
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
f"2026-{month:02d}-{day:02d}"
)
# Months WITHOUT a 31st: fire on the month's last day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
# The day BEFORE the clamp is not a firing.
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
def test_day_30_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2026-01-30",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# Months with a 30th: nominal day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2026-01-29",
)
# Non-leap 2027: Feb 28 is the clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
# Leap 2028: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_day_30_feb_leap_year_clamps_to_29() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2027-01-30",
)
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# ---------------------------------------------------------------------------
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
# ---------------------------------------------------------------------------
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
"""Every (month, day) in ``rule``'s year the engine says fires."""
hits = []
for month in range(1, 13):
for day in range(1, _month_end(year, month) + 1):
if occurs_on(rule, datetime.date(year, month, day)):
hits.append((month, day))
return hits
def test_full_year_day_31_sweep_2026() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Exactly one firing per month, twelve total.
hits = _sweep_monthly(rule, 2026)
assert len(hits) == 12
by_month = dict(hits)
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
assert by_month[2] == 28 # 2026 Feb is non-leap
assert by_month[4] == 30
# Each month has exactly one firing.
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_31_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2028-01-31",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap February clamps to the 29th
assert by_month[4] == 30
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_30_sweep_2028() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2028-01-30",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb clamps to 29
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2027_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2027-01-29",
)
hits = _sweep_monthly(rule, 2027)
by_month = dict(hits)
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2028-01-29",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
months = [month for month, _ in hits]
assert months == list(range(1, 13))
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_interval_2_every_second_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-01-15",
)
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
"""Interval counts nominal months from the anchor: Jan 31 anchor,
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
start_date="2026-01-31",
)
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-11-15",
)
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
def test_monthly_day_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
assert result == [
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
"2026-05-31", "2026-06-30",
]
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-15", "2026-01-15")
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
"""The clamp must not double-fire: only one day per month matches."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
hits = _sweep_monthly(rule, 2026)
months = [month for month, _ in hits]
assert months == sorted(months)
assert len(months) == len(set(months)) == 12
+299
View File
@@ -0,0 +1,299 @@
"""Tests for weekly and custom day-set recurrence evaluation."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# 2026-09-01 is a TUESDAY (weekday 1).
# Anchor-week arithmetic (start_date = Tue Sep 1): week 0 spans
# Sep 1-7, week 1 spans Sep 8-14, week 2 spans Sep 15-21, and so on —
# whole weeks elapsed from the anchor, NOT calendar-Sunday weeks.
# A weekly Mon/Wed/Fri (0,2,4) rule at interval 1 anchored here fires
# on Wed Sep 2, Fri Sep 4 (both week 0), then Mon Sep 7, Wed Sep 9,
# Fri Sep 11 (week 1), Mon Sep 14... (week 2), and so on.
def test_weekly_fires_exactly_on_set_weekdays() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01",
)
# Sep 2026: 1=Tue(1), 2=Wed(2), 4=Fri(4), 7=Mon(0), 8=Tue(1), 9=Wed,
# 10=Thu(3), 11=Fri(4)
assert occurs_on(rule, _d("2026-09-01")) is False # Tue NOT in set
assert occurs_on(rule, _d("2026-09-02")) is True # Wed in set
assert occurs_on(rule, _d("2026-09-03")) is False # Thu not in set
assert occurs_on(rule, _d("2026-09-04")) is True # Fri in set
assert occurs_on(rule, _d("2026-09-05")) is False # Sat
assert occurs_on(rule, _d("2026-09-06")) is False # Sun
assert occurs_on(rule, _d("2026-09-07")) is True # Mon
assert occurs_on(rule, _d("2026-09-08")) is False # Tue not in set
def test_weekly_start_date_itself_may_not_be_in_set() -> None:
"""start_date anchors the WEEK count, not a firing day: a Tuesday
start for a Mon/Wed/Fri rule fires on Wed Sep 2, not Sep 1."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01",
)
assert occurs_on(rule, _d("2026-09-01")) is False # Tue=1, not in set
assert occurs_on(rule, _d("2026-09-02")) is True # Wed=2
assert occurs_on(rule, _d("2026-09-04")) is True # Fri=4
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, next week
def test_weekly_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={2}, # Wednesdays
start_date="2026-09-02", # a Wednesday
)
# Wed Sep 2 is the anchor day and in-set: fires. Wed Aug 26 (one
# week BEFORE the anchor, in-set) must not fire: pre-window.
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-08-26")) is False # Wed, before start
assert occurs_on(rule, _d("2026-09-09")) is True # Wed, next week
def test_weekly_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01", end_date="2026-09-10",
)
assert occurs_on(rule, _d("2026-09-09")) is True # Wed within end
assert occurs_on(rule, _d("2026-09-11")) is False # Fri after end
def test_weekly_interval_2_fires_on_alternate_weeks() -> None:
"""Interval 2: fires on set weekdays only in even week-offsets."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", # a Monday
)
# Week 0 (Sep 7-13): fires Mon Sep 7.
assert occurs_on(rule, _d("2026-09-07")) is True
# Week 1 (Sep 14-20): skipped.
assert occurs_on(rule, _d("2026-09-14")) is False
# Week 2 (Sep 21-27): fires Mon Sep 21.
assert occurs_on(rule, _d("2026-09-21")) is True
# Week 3 (Sep 28-Oct 4): skipped; Mon Sep 28 does not fire.
assert occurs_on(rule, _d("2026-09-28")) is False
# Week 4 (Oct 5-11): fires Mon Oct 5.
assert occurs_on(rule, _d("2026-10-05")) is True
def test_weekly_interval_3_every_third_week() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={3}, interval=3,
start_date="2026-09-03", # a Thursday
)
assert occurs_on(rule, _d("2026-09-03")) is True # week 0
assert occurs_on(rule, _d("2026-09-10")) is False # week 1
assert occurs_on(rule, _d("2026-09-17")) is False # week 2
assert occurs_on(rule, _d("2026-09-24")) is True # week 3
assert occurs_on(rule, _d("2026-10-15")) is True # week 6
def test_weekly_interval_2_never_fires_in_odd_weeks_across_months() -> None:
"""The week offset is anchored to start_date across month edges."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={2}, interval=2,
start_date="2026-09-02", # a Wednesday
)
# Week 0: Wed Sep 2 fires. Week 1: Wed Sep 9 skipped.
# Week 2: Wed Sep 16 fires. Week 3: Wed Sep 23 skipped.
# Week 4 (Oct): Wed Sep 30 fires (still week 4!). Week 5: Wed Oct 7 skipped.
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-09-09")) is False
assert occurs_on(rule, _d("2026-09-16")) is True
assert occurs_on(rule, _d("2026-09-23")) is False
assert occurs_on(rule, _d("2026-09-30")) is True
assert occurs_on(rule, _d("2026-10-07")) is False
assert occurs_on(rule, _d("2026-10-14")) is True
def test_custom_days_interval_1_behaves_like_weekly() -> None:
"""CUSTOM_DAYS with interval 1 behaves identically to WEEKLY."""
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
start_date="2026-09-01",
)
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
start_date="2026-09-01",
)
# Whole month of September must match exactly.
for day in range(1, 31):
date = datetime.date(2026, 9, day)
assert occurs_on(weekly, date) == occurs_on(custom, date), date
def test_custom_days_never_fires_before_start(tmp_path) -> None:
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={0},
start_date="2026-09-07", # a Monday
)
assert occurs_on(rule, _d("2026-09-06")) is False # Sun, before
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, on
def test_hand_checked_8_week_sequence_weekly_mon_wed_fri() -> None:
"""The done-condition's hand-checked sequence: 8 weeks of a
Mon/Wed/Fri rule anchored at Tue 2026-09-01, interval 1.
Anchor weeks from Tue Sep 1 (week 0 = Sep 1-7, week 1 = Sep 8-14,
... week 7 = Oct 20-26). Mon/Wed/Fri firings (interval 1, so every
in-set weekday fires regardless of week):
Week 0 (Sep 1-7): Wed 2, Fri 4 (Mon Sep 7 is ALSO week 0:
6 elapsed days // 7 = 0 — the anchor week
boundary sits on Sep 8, not on the Monday)
Week 1 (Sep 8-14): Wed 9, Fri 11, Mon 14 (13 elapsed days // 7 = 1)
Week 2 (Sep 15-21): Wed 16, Fri 18, Mon 21
Week 3 (Sep 22-28): Wed 23, Fri 25, Mon 28
Week 4 (Sep 29-Oct 5): Wed 30, Fri 2 (Oct), Mon 5 (Oct)
Week 5 (Oct 6-12): Wed 7, Fri 9, Mon 12
Week 6 (Oct 13-19): Wed 14, Fri 16, Mon 19
Week 7 (Oct 20-26): Wed 21, Fri 23, Mon 26
"""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01", end_date="2026-10-26",
)
expected = [
"2026-09-02", "2026-09-04",
"2026-09-07", "2026-09-09", "2026-09-11",
"2026-09-14", "2026-09-16", "2026-09-18",
"2026-09-21", "2026-09-23", "2026-09-25",
"2026-09-28", "2026-09-30", "2026-10-02",
"2026-10-05", "2026-10-07", "2026-10-09",
"2026-10-12", "2026-10-14", "2026-10-16",
"2026-10-19", "2026-10-21", "2026-10-23", "2026-10-26",
]
actual = occurrences_between(rule, "2026-09-01", "2026-10-26")
assert actual == expected, (
f"diff: {set(expected) ^ set(actual)}"
)
def test_hand_checked_8_week_sequence_weekly_interval_2_mondays() -> None:
"""8 weeks of a Monday rule at interval 2, anchored Mon 2026-09-07
(week 0 = Sep 7-13): fires in weeks 0, 2, 4, 6 -> Mon Sep 7, Mon Sep 21,
Mon Oct 5, Mon Oct 19."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", end_date="2026-10-31",
)
actual = occurrences_between(rule, "2026-09-07", "2026-10-31")
assert actual == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
def test_custom_days_hand_checked_8_week_sequence() -> None:
"""8 weeks of a Tue/Thu/Sun rule (1,3,6) anchored Mon 2026-09-07:
fires Tue 8, Thu 10, Sun 13, then weekly."""
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 6},
start_date="2026-09-07",
)
# Anchor weeks from Mon Sep 7 (w0 = Sep 7-13 ... w7 = Oct 26-Nov 1):
# Tue/Thu/Sun firings per week, 8 weeks fully covered.
actual = occurrences_between(rule, "2026-09-07", "2026-11-01")
assert actual == [
"2026-09-08", "2026-09-10", "2026-09-13",
"2026-09-15", "2026-09-17", "2026-09-20",
"2026-09-22", "2026-09-24", "2026-09-27",
"2026-09-29", "2026-10-01", "2026-10-04",
"2026-10-06", "2026-10-08", "2026-10-11",
"2026-10-13", "2026-10-15", "2026-10-18",
"2026-10-20", "2026-10-22", "2026-10-25",
"2026-10-27", "2026-10-29", "2026-11-01",
]
def test_weekly_survives_53_week_year_boundary() -> None:
"""Anchor arithmetic never flips at Jan 1 (the ISO parity trap):
a weekly rule spanning New Year keeps its weekday pattern."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={3}, # Thursdays
start_date="2026-12-24", # a Thursday
)
# Thursdays around New Year 2026->2027: Dec 24, 31, Jan 7, 14.
assert occurs_on(rule, _d("2026-12-24")) is True
assert occurs_on(rule, _d("2026-12-31")) is True
assert occurs_on(rule, _d("2027-01-07")) is True
assert occurs_on(rule, _d("2027-01-14")) is True
# Interleaved days are not Thursdays.
assert occurs_on(rule, _d("2027-01-04")) is False # Monday
assert occurs_on(rule, _d("2027-01-05")) is False # Tuesday
def test_weekly_interval_2_across_year_boundary() -> None:
"""Interval weeks continue across Jan 1 with no parity flip."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={4}, interval=2,
start_date="2026-12-25", # a Friday
)
# Weeks: Dec 25 (w0) fires, Jan 1 (w1) skipped, Jan 8 (w2) fires,
# Jan 15 (w3) skipped, Jan 22 (w4) fires.
assert occurs_on(rule, _d("2026-12-25")) is True
assert occurs_on(rule, _d("2027-01-01")) is False
assert occurs_on(rule, _d("2027-01-08")) is True
assert occurs_on(rule, _d("2027-01-15")) is False
assert occurs_on(rule, _d("2027-01-22")) is True
def test_occurrences_between_weekly_disjoint(tmp_path) -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-09-01", end_date="2026-09-30",
)
assert occurrences_between(rule, "2026-10-01", "2026-10-31") == []
def test_occurrences_between_weekly_interval_2_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", end_date="2026-10-31",
)
result = occurrences_between(rule, "2026-09-01", "2026-10-31")
assert result == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
def test_occurrences_between_rejects_inverted_bounds() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01"
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-10-10", "2026-09-01")
def test_weekly_interval_2_non_monday_anchor_boundary() -> None:
"""The anchor week is anchored at start_date, NOT a calendar week:
a Tuesday-anchored Monday rule fires on Mon Sep 7 (week 0 extends
through Sep 7), skips Mon Sep 14 (week 1), fires Mon Sep 21 (week 2)."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-01", # a TUESDAY anchor
)
# Sep 7 - Sep 1 = 6 days -> week 0 (even): fires.
assert occurs_on(rule, _d("2026-09-07")) is True
# Sep 14: 13 days -> week 1 (odd): skipped.
assert occurs_on(rule, _d("2026-09-14")) is False
# Sep 21: 20 days -> week 2 (even): fires.
assert occurs_on(rule, _d("2026-09-21")) is True
# Sep 28: 27 days -> week 3 (odd): skipped.
assert occurs_on(rule, _d("2026-09-28")) is False
# Oct 5: 34 days -> week 4 (even): fires.
assert occurs_on(rule, _d("2026-10-05")) is True
-2037
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -100,14 +100,11 @@ def test_executor_helper_creates_directory(tmp_path) -> None:
assert nested.is_dir()
def test_no_sqlite3_import_outside_db_module() -> None:
"""sqlite3 is banned package-wide except db.py, the connection wrapper."""
def test_no_sqlite3_import_in_package() -> None:
pkg_dir = Path(store.__file__).parent
py_files = list(pkg_dir.glob("*.py"))
assert py_files
for py_file in py_files:
if py_file.name == "db.py":
continue
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Import):